348 lines
11 KiB
Python
348 lines
11 KiB
Python
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()
|