# openlogi-hook — OS input capture
The event-tap crate: CGEventTap on macOS, evdev+uinput on Linux, WH_MOUSE_LL
on Windows. Cross-platform cfg discipline is
[`.agents/rules/cross-platform.md`](../../.agents/rules/cross-platform.md), and
the macOS FFI contract is
[`.agents/rules/objc-ffi.md`](../../.agents/rules/objc-ffi.md); this file is the
crate's own load-bearing behavior.
- macOS: the CGEventTap freeze-hazard state machine is load-bearing. The tap must
self-disable when Accessibility is revoked, on its own thread, with the bounded
run-loop slice — a stopped watcher after grant once froze all input on the machine.
Don't restructure it casually, and don't migrate the tap to `objc2-core-graphics`.
The `NSWorkspace` read and the Accessibility-trust check/prompt are the parts that
did move to the objc2 framework crates — see `.agents/rules/objc-ffi.md` for the rule that
every TCC call uses a typed binding rather than a hand-written `extern` block.
- `AXIsProcessTrusted()` is **not** a revocation signal: it keeps returning `true`
after the user deletes the app's row from System Settings, which is how #674 froze
clicks machine-wide. `has_accessibility` pairs it with a throwaway filtering tap
(`CGEventTapCreate` → NULL when the grant is gone); keep both, in that order — the
trust read is the cheap short-circuit, the probe is the truth. Never probe with
`ListenOnly`: that asks about Input Monitoring, a different grant.
- The live tap probes on cue, not every slice. `grant::ProbeCue` owns an `axwatch`
watch — the `com.apple.accessibility.api` distributed notification plus tccd's
`com.apple.tcc.access.changed` Darwin notification, the only one that fires when the
row is *removed* — and a `PROBE_HEARTBEAT` backstop; each probe is a WindowServer
round trip that stalls for seconds around a sleep transition (#952). Keep the
heartbeat: the notifications are freshness, not a completeness proof, and the probe
stays the authority. Delivery needs the agent's main-thread `NSApplication` loop,
which `run_app_loop` starts once the core arms.
- Re-enabling the tap each slice is idempotent and recovers a disable the OS never
reported. Charge `RearmBudget` from both `TapDisabledBy*` and
`CGEventTapIsEnabled`: a tap the system keeps disabling must be let go instead of
fought over, even when CoreGraphics omits the callback.
- Keep the `CGEventTap` owned by its run-loop thread. Normal teardown disables it
synchronously there and `Drop` invalidates its Mach port; a watchdog whose tap
thread is wedged force-exits so the OS destroys that process-owned port. Do not
add cross-thread tap ownership solely to pre-invalidate it during process exit —
Core Graphics does not document that operation as thread-safe.
- The tap callback must never block and never panic: use `try_read`/`try_lock` only,
queue bound actions off-thread, wrap the user callback in `catch_unwind`, and keep
the stuck-callback watchdog that force-exits the agent if the budget is exceeded.
An active HID-level tap serialises every pointer event; a hang freezes clicks
machine-wide. Only suppress events from remappable Logitech sources
(`source_is_remappable`) — never the built-in trackpad.
- A macOS tap stop request is not proof of teardown. Keep the independent lifecycle
watchdog armed until the tap thread reports the tap destroyed (and, for an explicit
stop, the thread exited). The watchdog must not call the Accessibility trust API —
that query can stall during TCC revocation; monitor tap-thread progress instead, and
force-exit the agent if revocation or shutdown stalls so macOS releases the HID tap.
- A watchdog budget — lifecycle and callback alike — is charged only for stall the
watchdog was awake to see, and a gap in a watchdog's own schedule is discounted only
when a kernel sleep or wake fell inside it (`kern.sleeptime` / `kern.waketime`
moved). Around such a transition the lifecycle watchdog thread has fired 1.0 s and
4.8 s past its budget, which a thread evaluating every 100 ms cannot do: it was not
running, and neither was the tap thread it judges. Reading its own schedule gap as
tap-thread stall force-exited healthy agents on lid close (#952); the callback
watchdog would read a callback entered just before the same freeze the same way. A
gap with no transition in it is charged in full, so a watchdog merely delayed by
scheduling still catches a wedged tap on schedule. Likewise the between-slice
capability probe runs under `TapPhase::Probing` with its own budget: it is a
WindowServer/TCC round trip, not tap servicing — and a stop that waited out a slow
probe gets the short budget afresh for the teardown. Refresh progress *before*
publishing `Armed` again, or the watchdog judges a probe that already returned against
the pre-probe mark. Both exit logs carry `stalled_ms` (uptime since the stall began)
and `watched_ms`; a wide gap between them is the process-frozen signature.
- The off-main `frontmost_application` read keeps its explicit `autoreleasepool` — the
watcher thread has no run loop; that is the only place in this crate a pool belongs.
Every string it copies out (bundle id *and* localized name) must be owned before the
pool drops.
- This crate ships non-macOS implementations (evdev/uinput, WH_MOUSE_LL) that a
macOS-green build never compiles. CI lints them; treat the linux/windows CI jobs as
the check, not local builds.