diff --git a/resources/config/config.json b/resources/config/config.json new file mode 100644 index 0000000..c4f61c3 --- /dev/null +++ b/resources/config/config.json @@ -0,0 +1 @@ +{"hotkey": "ctrl+l", "opacity": 0.3, "notificationsEnabled": false} \ No newline at end of file diff --git a/resources/img/icon.ico b/resources/img/icon.ico new file mode 100644 index 0000000..acbf35b Binary files /dev/null and b/resources/img/icon.ico differ diff --git a/resources/img/icon.png b/resources/img/icon.png new file mode 100644 index 0000000..db86441 Binary files /dev/null and b/resources/img/icon.png differ diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/config/config.py b/src/config/config.py new file mode 100644 index 0000000..24f4a90 --- /dev/null +++ b/src/config/config.py @@ -0,0 +1,40 @@ +import json +import os.path +import shutil + +from src.util.path_util import get_packaged_path, get_config_path +from src.util.web_browser_util import open_about + +BUNDLED_CONFIG_FILE = os.path.join("resources", "config", "config.json") +DEFAULT_HOTKEY = "ctrl+l" + + +def load(): + try: + with open(get_config_path(), "r") as f: + return json.load(f) + except (FileNotFoundError, json.JSONDecodeError): + shutil.copy(get_packaged_path(BUNDLED_CONFIG_FILE), get_config_path()) + with open(get_config_path(), "r") as f: + return json.load(f) + + +class Config: + def __init__(self) -> None: + config = load() + self.hotkey = config.get("hotkey", DEFAULT_HOTKEY) if config else DEFAULT_HOTKEY + self.opacity = config.get("opacity", 0.3) if config else 0.3 + self.notifications_enabled = config.get("notificationsEnabled", True) if config else True + if not config: + open_about() + self.save() + + def save(self) -> None: + print(f'saving to: {get_config_path()}') + with open(get_config_path(), "w") as f: + config = { + "hotkey": self.hotkey, + "opacity": self.opacity, + "notificationsEnabled": self.notifications_enabled, + } + json.dump(config, f) diff --git a/src/keyboard_controller/__init__.py b/src/keyboard_controller/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/keyboard_controller/hotkey_listener.py b/src/keyboard_controller/hotkey_listener.py new file mode 100644 index 0000000..be7c1b4 --- /dev/null +++ b/src/keyboard_controller/hotkey_listener.py @@ -0,0 +1,24 @@ +import threading +import time + +import keyboard + + +class HotkeyListener: + def __init__(self, main): + self.main = main + + def start_hotkey_listener_thread(self) -> None: + keyboard.stash_state() + with self.main.hotkey_lock: + self.main.listen_for_hotkey = True + if self.main.hotkey_thread and threading.current_thread() is not self.main.hotkey_thread and self.main.hotkey_thread.is_alive(): + self.main.hotkey_thread.join() + self.main.hotkey_thread = threading.Thread(target=self.hotkey_listener, daemon=True) + self.main.hotkey_thread.start() + + def hotkey_listener(self) -> None: + keyboard.add_hotkey(self.main.config.hotkey, self.main.send_hotkey_signal, suppress=False) + while self.main.listen_for_hotkey: + time.sleep(1) + keyboard.unhook_all_hotkeys() diff --git a/src/keyboard_controller/pressed_events_handler.py b/src/keyboard_controller/pressed_events_handler.py new file mode 100644 index 0000000..c2e8202 --- /dev/null +++ b/src/keyboard_controller/pressed_events_handler.py @@ -0,0 +1,17 @@ +import time + +import keyboard + + +def clear_pressed_events() -> None: + while True: + # Hotkeys stop working after windows locks & unlocks + # https://github.com/boppreh/keyboard/issues/223 + deleted = [] + with keyboard._pressed_events_lock: + for k in list(keyboard._pressed_events.keys()): + item = keyboard._pressed_events[k] + if time.time() - item.time > 2: + deleted.append(item.name) + del keyboard._pressed_events[k] + time.sleep(1) diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..538bd10 --- /dev/null +++ b/src/main.py @@ -0,0 +1,80 @@ +import threading +import time +from queue import Queue + +import keyboard + +from src.config.config import Config +from src.keyboard_controller.hotkey_listener import HotkeyListener +from src.keyboard_controller.pressed_events_handler import clear_pressed_events +from src.os_controller.notifications import send_notification_in_thread +from src.os_controller.tray_icon import TrayIcon +from src.ui.overlay_window import OverlayWindow +from src.ui.update_window import UpdateWindow +from src.util.lockfile_handler import check_lockfile, remove_lockfile + + +class CatLockCore: + def __init__(self) -> None: + self.hotkey_thread = None + self.show_overlay_queue = Queue() + self.config = Config() + self.root = None + self.hotkey_lock = threading.Lock() + self.listen_for_hotkey = True + self.program_running = True + self.blocked_keys = set() + self.changing_hotkey_queue = Queue() + self.start_hotkey_listener() + self.clear_pressed_events_thread = threading.Thread(target=clear_pressed_events, daemon=True) + self.clear_pressed_events_thread.start() + self.tray_icon_thread = threading.Thread(target=self.create_tray_icon, daemon=True) + self.tray_icon_thread.start() + + def create_tray_icon(self) -> None: + TrayIcon(main=self).open() + + def start_hotkey_listener(self) -> None: + HotkeyListener(self).start_hotkey_listener_thread() + + def lock_keyboard(self) -> None: + self.blocked_keys.clear() + for i in range(150): + keyboard.block_key(i) + self.blocked_keys.add(i) + send_notification_in_thread(self.config.notifications_enabled) + + def unlock_keyboard(self, event=None) -> None: + for key in self.blocked_keys: + keyboard.unblock_key(key) + self.blocked_keys.clear() + if self.root: + self.root.destroy() + keyboard.stash_state() + + def send_hotkey_signal(self) -> None: + self.show_overlay_queue.put(True) + + def quit_program(self, icon, item) -> None: + remove_lockfile() + self.program_running = False + self.unlock_keyboard() + icon.stop() + + def start(self) -> None: + check_lockfile() + UpdateWindow(self).prompt_update() + # hack to prevent right ctrl sticking + keyboard.remap_key('right ctrl', 'left ctrl') + while self.program_running: + if not self.show_overlay_queue.empty(): + self.show_overlay_queue.get(block=False) + overlay = OverlayWindow(main=self) + keyboard.stash_state() + overlay.open() + time.sleep(.1) + + +if __name__ == "__main__": + core = CatLockCore() + core.start() diff --git a/src/os_controller/__init__.py b/src/os_controller/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/os_controller/notifications.py b/src/os_controller/notifications.py new file mode 100644 index 0000000..2679180 --- /dev/null +++ b/src/os_controller/notifications.py @@ -0,0 +1,26 @@ +import os +import threading +import time + +import plyer + +from src.util.path_util import get_packaged_path + + +def send_lock_notification() -> None: + path = os.path.join("resources", "img", "icon.ico") + plyer.notification.notify( + app_name="CatLock", + title="Keyboard Locked", + message="Click on screen to unlock", + app_icon=get_packaged_path(path), + timeout=3, + ) + time.sleep(.1) + + +def send_notification_in_thread(notifications_enabled: bool) -> None: + if notifications_enabled: + notification_thread = threading.Thread(target=send_lock_notification, daemon=True) + notification_thread.start() + notification_thread.join() diff --git a/src/os_controller/tray_icon.py b/src/os_controller/tray_icon.py new file mode 100644 index 0000000..fabc9e8 --- /dev/null +++ b/src/os_controller/tray_icon.py @@ -0,0 +1,50 @@ +import os + +from PIL import Image, ImageDraw +from pystray import Icon, Menu, MenuItem + +from src.util.path_util import get_packaged_path +from src.util.web_browser_util import open_about, open_buy_me_a_coffee, open_help + + +class TrayIcon: + def __init__(self, main): + self.main = main + + def set_opacity(self, opacity: float) -> None: + self.main.config.opacity = opacity + self.main.config.save() + + def toggle_notifications(self) -> None: + self.main.config.notifications_enabled = not self.main.config.notifications_enabled + self.main.config.save() + + def is_opacity_checked(self, opacity: float) -> bool: + return self.main.config.opacity == opacity + + def open(self) -> None: + path = os.path.join("resources", "img", "icon.png") + image = Image.open(get_packaged_path(path)) + draw = ImageDraw.Draw(image) + draw.rectangle((16, 16, 48, 48), fill="white") + menu = Menu( + MenuItem( + "Enable/Disable Notifications", + self.toggle_notifications, + checked=lambda item: self.main.config.notifications_enabled, + ), + MenuItem("Set Opacity", Menu( + MenuItem("5%", lambda: self.set_opacity(0.05), checked=lambda item: self.is_opacity_checked(0.05)), + MenuItem("10%", lambda: self.set_opacity(0.1), checked=lambda item: self.is_opacity_checked(0.1)), + MenuItem("30%", lambda: self.set_opacity(0.3), checked=lambda item: self.is_opacity_checked(0.3)), + MenuItem("50%", lambda: self.set_opacity(0.5), checked=lambda item: self.is_opacity_checked(0.5)), + MenuItem("70%", lambda: self.set_opacity(0.7), checked=lambda item: self.is_opacity_checked(0.7)), + MenuItem("90%", lambda: self.set_opacity(0.9), checked=lambda item: self.is_opacity_checked(0.9)), + )), + MenuItem("Help", open_help), + MenuItem("About", open_about), + MenuItem("Support ☕", open_buy_me_a_coffee), + MenuItem("Quit", self.main.quit_program), + ) + tray_icon = Icon("CatLock", image, "CatLock", menu) + tray_icon.run() diff --git a/src/ui/__init__.py b/src/ui/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/ui/overlay_window.py b/src/ui/overlay_window.py new file mode 100644 index 0000000..c615989 --- /dev/null +++ b/src/ui/overlay_window.py @@ -0,0 +1,27 @@ +import tkinter as tk + +from screeninfo import get_monitors + + +class OverlayWindow: + def __init__(self, main): + self.main = main + + def open(self) -> None: + monitors = get_monitors() + + # Calculate combined geometry of all monitors + total_width = sum([monitor.width for monitor in monitors]) + max_height = max([monitor.height for monitor in monitors]) + min_x = min([monitor.x for monitor in monitors]) + min_y = min([monitor.y for monitor in monitors]) + + self.main.root = tk.Tk() + self.main.root.overrideredirect(True) # Remove window decorations + self.main.root.geometry(f'{total_width}x{max_height}+{min_x}+{min_y}') + self.main.root.attributes('-topmost', True) + self.main.root.attributes('-alpha', self.main.config.opacity) + self.main.root.bind('', self.main.unlock_keyboard) + + self.main.lock_keyboard() + self.main.root.mainloop() \ No newline at end of file diff --git a/src/ui/update_window.py b/src/ui/update_window.py new file mode 100644 index 0000000..e8f46d8 --- /dev/null +++ b/src/ui/update_window.py @@ -0,0 +1,18 @@ +import tkinter as tk +from tkinter import messagebox + +from src.util.update_util import is_update_available +from src.util.web_browser_util import open_download + + +class UpdateWindow: + def __init__(self, main): + self.main = main + + def prompt_update(self): + if is_update_available(): + self.main.root = tk.Tk() + self.main.root.withdraw() + if messagebox.askyesno('Update Available', 'A new version of CatLock is available. Do you want to update?'): + open_download() + self.main.root.destroy() diff --git a/src/util/__init__.py b/src/util/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/util/lockfile_handler.py b/src/util/lockfile_handler.py new file mode 100644 index 0000000..e858539 --- /dev/null +++ b/src/util/lockfile_handler.py @@ -0,0 +1,24 @@ +import os +import signal +from pathlib import Path + +home = str(Path.home()) +LOCKFILE_PATH = os.path.join(home, '.catlock', 'lockfile.lock') + + +def check_lockfile(): + if os.path.exists(LOCKFILE_PATH): + with open(LOCKFILE_PATH, 'r') as f: + pid = int(f.read().strip()) + try: + os.kill(pid, signal.SIGTERM) # Kill the old process + except Exception as e: + # Process not found. It might have already been terminated. + pass + with open(LOCKFILE_PATH, 'w') as f: + f.write(str(os.getpid())) + + +def remove_lockfile(): + if os.path.exists(LOCKFILE_PATH): + os.remove(LOCKFILE_PATH) diff --git a/src/util/path_util.py b/src/util/path_util.py new file mode 100644 index 0000000..1d889be --- /dev/null +++ b/src/util/path_util.py @@ -0,0 +1,20 @@ +import os +import sys +from pathlib import Path + + +def get_packaged_path(path: str) -> str: + try: + wd = sys._MEIPASS + return os.path.abspath(os.path.join(wd, path)) + except: + base = Path(__file__).parent.parent.parent + return os.path.join(base, path) + + +def get_config_path() -> str: + home = str(Path.home()) + config_dir = os.path.join(home, '.catlock', 'config') + if not os.path.exists(config_dir): + os.makedirs(config_dir) + return os.path.join(config_dir, "config.json") diff --git a/src/util/update_util.py b/src/util/update_util.py new file mode 100644 index 0000000..2110e10 --- /dev/null +++ b/src/util/update_util.py @@ -0,0 +1,16 @@ +import requests + +VERSION = 'v1.0.0' + +LATEST_RELEASE_URL = 'https://api.github.com/repos/richiehowelll/cat-lock/releases/latest' + + +def is_update_available() -> bool: + response = requests.get(LATEST_RELEASE_URL) + + if response.status_code == 200: + release_data = response.json() + if release_data.get("name") != VERSION: + return True + + return False diff --git a/src/util/web_browser_util.py b/src/util/web_browser_util.py new file mode 100644 index 0000000..b9bdd3b --- /dev/null +++ b/src/util/web_browser_util.py @@ -0,0 +1,18 @@ +import webbrowser + + +def open_about(): + webbrowser.open("https://catlock.app/about/", new=2) + + +def open_buy_me_a_coffee(): + webbrowser.open("https://buymeacoffee.com/richiehowelll", new=2) + + +def open_help(): + webbrowser.open("https://catlock.app/faq/", new=2) + + +def open_download(): + webbrowser.open("https://catlock.app/download/", new=2) +