357 lines
12 KiB
Python
357 lines
12 KiB
Python
"""Low-level Windows keyboard hook for live macro recording.
|
|
|
|
Why this exists:
|
|
Tkinter on Windows can capture printable keys and most modifiers
|
|
in its <KeyPress> / <KeyRelease> 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
|