From 3e625a9669ef9df358c1caa700b7166498eee759 Mon Sep 17 00:00:00 2001 From: serversdwn Date: Mon, 23 Feb 2026 21:31:18 +0000 Subject: [PATCH] Initial Commit - Cat mode for windows. --- README.md | 35 +++++ main.py | 347 ++++++++++++++++++++++++++++++++++++++++++ requirements.txt | 3 + scripts/build_exe.ps1 | 11 ++ scripts/make_icon.py | 23 +++ 5 files changed, 419 insertions(+) create mode 100644 README.md create mode 100644 main.py create mode 100644 requirements.txt create mode 100644 scripts/build_exe.ps1 create mode 100644 scripts/make_icon.py diff --git a/README.md b/README.md new file mode 100644 index 0000000..9f7d279 --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# Cat Mode (Windows 10) + +A tiny desktop app that toggles **Cat Mode**: blocks *physical* keyboard and trackpad/mouse input so your cat can sit on the laptop without wrecking your work. Injected input (from software like ShareMouse) is still allowed. + +## How it Works +- Installs low-level Windows hooks for keyboard and mouse. +- When Cat Mode is ON, **physical** keyboard/mouse events are blocked. +- **Injected input** is allowed, so remote/KVM software can still control the machine. + +## Run +```bash +python -m pip install -r requirements.txt +python main.py +``` + +## Tray Icon +- The app shows a tray icon (if dependencies are installed). +- Closing the window hides it to the tray. +- Use the tray menu to toggle Cat Mode, show the window, or quit. + +## Emergency Unlock +- **Ctrl + Shift + F12** (physical keyboard) always disables Cat Mode. + +## Notes +- If your KVM is a *hardware* USB device, its input may appear as physical and be blocked. +- If hooks fail to install, try running the script as Administrator. + +## Future Ideas +- LAN trigger +- Auto-timeout + +## Build Single EXE +```powershell +.\scripts\build_exe.ps1 +``` diff --git a/main.py b/main.py new file mode 100644 index 0000000..09053de --- /dev/null +++ b/main.py @@ -0,0 +1,347 @@ +import ctypes +import threading +import queue +import sys +import tkinter as tk +from tkinter import messagebox +from ctypes import wintypes + +# Some Python builds don't expose common WinAPI types in ctypes.wintypes +if not hasattr(wintypes, "ULONG_PTR"): + wintypes.ULONG_PTR = ctypes.c_ulonglong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_ulong +if not hasattr(wintypes, "LRESULT"): + wintypes.LRESULT = ctypes.c_longlong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_long +if not hasattr(wintypes, "WPARAM"): + wintypes.WPARAM = wintypes.ULONG_PTR +if not hasattr(wintypes, "LPARAM"): + wintypes.LPARAM = ctypes.c_longlong if ctypes.sizeof(ctypes.c_void_p) == 8 else ctypes.c_long +if not hasattr(wintypes, "INT"): + wintypes.INT = ctypes.c_int + +try: + import pystray + from PIL import Image, ImageDraw + TRAY_AVAILABLE = True +except Exception: + TRAY_AVAILABLE = False + +# Windows constants +WH_KEYBOARD_LL = 13 +WH_MOUSE_LL = 14 +WM_KEYDOWN = 0x0100 +WM_SYSKEYDOWN = 0x0104 +WM_KEYUP = 0x0101 +WM_SYSKEYUP = 0x0105 +WM_QUIT = 0x0012 +HC_ACTION = 0 + +VK_F12 = 0x7B +VK_CONTROL = 0x11 +VK_SHIFT = 0x10 + +LLKHF_INJECTED = 0x10 +LLMHF_INJECTED = 0x01 + +FORMAT_MESSAGE_FROM_SYSTEM = 0x00001000 + +user32 = ctypes.WinDLL("user32", use_last_error=True) +kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) + +# Function prototypes (helps avoid silent failures on some Python builds) +user32.SetWindowsHookExW.argtypes = [ + wintypes.INT, + wintypes.HANDLE, + wintypes.HINSTANCE, + wintypes.DWORD, +] +user32.SetWindowsHookExW.restype = wintypes.HHOOK +user32.CallNextHookEx.argtypes = [wintypes.HHOOK, wintypes.INT, wintypes.WPARAM, wintypes.LPARAM] +user32.CallNextHookEx.restype = wintypes.LRESULT +user32.UnhookWindowsHookEx.argtypes = [wintypes.HHOOK] +user32.UnhookWindowsHookEx.restype = wintypes.BOOL +user32.GetMessageW.argtypes = [ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT] +user32.GetMessageW.restype = wintypes.BOOL +user32.TranslateMessage.argtypes = [ctypes.POINTER(wintypes.MSG)] +user32.DispatchMessageW.argtypes = [ctypes.POINTER(wintypes.MSG)] +user32.PostThreadMessageW.argtypes = [wintypes.DWORD, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM] +user32.PostThreadMessageW.restype = wintypes.BOOL + + +class KBDLLHOOKSTRUCT(ctypes.Structure): + _fields_ = [ + ("vkCode", wintypes.DWORD), + ("scanCode", wintypes.DWORD), + ("flags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", wintypes.ULONG_PTR), + ] + + +class MSLLHOOKSTRUCT(ctypes.Structure): + _fields_ = [ + ("pt", wintypes.POINT), + ("mouseData", wintypes.DWORD), + ("flags", wintypes.DWORD), + ("time", wintypes.DWORD), + ("dwExtraInfo", wintypes.ULONG_PTR), + ] + + +LowLevelKeyboardProc = ctypes.WINFUNCTYPE( + wintypes.LRESULT, wintypes.INT, wintypes.WPARAM, wintypes.LPARAM +) +LowLevelMouseProc = ctypes.WINFUNCTYPE( + wintypes.LRESULT, wintypes.INT, wintypes.WPARAM, wintypes.LPARAM +) + +# Globals shared between threads +cat_mode = False +ui_queue = queue.Queue() +quit_event = threading.Event() +ctrl_down = False +shift_down = False + +hook_thread_id = None +kbd_hook = None +mouse_hook = None +tray_icon = None + + +def _is_ctrl_shift_down(): + return ( + user32.GetAsyncKeyState(VK_CONTROL) & 0x8000 + and user32.GetAsyncKeyState(VK_SHIFT) & 0x8000 + ) + + +def set_cat_mode(enabled: bool): + global cat_mode + if cat_mode == enabled: + return + cat_mode = enabled + ui_queue.put(("status", cat_mode)) + + +@LowLevelKeyboardProc +def keyboard_proc(n_code, w_param, l_param): + global ctrl_down, shift_down + if n_code == HC_ACTION: + kb = ctypes.cast(l_param, ctypes.POINTER(KBDLLHOOKSTRUCT)).contents + injected = (kb.flags & LLKHF_INJECTED) != 0 + if not injected: + if w_param in (WM_KEYDOWN, WM_SYSKEYDOWN): + if kb.vkCode == VK_CONTROL: + ctrl_down = True + elif kb.vkCode == VK_SHIFT: + shift_down = True + elif w_param in (WM_KEYUP, WM_SYSKEYUP): + if kb.vkCode == VK_CONTROL: + ctrl_down = False + elif kb.vkCode == VK_SHIFT: + shift_down = False + if cat_mode and not injected: + # Emergency unlock combo: Ctrl+Shift+F12 + if w_param in (WM_KEYDOWN, WM_SYSKEYDOWN): + if kb.vkCode == VK_F12 and (ctrl_down and shift_down): + set_cat_mode(False) + return user32.CallNextHookEx(kbd_hook, n_code, w_param, l_param) + return 1 # block physical key + return user32.CallNextHookEx(kbd_hook, n_code, w_param, l_param) + + +@LowLevelMouseProc +def mouse_proc(n_code, w_param, l_param): + if n_code == HC_ACTION: + ms = ctypes.cast(l_param, ctypes.POINTER(MSLLHOOKSTRUCT)).contents + injected = (ms.flags & LLMHF_INJECTED) != 0 + if cat_mode and not injected: + return 1 # block physical mouse/trackpad + return user32.CallNextHookEx(mouse_hook, n_code, w_param, l_param) + + +def hook_loop(): + global hook_thread_id, kbd_hook, mouse_hook + hook_thread_id = kernel32.GetCurrentThreadId() + + # For low-level hooks, hMod can be NULL; some systems misbehave when passing the EXE handle. + hmod = None + kbd_hook = user32.SetWindowsHookExW(WH_KEYBOARD_LL, keyboard_proc, hmod, 0) + mouse_hook = user32.SetWindowsHookExW(WH_MOUSE_LL, mouse_proc, hmod, 0) + + if not kbd_hook or not mouse_hook: + err = ctypes.get_last_error() + buf = ctypes.create_unicode_buffer(512) + kernel32.FormatMessageW( + FORMAT_MESSAGE_FROM_SYSTEM, + None, + err, + 0, + buf, + len(buf), + None, + ) + msg = buf.value.strip() or "Unknown error" + ui_queue.put(("error", f"Hook install failed (error {err}): {msg}")) + return + + msg = wintypes.MSG() + while not quit_event.is_set() and user32.GetMessageW(ctypes.byref(msg), 0, 0, 0) != 0: + user32.TranslateMessage(ctypes.byref(msg)) + user32.DispatchMessageW(ctypes.byref(msg)) + + if mouse_hook: + user32.UnhookWindowsHookEx(mouse_hook) + if kbd_hook: + user32.UnhookWindowsHookEx(kbd_hook) + + +def stop_hooks(): + if hook_thread_id: + user32.PostThreadMessageW(hook_thread_id, WM_QUIT, 0, 0) + + +def create_tray_image(size=64): + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + d.ellipse((8, 12, size - 8, size - 4), fill=(255, 224, 189, 255), outline=(40, 40, 40, 255)) + d.polygon([(16, 16), (8, 2), (28, 12)], fill=(255, 200, 170, 255), outline=(40, 40, 40, 255)) + d.polygon([(size - 16, 16), (size - 8, 2), (size - 28, 12)], fill=(255, 200, 170, 255), outline=(40, 40, 40, 255)) + d.ellipse((22, 28, 28, 34), fill=(30, 30, 30, 255)) + d.ellipse((size - 28, 28, size - 22, 34), fill=(30, 30, 30, 255)) + d.polygon([(size // 2, 36), (size // 2 - 4, 42), (size // 2 + 4, 42)], fill=(200, 80, 80, 255)) + d.line((size // 2, 42, size // 2, 48), fill=(40, 40, 40, 255), width=2) + d.arc((size // 2 - 10, 44, size // 2, 54), 0, 180, fill=(40, 40, 40, 255), width=2) + d.arc((size // 2, 44, size // 2 + 10, 54), 0, 180, fill=(40, 40, 40, 255), width=2) + return img + + +class CatModeApp(tk.Tk): + def __init__(self): + super().__init__() + self.title("Cat Mode") + self.geometry("320x200") + self.resizable(False, False) + self.protocol("WM_DELETE_WINDOW", self.on_window_close) + + self.status_label = tk.Label(self, text="Cat mode is OFF", font=("Segoe UI", 14)) + self.status_label.pack(pady=(18, 8)) + + self.toggle_btn = tk.Button( + self, + text="Enable Cat Mode", + font=("Segoe UI", 12), + width=18, + command=self.toggle, + ) + self.toggle_btn.pack(pady=6) + + self.info_label = tk.Label( + self, + text="Blocks physical keyboard/trackpad.\nInjected input still works.", + font=("Segoe UI", 9), + ) + self.info_label.pack(pady=(6, 2)) + + self.hotkey_label = tk.Label( + self, + text="Emergency unlock: Ctrl+Shift+F12", + font=("Segoe UI", 9), + ) + self.hotkey_label.pack(pady=(0, 6)) + + self.exit_btn = tk.Button(self, text="Exit", width=10, command=self.on_exit) + self.exit_btn.pack(pady=4) + + self.after(50, self.poll_queue) + + def toggle(self): + set_cat_mode(not cat_mode) + + def set_status(self, enabled: bool): + if enabled: + self.status_label.config(text="Cat mode is ON") + self.toggle_btn.config(text="Disable Cat Mode") + else: + self.status_label.config(text="Cat mode is OFF") + self.toggle_btn.config(text="Enable Cat Mode") + if tray_icon: + tray_icon.title = "Cat Mode (ON)" if enabled else "Cat Mode (OFF)" + + def poll_queue(self): + try: + while True: + msg, payload = ui_queue.get_nowait() + if msg == "status": + self.set_status(payload) + elif msg == "error": + self.status_label.config(text=payload, wraplength=300, justify="center") + messagebox.showerror("Cat Mode Error", payload) + elif msg == "toggle": + self.toggle() + elif msg == "show": + self.deiconify() + self.lift() + elif msg == "hide": + self.withdraw() + elif msg == "quit": + self.on_exit() + except queue.Empty: + pass + self.after(50, self.poll_queue) + + def on_window_close(self): + if TRAY_AVAILABLE: + self.withdraw() + else: + self.on_exit() + + def on_exit(self): + set_cat_mode(False) + quit_event.set() + stop_hooks() + self.destroy() + + +def tray_loop(): + global tray_icon + if not TRAY_AVAILABLE: + return + + def _toggle(icon, item): + ui_queue.put(("toggle", None)) + + def _show(icon, item): + ui_queue.put(("show", None)) + + def _quit(icon, item): + ui_queue.put(("quit", None)) + icon.stop() + + tray_icon = pystray.Icon( + "cat_mode", + create_tray_image(), + "Cat Mode (OFF)", + menu=pystray.Menu( + pystray.MenuItem("Toggle Cat Mode", _toggle), + pystray.MenuItem("Show Window", _show), + pystray.MenuItem("Quit", _quit), + ), + ) + tray_icon.run() + + +def main(): + t = threading.Thread(target=hook_loop, daemon=True) + t.start() + + app = CatModeApp() + if TRAY_AVAILABLE: + threading.Thread(target=tray_loop, daemon=True).start() + app.mainloop() + + +if __name__ == "__main__": + if sys.platform != "win32": + print("This app is Windows-only.") + sys.exit(1) + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..cd7b27c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +pystray +pillow +pyinstaller diff --git a/scripts/build_exe.ps1 b/scripts/build_exe.ps1 new file mode 100644 index 0000000..6d8e766 --- /dev/null +++ b/scripts/build_exe.ps1 @@ -0,0 +1,11 @@ +$ErrorActionPreference = "Stop" + +python -m pip install -r requirements.txt + +# Generate icon files in project root +python scripts/make_icon.py + +# Build single-file exe +pyinstaller --noconsole --onefile --name CatMode --icon cat.ico main.py + +Write-Host "Build complete. Find the exe in .\\dist\\CatMode.exe" diff --git a/scripts/make_icon.py b/scripts/make_icon.py new file mode 100644 index 0000000..e809e6e --- /dev/null +++ b/scripts/make_icon.py @@ -0,0 +1,23 @@ +from PIL import Image, ImageDraw + + +def create_tray_image(size=256): + img = Image.new("RGBA", (size, size), (0, 0, 0, 0)) + d = ImageDraw.Draw(img) + d.ellipse((24, 36, size - 24, size - 12), fill=(255, 224, 189, 255), outline=(40, 40, 40, 255)) + d.polygon([(48, 48), (24, 8), (88, 36)], fill=(255, 200, 170, 255), outline=(40, 40, 40, 255)) + d.polygon([(size - 48, 48), (size - 24, 8), (size - 88, 36)], fill=(255, 200, 170, 255), outline=(40, 40, 40, 255)) + d.ellipse((88, 110, 112, 134), fill=(30, 30, 30, 255)) + d.ellipse((size - 112, 110, size - 88, 134), fill=(30, 30, 30, 255)) + d.polygon([(size // 2, 140), (size // 2 - 12, 156), (size // 2 + 12, 156)], fill=(200, 80, 80, 255)) + d.line((size // 2, 156, size // 2, 176), fill=(40, 40, 40, 255), width=4) + d.arc((size // 2 - 30, 168, size // 2, 200), 0, 180, fill=(40, 40, 40, 255), width=4) + d.arc((size // 2, 168, size // 2 + 30, 200), 0, 180, fill=(40, 40, 40, 255), width=4) + return img + + +if __name__ == "__main__": + img = create_tray_image(256) + img.save("cat.png") + # For Windows icons, save a multi-size .ico + img.save("cat.ico", sizes=[(256, 256), (128, 128), (64, 64), (32, 32), (16, 16)])