← Keyhac crftwr/keyhac on GitHub · craftware

Platform layer

What is genuinely OS-specific, how the two OSes differ, and the interfaces that hide it. Everything here is grounded in how keyhac-win (pyauto/WH_KEYBOARD_LL) and keyhac-mac (CGEventTap) actually behave in production.

The honest sync-vs-async comparison

Both hooks give Keyhac a synchronous consume decision — the callback’s return value decides whether the physical event is swallowed. The real differences are around the callback:

Aspect Windows (WH_KEYBOARD_LL) macOS (CGEventTap, session tap, defaultTap)
Decision Return nonzero from hook proc → consumed Return None (or null the event) → consumed
Delivery thread Thread that installed the hook, during its message retrieval Thread whose run loop holds the tap source (main)
Deadline ~300 ms (LowLevelHooksTimeout); exceeded → silent permanent unhook WindowServer timeout → tap disabled, but you get kCGEventTapDisabledByTimeout and can re-enable
Recovery Nothing tells you. Detect: poll GetAsyncKeyState for modifier changes with no callback (keyhac-win checkSanity, 4 strikes → reinstall) Timer polls CGEvent.tapIsEnabled + handle the disabled-tap event types → tapEnable(true), reset modifier state
Modifier keys Arrive as normal key down/up (VK_LSHIFT etc.) Arrive as flagsChanged — platform layer must synthesize down/up by diffing flag state
Injection SendInput(batch) — array injected atomically at the tail of the input queue; ordering vs. physical input is the queue order CGEventPost(kCGHIDEventTap) — posted events re-enter your own tap; ordering vs. concurrent real events is not guaranteed
Injection bookkeeping LLKHF_INJECTED flag + own dwExtraInfo signature to recognize self-injected events Compare eventSourceStateID against private CGEventSources (one for output, one for replay)
Ordering fix keyhac-win trick: hookCall — inject a sentinel vk=0 key-down so a follow-up action runs inside the input stream, serialized keyhac-mac machinery: count pending virtual events; defer real events that arrive while virtual ones are in flight; flush deferred events when drained or after a 0.2 s watchdog
Repeats Auto-repeat arrives as repeated key-downs Same (keyDown with autorepeat flag)
Permissions None (but cannot hook elevated processes’ input unless Keyhac runs elevated — document, don’t solve) Accessibility permission required (AXIsProcessTrustedWithOptions with prompt); granted per app bundle
Keyboard layout GetKeyboardType(0) == 7 → JIS table KBGetLayoutType(LMGetKbdType()) → ANSI / JIS / ISO

Consequences for the shared engine:

  1. The engine is written against synchronous dispatch with a deadline: user callables run inline only if trivially fast; anything else must be a ThreadedAction.
  2. Modifier state is tracked by Keyhac, not read from the OS (both predecessors do this), with periodic reconciliation against reality (GetAsyncKeyState / post-restore reset) to fix stuck modifiers.
  3. Self-injected events are tagged by the platform (KeyEvent.kind below) so the engine ignores its own output but does re-process replayed macros (keyhac-mac’s replay-source design — adopted for both OSes; on Windows, distinguish via two dwExtraInfo signatures).

Interfaces (keyhac/platform/base.py)

keyhac/platform/base.py is the authoritative definition (docstrings included); summary of the surface:

Windows implementation notes (keyhac/platform/win/, ctypes)

Reimplements what pyauto provided, in ctypes (PuiKit’s _win32_native.py shows the house style for raw-ctypes Win32/COM):

macOS implementation notes (keyhac/platform/mac/, PyObjC)

Reimplements keyhac-mac’s Swift KeyhacCore_Hook/UIElement/Clipboard in Python. pyobjc-framework-Quartz exposes CGEventTapCreate/CGEventPost; pyobjc-framework-ApplicationServices exposes AXUIElement* (both proven live — a few AX calls need objc.loadBundleFunctions-style care).

Keycode & layout strategy

What is deliberately NOT platform-abstracted