feat: CGEvent tap diagnostics — conflict detection + live event monitor#276
Conversation
Greptile SummaryThis PR adds an in-app CGEvent tap diagnostics feature:
Confidence Score: 5/5Safe to merge: adds a read-only diagnostic path with no new writes beyond a bounded VecDeque, and the hook hot path pays only one relaxed atomic load while monitoring is off. All three concurrency issues from prior review rounds are correctly fixed. The repr(C) layout matches Apple's 48-byte stride. No correctness, data-safety, or security issues remain. No files require special attention. Important Files Changed
Sequence Diagram%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
participant GUI as GUI (debug)
participant IPC as IPC Client
participant Agent as AgentServer
participant EM as EventMonitor
participant Hook as Hook Callback
participant Janitor as Idle Janitor
Note over GUI: Settings window opens
GUI->>IPC: PollEventMonitor(tx) every 300ms
IPC->>Agent: poll_event_monitor RPC
Agent->>EM: poll()
Note over EM: polled.store(true,Relaxed) then enabled.store(true,Release)
EM-->>Agent: Vec MonitorEvent
Agent-->>IPC: Vec MonitorEvent
IPC-->>GUI: reply via oneshot rx
Hook->>EM: record(event)
Note over EM: enabled.load(Relaxed) fast path
Note over Janitor: every 3s via interval_at
Janitor->>EM: enabled.load(Acquire)
Note over EM: if enabled and not polled: disable()
Note over GUI: Settings window closes
Note over GUI: _monitor_task dropped, loop cancelled
Note over Janitor: next tick disables monitoring
%%{init: {'theme': 'base', 'themeVariables': {"darkMode": true, "background": "#0d1117", "primaryColor": "#21262d", "primaryTextColor": "#e6edf3", "primaryBorderColor": "#8b949e", "lineColor": "#8b949e", "textColor": "#e6edf3", "edgeLabelBackground": "#161b22", "actorBkg": "#21262d", "actorBorder": "#8b949e", "actorTextColor": "#e6edf3", "actorLineColor": "#8b949e", "signalColor": "#8b949e", "signalTextColor": "#e6edf3", "noteBkgColor": "#373320", "noteBorderColor": "#d4a72c", "noteTextColor": "#f0e6c0", "labelBoxBkgColor": "#21262d", "labelBoxBorderColor": "#8b949e", "labelTextColor": "#e6edf3", "loopTextColor": "#e6edf3", "activationBkgColor": "#30363d", "activationBorderColor": "#8b949e"}}}%%
sequenceDiagram
participant GUI as GUI (debug)
participant IPC as IPC Client
participant Agent as AgentServer
participant EM as EventMonitor
participant Hook as Hook Callback
participant Janitor as Idle Janitor
Note over GUI: Settings window opens
GUI->>IPC: PollEventMonitor(tx) every 300ms
IPC->>Agent: poll_event_monitor RPC
Agent->>EM: poll()
Note over EM: polled.store(true,Relaxed) then enabled.store(true,Release)
EM-->>Agent: Vec MonitorEvent
Agent-->>IPC: Vec MonitorEvent
IPC-->>GUI: reply via oneshot rx
Hook->>EM: record(event)
Note over EM: enabled.load(Relaxed) fast path
Note over Janitor: every 3s via interval_at
Janitor->>EM: enabled.load(Acquire)
Note over EM: if enabled and not polled: disable()
Note over GUI: Settings window closes
Note over GUI: _monitor_task dropped, loop cancelled
Note over Janitor: next tick disables monitoring
Reviews (3): Last reviewed commit: "perf(gui): cache the diagnostics event-t..." | Re-trigger Greptile |
e7e4d10 to
7115200
Compare
Enumerate every event tap in the login session via CGGetEventTapList: owner process, tap location (HID/Session), active vs listen-only, and enabled state. Read-only and needs no Accessibility grant, so it surfaces input contention — a competing app holding an active HID tap is the classic cause of pointer lag, and it also flags OpenLogi's own tap being disabled. The latency fields CGEventTapInformation carries are deliberately not exposed: they hold uninitialised sentinel values that change between samples, so they are not a trustworthy signal. Non-macOS targets return an empty list. A list_taps example doubles as a headless diagnostic.
EventTapInfo::gates_input flags the active+enabled+HID configuration that can add system-wide latency; known_input_conflict matches the owner against a curated list of third-party mouse drivers (Logi Options+, SteerMouse, BetterMouse, Mac Mouse Fix, LinearMouse, …) so the GUI can warn about a likely pointer-lag cause without false-flagging legitimate utilities. Unit-tested: the gating predicate and case-insensitive owner matching.
Add an opt-in event monitor so the GUI can show what the mouse hook observes. A shared EventMonitor buffers button/scroll/interrupt events (pointer moves excluded as noise); the freeze-sensitive hook callback pays only one relaxed atomic load while monitoring is off. The new poll_event_monitor IPC method drains the buffer and implicitly enables monitoring; an idle janitor disables it once the GUI stops polling, so a closed panel or crashed GUI can't leave the callback buffering forever. Bumps PROTOCOL_VERSION to 9 (poll_event_monitor appended last, MonitorEvent added) and pins the new wire goldens.
Add a macOS Diagnostics settings page. In release it surfaces the curated known-conflict check: if another app holds an active HID tap (Logi Options+, SteerMouse, BetterMouse, …) it warns that the app may be causing pointer lag. In debug builds it also dumps the full event-tap list and a live event monitor that polls the agent's hook over IPC (poll_event_monitor) while the window is open. Stores polled events in AppState (debug macOS only) and adds the five release-facing strings to all 20 locale files.
… the janitor's first tick The enable path published `polled` and `enabled` with two Relaxed stores while the idle janitor read them from another thread, also Relaxed. With no cross- thread ordering the janitor could observe `enabled == true` alongside a stale `polled == false` right after the GUI's first poll and disable monitoring for a tick. Publish `enabled` with Release and load it with Acquire in the janitor so observing `enabled == true` guarantees the paired `polled = true` is visible; the `polled` accesses stay Relaxed (payload ordered by the enabled pair) and the hot-path `record` load stays Relaxed. Also switch the janitor to `interval_at(now + IDLE_TICK, IDLE_TICK)`: `interval` fires immediately, so on an agent restart with monitoring enabled the first tick would disable it before the reconnecting GUI repolls. Waiting a full idle window lets the GUI poll first.
…g per render `input_conflict_field` is a render fn that ran `Hook::list_event_taps` (two `CGGetEventTapList` syscalls) on every repaint. With the live monitor open the Diagnostics page repaints several times a second, so those syscalls fired continuously. In debug macOS builds, cache the tap snapshot in `AppState` and refresh it on the same ~300ms tick that pushes monitor events; the render reads the cache. Release builds keep the live call — that page renders only on interaction there, not on a 300ms monitor cadence, so per-render enumeration is cheap. The cache field and its accessors are gated `cfg(all(target_os = "macos", debug_assertions))`, matching `monitor_events`, so non-macOS builds are unaffected.
210f278 to
79b511c
Compare
Adds an in-app way to answer "is another app intercepting my mouse / what's in the CGEvent stream?" — the productized version of hand-running
CGGetEventTapList.What's in it
Hook::list_event_taps()enumerates every event tap in the login session (owner, location, active/listen, enabled) via a hand-boundCGGetEventTapList+proc_pidpath(read-only, no Accessibility needed).EventTapInfo::gates_input()/known_input_conflict()classify the lag-relevant ones against a curated driver list (Logi Options+, SteerMouse, BetterMouse, Mac Mouse Fix, LinearMouse, …). Alist_tapsexample doubles as a headless diagnostic. Latency fields are dropped (uninitialised sentinel values).EventMonitorbuffers button/scroll/interrupt events (pointer moves excluded); the freeze-sensitive hook callback pays only one relaxed atomic load while monitoring is off. Newpoll_event_monitorIPC method drains + implicitly enables it; an idle janitor disables it once the GUI stops polling. BumpsPROTOCOL_VERSION3→4 and regenerates the wire goldens (agent + GUI ship together).Verification
list_event_taps()output matches a reference SwiftCGGetEventTapListdump byte-for-byte.-D warnings, i18n parity holds.Axis::Verticalso the wide content wraps full-width).Notes