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