Skip to content

feat: CGEvent tap diagnostics — conflict detection + live event monitor#276

Open
AprilNEA wants to merge 4 commits into
masterfrom
feat/cgevent-diagnostics
Open

feat: CGEvent tap diagnostics — conflict detection + live event monitor#276
AprilNEA wants to merge 4 commits into
masterfrom
feat/cgevent-diagnostics

Conversation

@AprilNEA

@AprilNEA AprilNEA commented Jun 16, 2026

Copy link
Copy Markdown
Owner

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

  • openlogi-hook: Hook::list_event_taps() enumerates every event tap in the login session (owner, location, active/listen, enabled) via a hand-bound CGGetEventTapList + 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, …). A list_taps example doubles as a headless diagnostic. Latency fields are dropped (uninitialised sentinel values).
  • agent (protocol v4): an opt-in live event monitor. A shared EventMonitor buffers button/scroll/interrupt events (pointer moves excluded); the freeze-sensitive hook callback pays only one relaxed atomic load while monitoring is off. New poll_event_monitor IPC method drains + implicitly enables it; an idle janitor disables it once the GUI stops polling. Bumps PROTOCOL_VERSION 3→4 and regenerates the wire goldens (agent + GUI ship together).
  • GUI: a macOS Diagnostics settings page. Release surfaces the curated conflict warning; debug builds also show the full tap list and the live monitor (polls the agent while the window is open). Five new strings translated into all 20 locales.

Verification

  • list_event_taps() output matches a reference Swift CGGetEventTapList dump byte-for-byte.
  • Unit tests (classification, monitor buffer) + regenerated wire goldens pass; GUI compiles/links, clippy-clean under -D warnings, i18n parity holds.
  • Diagnostics page rendered and visually confirmed (layout uses Axis::Vertical so the wide content wraps full-width).

Notes

AprilNEA added 4 commits June 15, 2026 17:33
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 4 (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.
@greptile-apps

greptile-apps Bot commented Jun 16, 2026

Copy link
Copy Markdown

Greptile Summary

Adds a macOS Diagnostics settings page that detects third-party event taps competing with OpenLogi's mouse hook (a common pointer-lag cause), backed by a new CGGetEventTapList FFI binding in openlogi-hook and a live event monitor streamed over a new poll_event_monitor IPC method (protocol v4). The debug build additionally exposes the full raw tap list and a polled live event feed.

  • openlogi-hook: New Hook::list_event_taps() enumerates every session event tap via hand-bound CGGetEventTapList + proc_pidpath; EventTapInfo::gates_input() / known_input_conflict() classify lag-relevant taps against a curated driver list.
  • openlogi-agent-core: New EventMonitor buffers hook events into a bounded VecDeque guarded by an AtomicBool fast path; an idle janitor auto-disables monitoring when the GUI stops polling; protocol bumped to v4 with poll_event_monitor appended last.
  • GUI: Diagnostics page surfaces conflict warnings in release builds; debug builds additionally render the full tap dump and a 300 ms-polled live monitor; all five new user-facing strings translated into all 20 locales.

Confidence Score: 4/5

Safe to merge; all findings are in non-critical diagnostic/debug paths with no impact on the core mouse hook or IPC contract.

The FFI binding, IPC extension, and wire format changes are solid. The two issues in EventMonitor (Relaxed atomic ordering and the janitor's immediate first tick) affect only the debug live-monitor feature — the worst outcome is monitoring being silently disabled for up to 3 seconds after the first poll, not a crash or data loss. The per-render CGGetEventTapList call is wasteful but the Diagnostics page is rarely visited and the call is fast.

crates/openlogi-agent-core/src/event_monitor.rs (atomic ordering + janitor first-tick race) and crates/openlogi-gui/src/windows/settings.rs (CGGetEventTapList on every render).

Important Files Changed

Filename Overview
crates/openlogi-agent-core/src/event_monitor.rs New shared bounded buffer for live event monitoring; two correctness issues: Relaxed atomic ordering doesn't preserve the intended poll-before-enable sequence, and the janitor's immediate first tick can race against the first GUI poll on agent restart.
crates/openlogi-hook/src/macos.rs Adds hand-bound CGGetEventTapList + proc_pidpath FFI; repr(C) layout matches the 48-byte CoreGraphics struct, count-probe/fill pattern is correct, and truncation after the second call handles TOCTOU shrinkage.
crates/openlogi-hook/src/lib.rs Adds EventTapInfo / TapLocation types and Hook::list_event_taps(); gates_input() and known_input_conflict() classification logic is clean and well-tested.
crates/openlogi-gui/src/windows/settings.rs Diagnostics page correctly gates debug-only content behind cfg(debug_assertions); however input_conflict_field calls Hook::list_event_taps() (two system calls) on every render frame.
crates/openlogi-agent-core/src/hook_runtime.rs Clean refactor to add monitor.record() at the top of the callback; the single Relaxed load on the hot path is appropriate for a best-effort diagnostic tap.
crates/openlogi-agent-core/src/ipc.rs Protocol bumped to v4 with MonitorEvent enum appended as a new method; bincode append-only constraint enforced by wire golden tests.
crates/openlogi-agent/src/main.rs EventMonitor created, janitor spawned, and Arc shared to both hook_runtime::start and the IPC server correctly.
crates/openlogi-agent/src/server.rs poll_event_monitor IPC handler is a thin delegation to EventMonitor::poll(); straightforward and correct.
crates/openlogi-gui/src/state.rs monitor_events VecDeque correctly bounded at 200 entries; all new fields and methods properly gated behind cfg(all(target_os = "macos", debug_assertions)).
crates/openlogi-gui/src/ipc_client.rs PollEventMonitor command correctly gated behind cfg(all(target_os = "macos", debug_assertions)); oneshot reply channel pattern matches existing IPC commands.
crates/openlogi-agent-core/tests/wire_format.rs Wire golden tests updated: protocol version pinned to 4, PollEventMonitor variant index 0x0e appended, MonitorEvent encoding verified for all three variants.

Sequence Diagram

%%{init: {'theme': 'neutral'}}%%
sequenceDiagram
    participant GUI as GUI (SettingsView)
    participant IPC as IPC Server
    participant Monitor as EventMonitor
    participant Hook as Hook Callback
    participant Janitor as Idle Janitor

    Note over GUI: Settings window opens (debug macOS)
    loop every 300ms
        GUI->>IPC: poll_event_monitor()
        IPC->>Monitor: poll()
        Monitor->>Monitor: "polled=true, enabled=true"
        Monitor-->>IPC: Vec MonitorEvent
        IPC-->>GUI: Vec MonitorEvent
        GUI->>GUI: push_monitor_events() → refresh_windows()
    end

    Hook->>Monitor: record(event)
    Monitor->>Monitor: enabled? load(Relaxed)
    alt monitoring on
        Monitor->>Monitor: buf.lock() → push_back
    end

    loop every 3s
        Janitor->>Monitor: enabled() and polled.swap(false)
        alt no poll in last 3s
            Janitor->>Monitor: disable() → buf.clear()
        end
    end

    Note over GUI: Settings window closes
    GUI->>GUI: _monitor_task dropped → polling stops
    Note over Janitor: Next tick: no poll → disable monitoring
Loading
%%{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 (SettingsView)
    participant IPC as IPC Server
    participant Monitor as EventMonitor
    participant Hook as Hook Callback
    participant Janitor as Idle Janitor

    Note over GUI: Settings window opens (debug macOS)
    loop every 300ms
        GUI->>IPC: poll_event_monitor()
        IPC->>Monitor: poll()
        Monitor->>Monitor: "polled=true, enabled=true"
        Monitor-->>IPC: Vec MonitorEvent
        IPC-->>GUI: Vec MonitorEvent
        GUI->>GUI: push_monitor_events() → refresh_windows()
    end

    Hook->>Monitor: record(event)
    Monitor->>Monitor: enabled? load(Relaxed)
    alt monitoring on
        Monitor->>Monitor: buf.lock() → push_back
    end

    loop every 3s
        Janitor->>Monitor: enabled() and polled.swap(false)
        alt no poll in last 3s
            Janitor->>Monitor: disable() → buf.clear()
        end
    end

    Note over GUI: Settings window closes
    GUI->>GUI: _monitor_task dropped → polling stops
    Note over Janitor: Next tick: no poll → disable monitoring
Loading

Fix All in Codex Fix All in Claude Code

Reviews (1): Last reviewed commit: "feat(gui): Diagnostics page with input-c..." | Re-trigger Greptile

Comment on lines +77 to +82
/// Enable monitoring (idempotent) and drain everything buffered since the
/// last poll. Called from the IPC `poll_event_monitor` handler.
pub fn poll(&self) -> Vec<MonitorEvent> {
// Mark the poll *before* enabling so a janitor tick landing between the
// two stores can't read enabled-but-never-polled and disable instantly.
self.polled.store(true, Ordering::Relaxed);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Relaxed ordering undermines the intended poll-before-enable sequencing

The comment on line 78 explains the race being guarded against: a janitor tick landing between the two stores must not see enabled=true + polled=false and disable immediately. But Relaxed ordering carries no cross-thread visibility guarantee — the compiler or CPU is free to reorder the two stores, meaning the janitor can observe enabled=true while polled still reads its old false. The result is monitoring being silently disabled for up to 3 seconds right after the GUI's first poll enables it. Use Release for both stores here and Acquire for the janitor's enabled.load and polled.swap to form a proper release–acquire pair.

Fix in Codex Fix in Claude Code

Comment on lines +321 to +326
/// plus (debug) the full tap list. Recomputed on each render, so it reflects the
/// live tap set whenever the window repaints.
#[cfg(target_os = "macos")]
fn input_conflict_field(pal: Palette, cx: &mut App) -> AnyElement {
let taps = Hook::list_event_taps();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 CGGetEventTapList called twice on every render frame

input_conflict_field is a render function — it runs every time GPUI repaints the Diagnostics page. Hook::list_event_taps() issues two CGGetEventTapList system calls on each invocation (probe + fill). While the call is fast, the tap list doesn't change at render frequency; the monitor task's cx.refresh_windows() alone drives ~3 repaints/sec, and any mouse interaction while the page is open adds more. Caching the snapshot in AppState (refreshed on the same 300 ms tick as push_monitor_events) and reading it here would eliminate the per-render overhead.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Fix in Codex Fix in Claude Code

Comment on lines +103 to +115
loop {
ticker.tick().await;
// `swap` consumes the flag: a poll since the last tick keeps it
// alive; an untouched flag means no poll happened this interval.
if self.enabled() && !self.polled.swap(false, Ordering::Relaxed) {
self.disable();
}
}
}
}

#[cfg(test)]
mod tests {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Janitor's first tick always fires immediately

tokio::time::interval resolves its first tick instantly on creation. Since the janitor is spawned at agent startup long before any GUI connects, this is normally harmless (monitoring is off). But if the agent restarts while monitoring was enabled, the janitor's immediate first tick sees enabled=true + polled=false and disables monitoring before the reconnecting GUI has a chance to poll. Using tokio::time::interval_at(Instant::now() + IDLE_TICK, IDLE_TICK) makes the first check happen after a full idle window.

Fix in Codex Fix in Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant