Add files via upload
This commit is contained in:
1
resources/config/config.json
Normal file
1
resources/config/config.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"hotkey": "ctrl+l", "opacity": 0.3, "notificationsEnabled": false}
|
||||||
BIN
resources/img/icon.ico
Normal file
BIN
resources/img/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
BIN
resources/img/icon.png
Normal file
BIN
resources/img/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 31 KiB |
0
src/__init__.py
Normal file
0
src/__init__.py
Normal file
0
src/config/__init__.py
Normal file
0
src/config/__init__.py
Normal file
40
src/config/config.py
Normal file
40
src/config/config.py
Normal file
@@ -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)
|
||||||
0
src/keyboard_controller/__init__.py
Normal file
0
src/keyboard_controller/__init__.py
Normal file
24
src/keyboard_controller/hotkey_listener.py
Normal file
24
src/keyboard_controller/hotkey_listener.py
Normal file
@@ -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()
|
||||||
17
src/keyboard_controller/pressed_events_handler.py
Normal file
17
src/keyboard_controller/pressed_events_handler.py
Normal file
@@ -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)
|
||||||
80
src/main.py
Normal file
80
src/main.py
Normal file
@@ -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()
|
||||||
0
src/os_controller/__init__.py
Normal file
0
src/os_controller/__init__.py
Normal file
26
src/os_controller/notifications.py
Normal file
26
src/os_controller/notifications.py
Normal file
@@ -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()
|
||||||
50
src/os_controller/tray_icon.py
Normal file
50
src/os_controller/tray_icon.py
Normal file
@@ -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()
|
||||||
0
src/ui/__init__.py
Normal file
0
src/ui/__init__.py
Normal file
27
src/ui/overlay_window.py
Normal file
27
src/ui/overlay_window.py
Normal file
@@ -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('<Button-1>', self.main.unlock_keyboard)
|
||||||
|
|
||||||
|
self.main.lock_keyboard()
|
||||||
|
self.main.root.mainloop()
|
||||||
18
src/ui/update_window.py
Normal file
18
src/ui/update_window.py
Normal file
@@ -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()
|
||||||
0
src/util/__init__.py
Normal file
0
src/util/__init__.py
Normal file
24
src/util/lockfile_handler.py
Normal file
24
src/util/lockfile_handler.py
Normal file
@@ -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)
|
||||||
20
src/util/path_util.py
Normal file
20
src/util/path_util.py
Normal file
@@ -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")
|
||||||
16
src/util/update_util.py
Normal file
16
src/util/update_util.py
Normal file
@@ -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
|
||||||
18
src/util/web_browser_util.py
Normal file
18
src/util/web_browser_util.py
Normal file
@@ -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)
|
||||||
|
|
||||||
Reference in New Issue
Block a user