"""Low-level Windows keyboard hook for live macro recording. Why this exists: Tkinter on Windows can capture printable keys and most modifiers in its / bindings, but the Windows key (VK_LWIN / VK_RWIN) and several other system shortcuts are intercepted by the OS before any window sees them. To relay those over BLE to the target machine we need a system-wide low-level keyboard hook (WH_KEYBOARD_LL) and we need to suppress the local event so it doesn't *also* fire on the host. Critical implementation detail: LRESULT is a pointer-sized signed integer (4 bytes on x86, 8 bytes on x64). ctypes.c_long is only 32 bits on Windows x64, so declaring the hook proc's return type as c_long causes the "return 1 to suppress" path to silently get sign-extended to a value Windows reads as "do not suppress" on 64-bit Pythons. This module uses ctypes.c_ssize_t throughout and EXPLICITLY declares argtypes/restype on every Win32 function so the marshalling is right. Lifecycle: hook = WinKeyboardHook(on_event=cb, on_escape=cb2) hook.start(suppress_local=True) ... hook.stop() Caveats: - Ctrl+Alt+Delete is the Windows Secure Attention Sequence and cannot be hooked by any user-mode code. - Xbox Game Bar shortcuts (Win+G, Win+R when GB is foregrounded, etc.) bypass user-mode hooks. They're handled at a lower level. - The hook runs in its own thread with a Windows message pump; callbacks fire on that thread. Marshal back to Tk via Widget.after(0, ...). """ from __future__ import annotations import ctypes import sys import threading from ctypes import wintypes from typing import Callable, Optional # Hook ID and message constants WH_KEYBOARD_LL = 13 HC_ACTION = 0 WM_KEYDOWN = 0x0100 WM_KEYUP = 0x0101 WM_SYSKEYDOWN = 0x0104 WM_SYSKEYUP = 0x0105 WM_QUIT = 0x0012 LLKHF_EXTENDED = 0x01 LLKHF_INJECTED = 0x10 # LRESULT is LONG_PTR (signed pointer-sized). c_ssize_t matches that # on all platforms ctypes runs on, unlike c_long which is 32 bits on # 64-bit Windows. LRESULT = ctypes.c_ssize_t # ---- VK → HID translation tables ---- # Most non-modifier keys. _VK_TO_HID: dict[int, int] = { # Letters (VK_A..VK_Z = 0x41..0x5A) -> HID 0x04..0x1D **{0x41 + i: 0x04 + i for i in range(26)}, # Digits (VK_0..VK_9) -> HID 0x1E..0x27 0x31: 0x1E, 0x32: 0x1F, 0x33: 0x20, 0x34: 0x21, 0x35: 0x22, 0x36: 0x23, 0x37: 0x24, 0x38: 0x25, 0x39: 0x26, 0x30: 0x27, # Whitespace / edit 0x0D: 0x28, # VK_RETURN 0x1B: 0x29, # VK_ESCAPE (handled specially) 0x08: 0x2A, # VK_BACK 0x09: 0x2B, # VK_TAB 0x20: 0x2C, # VK_SPACE # US-layout punctuation 0xBD: 0x2D, 0xBB: 0x2E, 0xDB: 0x2F, 0xDD: 0x30, 0xDC: 0x31, 0xBA: 0x33, 0xDE: 0x34, 0xC0: 0x35, 0xBC: 0x36, 0xBE: 0x37, 0xBF: 0x38, # Locks / system 0x14: 0x39, 0x90: 0x53, 0x91: 0x47, 0x2C: 0x46, 0x13: 0x48, 0x5D: 0x65, # Navigation 0x2D: 0x49, 0x24: 0x4A, 0x21: 0x4B, 0x2E: 0x4C, 0x23: 0x4D, 0x22: 0x4E, 0x27: 0x4F, 0x25: 0x50, 0x28: 0x51, 0x26: 0x52, # F1..F12 **{0x70 + i: 0x3A + i for i in range(12)}, # F13..F24 **{0x7C + i: 0x68 + i for i in range(12)}, # Numpad 0x6F: 0x54, 0x6A: 0x55, 0x6D: 0x56, 0x6B: 0x57, 0x6E: 0x63, 0x61: 0x59, 0x62: 0x5A, 0x63: 0x5B, 0x64: 0x5C, 0x65: 0x5D, 0x66: 0x5E, 0x67: 0x5F, 0x68: 0x60, 0x69: 0x61, 0x60: 0x62, } # Modifiers — including the Windows key, which is the whole reason # this module exists. _VK_MODIFIER_TO_HID: dict[int, int] = { 0xA0: 0xE1, # VK_LSHIFT 0xA1: 0xE5, # VK_RSHIFT 0xA2: 0xE0, # VK_LCONTROL 0xA3: 0xE4, # VK_RCONTROL 0xA4: 0xE2, # VK_LMENU (Left Alt) 0xA5: 0xE6, # VK_RMENU (Right Alt / AltGr) 0x5B: 0xE3, # VK_LWIN <-- the Windows key 0x5C: 0xE7, # VK_RWIN } def is_supported() -> bool: return sys.platform == "win32" # ---- ctypes structs ---- class KBDLLHOOKSTRUCT(ctypes.Structure): _fields_ = [ ("vkCode", wintypes.DWORD), ("scanCode", wintypes.DWORD), ("flags", wintypes.DWORD), ("time", wintypes.DWORD), ("dwExtraInfo", ctypes.c_void_p), ] # HOOKPROC: LRESULT (*)(int nCode, WPARAM wParam, LPARAM lParam) LowLevelKeyboardProc = ctypes.WINFUNCTYPE( LRESULT, # return: LRESULT (NOT c_long!) ctypes.c_int, # nCode wintypes.WPARAM, # wParam wintypes.LPARAM, # lParam ) # ---- Win32 function bindings with explicit argtypes/restype ---- if is_supported(): _user32 = ctypes.WinDLL("user32", use_last_error=True) _kernel32 = ctypes.WinDLL("kernel32", use_last_error=True) _user32.SetWindowsHookExW.argtypes = [ ctypes.c_int, LowLevelKeyboardProc, wintypes.HINSTANCE, wintypes.DWORD, ] _user32.SetWindowsHookExW.restype = wintypes.HHOOK _user32.UnhookWindowsHookEx.argtypes = [wintypes.HHOOK] _user32.UnhookWindowsHookEx.restype = wintypes.BOOL _user32.CallNextHookEx.argtypes = [ wintypes.HHOOK, ctypes.c_int, wintypes.WPARAM, wintypes.LPARAM, ] _user32.CallNextHookEx.restype = LRESULT _user32.GetMessageW.argtypes = [ ctypes.POINTER(wintypes.MSG), wintypes.HWND, wintypes.UINT, wintypes.UINT, ] _user32.GetMessageW.restype = ctypes.c_int # signed BOOL _user32.TranslateMessage.argtypes = [ctypes.POINTER(wintypes.MSG)] _user32.TranslateMessage.restype = wintypes.BOOL _user32.DispatchMessageW.argtypes = [ctypes.POINTER(wintypes.MSG)] _user32.DispatchMessageW.restype = LRESULT _user32.PostThreadMessageW.argtypes = [ wintypes.DWORD, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM, ] _user32.PostThreadMessageW.restype = wintypes.BOOL _kernel32.GetCurrentThreadId.argtypes = [] _kernel32.GetCurrentThreadId.restype = wintypes.DWORD _kernel32.GetModuleHandleW.argtypes = [wintypes.LPCWSTR] _kernel32.GetModuleHandleW.restype = wintypes.HMODULE else: _user32 = None _kernel32 = None class WinKeyboardHook: """Installs WH_KEYBOARD_LL, translates VKs to HID codes, and suppresses anything we forward so the host doesn't react to it.""" ACTION_DOWN = 0 ACTION_UP = 1 def __init__( self, on_event: Callable[[int, int], None], on_escape: Optional[Callable[[], None]] = None, ): if not is_supported(): raise RuntimeError( f"WinKeyboardHook only runs on Windows (sys.platform={sys.platform})") self._on_event = on_event self._on_escape = on_escape self._suppress = True self._thread: threading.Thread | None = None self._thread_id: int | None = None self._hook_id = None self._ready_evt = threading.Event() self._install_err: int | None = None # Hold a strong reference so the GC doesn't sweep the callable # while Windows is still calling into it. self._proc = LowLevelKeyboardProc(self._hook_proc) def start(self, suppress_local: bool = True) -> bool: """Install the hook. Returns True on success, False if the Windows API rejected the install (uncommon; usually means the calling process lacks message-loop privileges).""" if self._thread is not None: return True self._suppress = bool(suppress_local) self._ready_evt.clear() self._install_err = None self._thread = threading.Thread( target=self._thread_main, name="WinKeyboardHook", daemon=True) self._thread.start() # Wait until the install has resolved one way or the other so # the caller knows whether it actually took. self._ready_evt.wait(timeout=2.0) if self._hook_id in (None, 0): print(f"[winhook] install failed (err={self._install_err})") return False return True def stop(self, timeout: float = 2.0) -> None: """Uninstall the hook. Safe to call multiple times.""" tid = self._thread_id if tid is not None and _user32 is not None: try: _user32.PostThreadMessageW(tid, WM_QUIT, 0, 0) except Exception: pass if self._thread is not None: self._thread.join(timeout=timeout) self._thread = None self._thread_id = None self._hook_id = None def is_running(self) -> bool: return self._thread is not None and self._thread.is_alive() # ---- internal ---- def _thread_main(self) -> None: assert _user32 is not None and _kernel32 is not None self._thread_id = _kernel32.GetCurrentThreadId() hmod = _kernel32.GetModuleHandleW(None) self._hook_id = _user32.SetWindowsHookExW( WH_KEYBOARD_LL, self._proc, hmod, 0) if not self._hook_id: self._install_err = ctypes.get_last_error() self._ready_evt.set() return self._ready_evt.set() try: msg = wintypes.MSG() # GetMessageW returns: # >0 if a message was retrieved # 0 if WM_QUIT was retrieved (clean exit) # -1 on error while True: ret = _user32.GetMessageW(ctypes.byref(msg), None, 0, 0) if ret <= 0: break _user32.TranslateMessage(ctypes.byref(msg)) _user32.DispatchMessageW(ctypes.byref(msg)) finally: try: _user32.UnhookWindowsHookEx(self._hook_id) except Exception: pass def _hook_proc(self, nCode, wParam, lParam): # Pass-through anything that isn't an action (nCode < 0) or # that the docs say doesn't apply (nCode != HC_ACTION). if nCode != HC_ACTION: return _user32.CallNextHookEx( self._hook_id, nCode, wParam, lParam) try: kbd = ctypes.cast( lParam, ctypes.POINTER(KBDLLHOOKSTRUCT))[0] vk = kbd.vkCode flags = kbd.flags is_down = wParam in (WM_KEYDOWN, WM_SYSKEYDOWN) is_up = wParam in (WM_KEYUP, WM_SYSKEYUP) # Defense-in-depth: don't recurse on input we synthesized. if flags & LLKHF_INJECTED: return _user32.CallNextHookEx( self._hook_id, nCode, wParam, lParam) # Escape stops the recording locally; never forwarded. if vk == 0x1B and is_down and self._on_escape is not None: try: self._on_escape() except Exception: pass # Let it through — Escape can also dismiss whatever # modal is open. Suppression isn't needed. return _user32.CallNextHookEx( self._hook_id, nCode, wParam, lParam) hid = self._vk_to_hid(vk, flags) if hid is None: # Unknown key — pass through. We capture the broad set # of keys mapped above; anything else is rare and # safer to leak through than to silently swallow. return _user32.CallNextHookEx( self._hook_id, nCode, wParam, lParam) if is_down: self._safe_emit(self.ACTION_DOWN, hid) elif is_up: self._safe_emit(self.ACTION_UP, hid) if self._suppress: # Returning a non-zero LRESULT tells the OS to drop # the event before it reaches any window or the # shell. For VK_LWIN this is what stops the Start # menu from opening. return LRESULT(1).value except Exception: # NEVER let the hook proc raise — Windows would silently # disable the hook and the user would see no keys at all. pass return _user32.CallNextHookEx( self._hook_id, nCode, wParam, lParam) @staticmethod def _vk_to_hid(vk: int, flags: int) -> Optional[int]: if vk in _VK_MODIFIER_TO_HID: return _VK_MODIFIER_TO_HID[vk] if vk == 0x10: return 0xE1 if vk == 0x11: return 0xE4 if (flags & LLKHF_EXTENDED) else 0xE0 if vk == 0x12: return 0xE6 if (flags & LLKHF_EXTENDED) else 0xE2 return _VK_TO_HID.get(vk) def _safe_emit(self, action: int, hid: int) -> None: try: self._on_event(action, hid) except Exception: pass