Files
m5stack-automation-tool/utils/win_keyboard_hook.py
T
2026-07-17 15:29:53 -04:00

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