From e82f463d1531b4941b422218d7764305f100e25a Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 15 Jun 2026 17:33:06 +0800 Subject: [PATCH 1/4] feat(hook): add Hook::list_event_taps for CGEvent tap diagnostics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/openlogi-hook/examples/list_taps.rs | 16 +++ crates/openlogi-hook/src/lib.rs | 67 +++++++++++++ crates/openlogi-hook/src/macos.rs | 107 ++++++++++++++++++++- 3 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 crates/openlogi-hook/examples/list_taps.rs diff --git a/crates/openlogi-hook/examples/list_taps.rs b/crates/openlogi-hook/examples/list_taps.rs new file mode 100644 index 00000000..ea15fc4b --- /dev/null +++ b/crates/openlogi-hook/examples/list_taps.rs @@ -0,0 +1,16 @@ +fn main() { + let taps = openlogi_hook::Hook::list_event_taps(); + println!("{} tap(s)", taps.len()); + for t in &taps { + println!( + "tap#{:<11} {:?} {} enabled={} owner={:?}({}) target={:?}", + t.tap_id, + t.location, + if t.active { "active" } else { "listen" }, + t.enabled, + t.owner_name, + t.owner_pid, + t.target_pid, + ); + } +} diff --git a/crates/openlogi-hook/src/lib.rs b/crates/openlogi-hook/src/lib.rs index 75d31740..7173c02d 100644 --- a/crates/openlogi-hook/src/lib.rs +++ b/crates/openlogi-hook/src/lib.rs @@ -70,6 +70,51 @@ pub enum EventDisposition { Suppress, } +/// Where in the event stream a tap is inserted (macOS `CGEventTapLocation`). +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum TapLocation { + /// `kCGHIDEventTap` — the lowest level, ahead of the window server. An + /// *active* tap here gates raw device input for the whole system, so a slow + /// or wedged owner adds latency to every event. This is where OpenLogi (and + /// Logi Options+) install. + Hid, + /// `kCGSessionEventTap` — scoped to the current login session. + Session, + /// `kCGAnnotatedSessionEventTap` — session tap that also sees annotations. + AnnotatedSession, + /// A location value newer than this enum knows about. + Other(u32), +} + +/// A live event tap installed somewhere in the system, as reported by +/// [`Hook::list_event_taps`]. Read-only diagnostic snapshot — enumerating taps +/// needs no Accessibility grant and any process in the session sees them all. +/// +/// The per-tap latency figures `CGEventTapInformation` carries are deliberately +/// omitted: empirically they hold uninitialised sentinel values that change +/// between samples, so they are not a trustworthy lag signal. +#[derive(Clone, Debug)] +pub struct EventTapInfo { + /// The system-assigned tap identifier. + pub tap_id: u32, + /// Where the tap sits in the event stream. + pub location: TapLocation, + /// `true` for an *active* tap (`kCGEventTapOptionDefault`) that can modify + /// or suppress events; `false` for a passive *listen-only* tap, which + /// physically cannot stall input. + pub active: bool, + /// Whether the tap is currently enabled (servicing events). + pub enabled: bool, + /// PID of the process that installed the tap. + pub owner_pid: i32, + /// Best-effort executable file name of the owner, or `None` if the process + /// has exited or its path is unreadable. + pub owner_name: Option, + /// PID of the single process whose events this tap intercepts, or `None` + /// for a global tap (one that sees every process's events). + pub target_pid: Option, +} + /// Errors that [`Hook::start`] and related functions can produce. #[derive(Debug, thiserror::Error)] pub enum HookError { @@ -243,6 +288,28 @@ impl Hook { macos::prompt_accessibility(); } } + + /// Enumerate every event tap currently installed in this login session. + /// + /// A read-only diagnostic snapshot for spotting input contention — e.g. a + /// competing app holding an *active* [`TapLocation::Hid`] tap (the classic + /// "another driver is also intercepting the mouse" cause of pointer lag), + /// or OpenLogi's own tap being unexpectedly disabled. Needs no Accessibility + /// grant; the call sees every process's taps regardless of who asks. + /// + /// Returns an empty vector on non-macOS targets, which have no equivalent + /// global tap registry. + #[must_use] + pub fn list_event_taps() -> Vec { + #[cfg(target_os = "macos")] + { + macos::list_event_taps() + } + #[cfg(not(target_os = "macos"))] + { + Vec::new() + } + } } /// Return an opaque string identifying the currently frontmost application. diff --git a/crates/openlogi-hook/src/macos.rs b/crates/openlogi-hook/src/macos.rs index de79ca14..f7232ecc 100644 --- a/crates/openlogi-hook/src/macos.rs +++ b/crates/openlogi-hook/src/macos.rs @@ -12,7 +12,7 @@ use core_graphics::event::{ }; use tracing::{debug, error, warn}; -use crate::{ButtonId, EventDisposition, HookError, MouseEvent}; +use crate::{ButtonId, EventDisposition, EventTapInfo, HookError, MouseEvent, TapLocation}; /// Everything `Hook` needs to control the background thread. pub(crate) struct HookInner { @@ -349,6 +349,111 @@ fn disable_tap(tap: &CGEventTap) { unsafe { CGEventTapEnable(tap.mach_port().as_concrete_TypeRef(), false) }; } +/// Mirror of CoreGraphics' `CGEventTapInformation`. `#[repr(C)]` reproduces the +/// header layout (including the padding before `events_of_interest` and +/// `min_usec_latency`) so `CGGetEventTapList` writes into the right offsets. +#[repr(C)] +#[derive(Clone, Copy)] +#[allow( + dead_code, + reason = "events_of_interest and the latency floats are unread but must \ + exist so the struct keeps CoreGraphics' exact 48-byte stride; \ + CGGetEventTapList writes whole records into the buffer" +)] +struct CGEventTapInformation { + event_tap_id: u32, + tap_point: u32, + options: u32, + events_of_interest: u64, + tapping_process: i32, + process_being_tapped: i32, + enabled: bool, + min_usec_latency: f32, + avg_usec_latency: f32, + max_usec_latency: f32, +} + +#[link(name = "CoreGraphics", kind = "framework")] +unsafe extern "C" { + // `core-graphics` doesn't bind the enumeration side (it ships the tap + // *create/enable* path only), so we declare it ourselves. Passing a null + // list with count 0 returns the number of taps via `event_tap_count`. + fn CGGetEventTapList( + max_number_of_taps: u32, + tap_list: *mut CGEventTapInformation, + event_tap_count: *mut u32, + ) -> i32; +} + +#[link(name = "System", kind = "dylib")] +unsafe extern "C" { + // libproc; resolves a PID to its executable path. Returns the byte length + // written, or <= 0 on failure (e.g. the process exited, or it's out of the + // caller's permission scope). + fn proc_pidpath(pid: i32, buffer: *mut std::ffi::c_void, buffersize: u32) -> i32; +} + +/// See [`super::Hook::list_event_taps`]. +pub(crate) fn list_event_taps() -> Vec { + let mut count: u32 = 0; + // SAFETY: a null `tap_list` with `max == 0` is the documented count-probe + // form; it only writes `count`. + let err = unsafe { CGGetEventTapList(0, std::ptr::null_mut(), &raw mut count) }; + if err != 0 || count == 0 { + return Vec::new(); + } + + // SAFETY: `CGEventTapInformation` is a plain `repr(C)` POD; an all-zero bit + // pattern is a valid instance (`enabled = false`, all numeric fields 0). + // `CGGetEventTapList` overwrites each slot it fills. + let mut taps: Vec = vec![unsafe { std::mem::zeroed() }; count as usize]; + let err = unsafe { CGGetEventTapList(count, taps.as_mut_ptr(), &raw mut count) }; + if err != 0 { + return Vec::new(); + } + // The second call may report fewer taps than the probe; never read past it. + taps.truncate(count as usize); + + taps.into_iter() + .map(|t| EventTapInfo { + tap_id: t.event_tap_id, + location: match t.tap_point { + 0 => TapLocation::Hid, + 1 => TapLocation::Session, + 2 => TapLocation::AnnotatedSession, + other => TapLocation::Other(other), + }, + // kCGEventTapOptionDefault == 0 (active); kCGEventTapOptionListenOnly == 1. + active: t.options == 0, + enabled: t.enabled, + owner_pid: t.tapping_process, + owner_name: process_name(t.tapping_process), + target_pid: (t.process_being_tapped != 0).then_some(t.process_being_tapped), + }) + .collect() +} + +/// Best-effort PID → executable file name via libproc. +fn process_name(pid: i32) -> Option { + // PROC_PIDPATHINFO_MAXSIZE is 4 * MAXPATHLEN (4 * 1024). + const BUF_LEN: u32 = 4096; + if pid <= 0 { + return None; + } + let mut buf = vec![0u8; BUF_LEN as usize]; + // SAFETY: `buf` is a live, writable buffer of `BUF_LEN` bytes; the C side + // writes at most that many and returns the length actually written. + let len = unsafe { proc_pidpath(pid, buf.as_mut_ptr().cast(), BUF_LEN) }; + if len <= 0 { + return None; + } + // `len > 0` here, so `unsigned_abs` is the value itself; widening to usize + // is lossless and sidesteps the sign-loss cast lint. + buf.truncate(len.unsigned_abs() as usize); + let path = String::from_utf8_lossy(&buf); + Some(path.rsplit('/').next().unwrap_or(&path).to_string()) +} + /// Signal the run loop to stop and join the background thread. pub(crate) fn stop(inner: HookInner) { inner.run_loop.stop(); From 1feef2c57c0c0e3b8df577358db6c02e8c72379d Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 15 Jun 2026 17:37:42 +0800 Subject: [PATCH 2/4] feat(hook): classify event taps as input-gating and known conflicts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/openlogi-hook/src/lib.rs | 40 +++++++++++++++++++++++++++ crates/openlogi-hook/src/tests.rs | 46 +++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/crates/openlogi-hook/src/lib.rs b/crates/openlogi-hook/src/lib.rs index 7173c02d..7a2dbd90 100644 --- a/crates/openlogi-hook/src/lib.rs +++ b/crates/openlogi-hook/src/lib.rs @@ -115,6 +115,46 @@ pub struct EventTapInfo { pub target_pid: Option, } +impl EventTapInfo { + /// `true` when this tap sits *active* at the [`TapLocation::Hid`] level and + /// is enabled — the one configuration that inserts the owner into the path + /// of every event and can therefore add latency system-wide. Listen-only, + /// disabled, or session-level taps cannot stall input this way. + #[must_use] + pub fn gates_input(&self) -> bool { + self.active && self.enabled && self.location == TapLocation::Hid + } + + /// If this tap's owner is a known third-party input driver that competes + /// with OpenLogi for the mouse stream, return its product name — used to + /// warn the user about a likely pointer-lag cause. + /// + /// Matches on the owner executable name only; callers should combine it with + /// [`Self::gates_input`] so a competitor's *inactive* helper isn't flagged. + #[must_use] + pub fn known_input_conflict(&self) -> Option<&'static str> { + // (lower-cased executable-name substring, product display name). Brand + // names are not localised; only the surrounding warning copy is. + const KNOWN: &[(&str, &str)] = &[ + ("logioptionsplus", "Logi Options+"), + ("logioptions", "Logitech Options"), + ("logimgr", "Logitech Options"), + ("lccdaemon", "Logitech Control Center"), + ("steermouse", "SteerMouse"), + ("bettermouse", "BetterMouse"), + ("usboverdrive", "USB Overdrive"), + ("mac mouse fix", "Mac Mouse Fix"), + ("linearmouse", "LinearMouse"), + ("smoothscroll", "SmoothScroll"), + ]; + let name = self.owner_name.as_deref()?.to_ascii_lowercase(); + KNOWN + .iter() + .find(|(needle, _)| name.contains(needle)) + .map(|&(_, label)| label) + } +} + /// Errors that [`Hook::start`] and related functions can produce. #[derive(Debug, thiserror::Error)] pub enum HookError { diff --git a/crates/openlogi-hook/src/tests.rs b/crates/openlogi-hook/src/tests.rs index f21947a0..f7249c2a 100644 --- a/crates/openlogi-hook/src/tests.rs +++ b/crates/openlogi-hook/src/tests.rs @@ -81,3 +81,49 @@ fn linux_start_does_not_return_unsupported() { fn non_macos_has_accessibility_is_true() { assert!(Hook::has_accessibility()); } + +/// Build an `EventTapInfo` with the given owner name and tap properties, +/// defaulting the fields the conflict logic doesn't read. +fn tap(owner: Option<&str>, location: TapLocation, active: bool, enabled: bool) -> EventTapInfo { + EventTapInfo { + tap_id: 1, + location, + active, + enabled, + owner_pid: 100, + owner_name: owner.map(str::to_owned), + target_pid: None, + } +} + +/// `gates_input` is true only for an enabled, active, HID-level tap. +#[test] +fn gates_input_requires_active_enabled_hid() { + assert!(tap(None, TapLocation::Hid, true, true).gates_input()); + // listen-only, disabled, or session-level cannot stall the HID stream. + assert!(!tap(None, TapLocation::Hid, false, true).gates_input()); + assert!(!tap(None, TapLocation::Hid, true, false).gates_input()); + assert!(!tap(None, TapLocation::Session, true, true).gates_input()); +} + +/// Known third-party input drivers are matched case-insensitively by owner +/// executable name; unrelated owners and missing names return `None`. +#[test] +fn known_input_conflict_matches_curated_list() { + let hid = |owner| tap(Some(owner), TapLocation::Hid, true, true); + assert_eq!( + hid("logioptionsplus_agent").known_input_conflict(), + Some("Logi Options+") + ); + // Case-insensitive, and substring of a longer path component. + assert_eq!( + hid("BetterMouse").known_input_conflict(), + Some("BetterMouse") + ); + assert_eq!(hid("SteerMouse").known_input_conflict(), Some("SteerMouse")); + assert_eq!(hid("Raycast").known_input_conflict(), None); + assert_eq!( + tap(None, TapLocation::Hid, true, true).known_input_conflict(), + None + ); +} From 859786cba98c41497d2c99c592aa6eebe241c38b Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 15 Jun 2026 17:59:24 +0800 Subject: [PATCH 3/4] feat(agent): live event monitor over IPC (protocol v4) 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. --- .../openlogi-agent-core/src/event_monitor.rs | 165 ++++++++++++++++ .../openlogi-agent-core/src/hook_runtime.rs | 184 +++++++++--------- crates/openlogi-agent-core/src/ipc.rs | 26 ++- crates/openlogi-agent-core/src/lib.rs | 1 + .../openlogi-agent-core/tests/wire_format.rs | 25 ++- crates/openlogi-agent/src/main.rs | 9 + crates/openlogi-agent/src/server.rs | 8 +- 7 files changed, 326 insertions(+), 92 deletions(-) create mode 100644 crates/openlogi-agent-core/src/event_monitor.rs diff --git a/crates/openlogi-agent-core/src/event_monitor.rs b/crates/openlogi-agent-core/src/event_monitor.rs new file mode 100644 index 00000000..8282c108 --- /dev/null +++ b/crates/openlogi-agent-core/src/event_monitor.rs @@ -0,0 +1,165 @@ +//! Live event monitor: a shared, bounded buffer that mirrors the events the OS +//! mouse hook observes to the GUI's debug monitor, on demand. +//! +//! Monitoring is **off by default**. The freeze-sensitive hook callback pays +//! only a single relaxed atomic load per event while off (see the freeze-hazard +//! note in `openlogi-hook`); it locks and pushes only once the GUI starts +//! polling. The GUI enables monitoring implicitly by polling +//! [`EventMonitor::poll`], and [`EventMonitor::run_idle_janitor`] turns it back +//! off when polls stop — so a closed panel or a crashed GUI can't leave the +//! callback doing buffer work forever. + +use std::collections::VecDeque; +use std::sync::Mutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use openlogi_hook::MouseEvent; + +use crate::ipc::MonitorEvent; + +/// A shared [`EventMonitor`], threaded between the hook callback (writer) and +/// the IPC server (reader/poller). +pub type SharedEventMonitor = std::sync::Arc; + +/// How many recent events to retain between polls. A held button + a flick of +/// the scroll wheel is a handful of events; a generous cap still drops only the +/// oldest if the GUI stalls. +const CAPACITY: usize = 256; + +/// How often the janitor checks for an idle (no-longer-polled) monitor. +const IDLE_TICK: Duration = Duration::from_secs(3); + +/// Buffers the hook's observed events for the GUI's live monitor when enabled. +#[derive(Default)] +pub struct EventMonitor { + enabled: AtomicBool, + /// Set on every [`Self::poll`]; the janitor clears it each tick and treats a + /// tick with no intervening poll as "the GUI stopped watching". + polled: AtomicBool, + buf: Mutex>, +} + +impl EventMonitor { + /// Whether monitoring is currently on — the one check the hot hook path runs. + #[must_use] + pub fn enabled(&self) -> bool { + self.enabled.load(Ordering::Relaxed) + } + + /// Record a hook event, if monitoring is on. Pointer moves are dropped: they + /// arrive at pointer-motion rates and would evict every button/scroll event + /// from the bounded buffer before the GUI's next poll. + pub fn record(&self, event: &MouseEvent) { + if !self.enabled() { + return; + } + let mapped = match event { + MouseEvent::Button { id, pressed } => MonitorEvent::Button { + button: id.to_string(), + pressed: *pressed, + }, + MouseEvent::Scroll { delta_x, delta_y } => MonitorEvent::Scroll { + delta_x: *delta_x, + delta_y: *delta_y, + }, + MouseEvent::CaptureInterrupted => MonitorEvent::CaptureInterrupted, + MouseEvent::Moved { .. } => return, + }; + if let Ok(mut buf) = self.buf.lock() { + if buf.len() == CAPACITY { + buf.pop_front(); + } + buf.push_back(mapped); + } + } + + /// Enable monitoring (idempotent) and drain everything buffered since the + /// last poll. Called from the IPC `poll_event_monitor` handler. + pub fn poll(&self) -> Vec { + // 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); + self.enabled.store(true, Ordering::Relaxed); + self.buf + .lock() + .map(|mut buf| buf.drain(..).collect()) + .unwrap_or_default() + } + + /// Turn monitoring off and discard any buffered events. + fn disable(&self) { + self.enabled.store(false, Ordering::Relaxed); + if let Ok(mut buf) = self.buf.lock() { + buf.clear(); + } + } + + /// Auto-disable monitoring when the GUI stops polling. Runs for the life of + /// the agent: each tick, if monitoring is on but no poll arrived since the + /// previous tick, the GUI is gone — disable and free the buffer. + pub async fn run_idle_janitor(self: SharedEventMonitor) { + let mut ticker = tokio::time::interval(IDLE_TICK); + 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 { + use super::*; + use openlogi_core::binding::ButtonId; + + #[test] + fn records_only_while_enabled_and_skips_moves() { + let m = EventMonitor::default(); + // Off by default: a press before any poll is not buffered. + m.record(&MouseEvent::Button { + id: ButtonId::Back, + pressed: true, + }); + assert!(!m.enabled()); + + // The first poll enables monitoring and returns nothing buffered yet. + assert!(m.poll().is_empty()); + assert!(m.enabled()); + + // Now events land — except pointer moves, which are dropped. + m.record(&MouseEvent::Moved { + delta_x: 5, + delta_y: 5, + }); + m.record(&MouseEvent::Button { + id: ButtonId::Forward, + pressed: false, + }); + assert_eq!( + m.poll(), + vec![MonitorEvent::Button { + button: ButtonId::Forward.to_string(), + pressed: false, + }] + ); + // Draining leaves the buffer empty. + assert!(m.poll().is_empty()); + } + + #[test] + fn bounded_buffer_drops_oldest() { + let m = EventMonitor::default(); + m.poll(); // enable + for _ in 0..(CAPACITY + 10) { + m.record(&MouseEvent::Scroll { + delta_x: 0.0, + delta_y: 1.0, + }); + } + assert_eq!(m.poll().len(), CAPACITY, "never grows past the cap"); + } +} diff --git a/crates/openlogi-agent-core/src/hook_runtime.rs b/crates/openlogi-agent-core/src/hook_runtime.rs index 07419d3e..d2d1675a 100644 --- a/crates/openlogi-agent-core/src/hook_runtime.rs +++ b/crates/openlogi-agent-core/src/hook_runtime.rs @@ -17,6 +17,7 @@ use openlogi_hook::{EventDisposition, Hook, MouseEvent}; use tracing::{info, warn}; use crate::DpiCycleState; +use crate::event_monitor::SharedEventMonitor; use crate::hardware::{toggle_smartshift_in_background, write_dpi_in_background}; /// The two button maps the OS-hook callback reads, kept behind ONE lock so a @@ -101,6 +102,7 @@ pub fn start( hooks: SharedHookMaps, dpi_cycle: Arc>, capture: CaptureChannel, + monitor: SharedEventMonitor, ) -> Option { if !Hook::has_accessibility() { warn!( @@ -112,105 +114,111 @@ pub fn start( // The per-hold pointer accumulator lives in the thread-local `HOLD`; the // callback must never block — see the freeze-hazard note in `macos.rs`. - let result = Hook::start(move |event| match event { - MouseEvent::Button { id, pressed } => { - // The CGEventTap only sees standard buttons 0-4. We remap - // Middle/Back/Forward; the primary L/R clicks always pass through - // (suppressing them would brick the mouse), and the DPI / thumb / - // dedicated gesture button aren't visible to the tap at all — the - // dedicated gesture button is captured separately over HID++. - if !id.is_os_hook_button() { - return EventDisposition::PassThrough; - } - - // Gesture button: suppress the native click and begin a hold. The - // swipe commits mid-motion in the `Moved` arm; here, on release, we - // only fire the plain `Click` when no swipe committed. The cursor is - // free to drift via the pass-through `Moved` events during the hold. - if pressed { - let is_gesture = hooks.read().is_ok_and(|m| m.gestures.contains_key(&id)); - if is_gesture { - HOLD.with_borrow_mut(|h| h.begin(id)); - return EventDisposition::Suppress; + let result = Hook::start(move |event| { + // Mirror the raw event to the GUI's live monitor first (a single relaxed + // atomic load while monitoring is off — see `event_monitor`), before any + // remapping decides its disposition. + monitor.record(&event); + match event { + MouseEvent::Button { id, pressed } => { + // The CGEventTap only sees standard buttons 0-4. We remap + // Middle/Back/Forward; the primary L/R clicks always pass through + // (suppressing them would brick the mouse), and the DPI / thumb / + // dedicated gesture button aren't visible to the tap at all — the + // dedicated gesture button is captured separately over HID++. + if !id.is_os_hook_button() { + return EventDisposition::PassThrough; } - } else { - // Release: end the hold and release the `HOLD` borrow *before* any - // dispatch — the callback must stay lock-light, since a - // synthesized event could otherwise re-enter the tap and re-borrow - // `HOLD` (a RefCell double-borrow panic, freeze hazard). - let ended = HOLD.with_borrow_mut(|h| h.end(id)); - if let Some(was_click) = ended { - if was_click { - // No swipe committed → fire the plain click. Resolve to an - // owned action (so no lock is held across dispatch), then - // dispatch with the guard already dropped. - let action = hooks - .read() - .ok() - .map(|m| resolve_gesture_click(&m.gestures, id)); - if let Some(action) = action { - info!(button = %id, action = %action.label(), "gesture click → executing bound action"); - dispatch_action(&action, &dpi_cycle, &capture); + + // Gesture button: suppress the native click and begin a hold. The + // swipe commits mid-motion in the `Moved` arm; here, on release, we + // only fire the plain `Click` when no swipe committed. The cursor is + // free to drift via the pass-through `Moved` events during the hold. + if pressed { + let is_gesture = hooks.read().is_ok_and(|m| m.gestures.contains_key(&id)); + if is_gesture { + HOLD.with_borrow_mut(|h| h.begin(id)); + return EventDisposition::Suppress; + } + } else { + // Release: end the hold and release the `HOLD` borrow *before* any + // dispatch — the callback must stay lock-light, since a + // synthesized event could otherwise re-enter the tap and re-borrow + // `HOLD` (a RefCell double-borrow panic, freeze hazard). + let ended = HOLD.with_borrow_mut(|h| h.end(id)); + if let Some(was_click) = ended { + if was_click { + // No swipe committed → fire the plain click. Resolve to an + // owned action (so no lock is held across dispatch), then + // dispatch with the guard already dropped. + let action = hooks + .read() + .ok() + .map(|m| resolve_gesture_click(&m.gestures, id)); + if let Some(action) = action { + info!(button = %id, action = %action.label(), "gesture click → executing bound action"); + dispatch_action(&action, &dpi_cycle, &capture); + } } + return EventDisposition::Suppress; } - return EventDisposition::Suppress; } - } - // Single-action button. - let action = hooks.read().ok().and_then(|m| m.bindings.get(&id).cloned()); - let Some(action) = action else { - // Unbound → leave the physical button to the OS. - return EventDisposition::PassThrough; - }; + // Single-action button. + let action = hooks.read().ok().and_then(|m| m.bindings.get(&id).cloned()); + let Some(action) = action else { + // Unbound → leave the physical button to the OS. + return EventDisposition::PassThrough; + }; - // A button left on its own native click (e.g. Middle → MiddleClick) - // should just do that click; suppressing and re-synthesising it - // would be pointless churn. - if is_native_click(id, &action) { - return EventDisposition::PassThrough; - } + // A button left on its own native click (e.g. Middle → MiddleClick) + // should just do that click; suppressing and re-synthesising it + // would be pointless churn. + if is_native_click(id, &action) { + return EventDisposition::PassThrough; + } - if pressed { - info!(button = %id, action = %action.label(), "button → executing bound action"); - dispatch_action(&action, &dpi_cycle, &capture); - } - EventDisposition::Suppress - } - MouseEvent::Moved { delta_x, delta_y } => { - // Feed an in-progress hold; a committed swipe fires here, mid-motion. - // Always pass through so the cursor keeps moving — the swipe is read, - // not consumed (the B2 cursor-drift tradeoff vs. a HID++ raw-XY divert - // that would freeze the pointer). - let commit = HOLD.with_borrow_mut(|h| h.accumulate(delta_x, delta_y)); - if let Some((button, dir)) = commit { - // Resolve to an owned action and drop the read guard before - // dispatch (same lock-light rule as the release arm). The button - // can leave the gesture set mid-hold (a per-app rebuild); the - // commit has already armed `fired`, so the release won't fire a - // click. Fall back to the same click action the release path uses - // so the suppressed press is never swallowed into nothing — - // symmetric with `resolve_gesture_click`. - let action = hooks.read().ok().map(|m| { - m.gestures - .get(&button) - .and_then(|dirs| dirs.get(&dir).cloned()) - .unwrap_or_else(|| resolve_gesture_click(&m.gestures, button)) - }); - if let Some(action) = action { - info!(button = %button, ?dir, action = %action.label(), "gesture swipe → executing bound action"); + if pressed { + info!(button = %id, action = %action.label(), "button → executing bound action"); dispatch_action(&action, &dpi_cycle, &capture); } + EventDisposition::Suppress } - EventDisposition::PassThrough - } - MouseEvent::CaptureInterrupted => { - // The OS dropped events (tap disabled); cancel any hold so a lost - // button-up can't later commit a phantom swipe off ordinary motion. - HOLD.with_borrow_mut(HoldState::cancel); - EventDisposition::PassThrough + MouseEvent::Moved { delta_x, delta_y } => { + // Feed an in-progress hold; a committed swipe fires here, mid-motion. + // Always pass through so the cursor keeps moving — the swipe is read, + // not consumed (the B2 cursor-drift tradeoff vs. a HID++ raw-XY divert + // that would freeze the pointer). + let commit = HOLD.with_borrow_mut(|h| h.accumulate(delta_x, delta_y)); + if let Some((button, dir)) = commit { + // Resolve to an owned action and drop the read guard before + // dispatch (same lock-light rule as the release arm). The button + // can leave the gesture set mid-hold (a per-app rebuild); the + // commit has already armed `fired`, so the release won't fire a + // click. Fall back to the same click action the release path uses + // so the suppressed press is never swallowed into nothing — + // symmetric with `resolve_gesture_click`. + let action = hooks.read().ok().map(|m| { + m.gestures + .get(&button) + .and_then(|dirs| dirs.get(&dir).cloned()) + .unwrap_or_else(|| resolve_gesture_click(&m.gestures, button)) + }); + if let Some(action) = action { + info!(button = %button, ?dir, action = %action.label(), "gesture swipe → executing bound action"); + dispatch_action(&action, &dpi_cycle, &capture); + } + } + EventDisposition::PassThrough + } + MouseEvent::CaptureInterrupted => { + // The OS dropped events (tap disabled); cancel any hold so a lost + // button-up can't later commit a phantom swipe off ordinary motion. + HOLD.with_borrow_mut(HoldState::cancel); + EventDisposition::PassThrough + } + MouseEvent::Scroll { .. } => EventDisposition::PassThrough, } - MouseEvent::Scroll { .. } => EventDisposition::PassThrough, }); match result { diff --git a/crates/openlogi-agent-core/src/ipc.rs b/crates/openlogi-agent-core/src/ipc.rs index df887d95..a6fa4d16 100644 --- a/crates/openlogi-agent-core/src/ipc.rs +++ b/crates/openlogi-agent-core/src/ipc.rs @@ -21,7 +21,8 @@ use serde::{Deserialize, Serialize}; /// /// v2: `AgentStatus::inventory_ready` added. /// v3: `inventory_ready` widened to [`InventoryHealth`] (adds `Unavailable`). -pub const PROTOCOL_VERSION: u32 = 3; +/// v4: `poll_event_monitor` appended + [`MonitorEvent`] (live event monitor). +pub const PROTOCOL_VERSION: u32 = 4; /// Where the agent's device enumeration stands. The distinction matters /// because an empty [`Agent::inventory`] is ambiguous on its own: the GUI must @@ -85,6 +86,23 @@ pub enum PairingUpdate { Failed(String), } +/// One input event the agent's mouse hook observed, streamed to the GUI's live +/// event monitor via [`Agent::poll_event_monitor`]. Pointer-move events are +/// deliberately excluded — they would flood the buffer — so this is the +/// button/scroll/interrupt view of what OpenLogi's hook actually receives. +/// +/// bincode encodes the variant *index*, so variants are append-only. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +pub enum MonitorEvent { + /// A mouse button changed state. `button` is the display label (e.g. `Back`). + Button { button: String, pressed: bool }, + /// A scroll-wheel tick; positive `delta_x` = right, positive `delta_y` = down. + Scroll { delta_x: f32, delta_y: f32 }, + /// The OS interrupted capture (the tap was disabled by a timeout or by + /// competing user input). Surfaced because it explains a momentary gap. + CaptureInterrupted, +} + #[tarpc::service] pub trait Agent { /// Wire-protocol version, for the connect handshake. @@ -141,4 +159,10 @@ pub trait Agent { /// window elapses with no event (the GUI simply re-polls); the GUI drives /// this in a loop while the Add Device window is open. async fn next_pairing() -> Option; + /// Drain the events the hook has observed since the last poll, for the GUI's + /// live event monitor. The first poll enables monitoring; the agent + /// auto-disables it once polls stop (the GUI closed the panel or died), so + /// there is no explicit stop. Appended last — see the method-order note on + /// [`Agent::protocol_version`]. + async fn poll_event_monitor() -> Vec; } diff --git a/crates/openlogi-agent-core/src/lib.rs b/crates/openlogi-agent-core/src/lib.rs index 548b396d..0def49e6 100644 --- a/crates/openlogi-agent-core/src/lib.rs +++ b/crates/openlogi-agent-core/src/lib.rs @@ -9,6 +9,7 @@ pub mod bindings; pub mod device_order; mod dpi; +pub mod event_monitor; pub mod hardware; pub mod hook_runtime; pub mod ipc; diff --git a/crates/openlogi-agent-core/tests/wire_format.rs b/crates/openlogi-agent-core/tests/wire_format.rs index 3ff165a5..67eb40e9 100644 --- a/crates/openlogi-agent-core/tests/wire_format.rs +++ b/crates/openlogi-agent-core/tests/wire_format.rs @@ -23,7 +23,8 @@ use std::fmt::Write; use bincode::Options; use openlogi_agent_core::ipc::{ - AgentRequest, AgentStatus, FoundDevice, InventoryHealth, PROTOCOL_VERSION, PairingUpdate, + AgentRequest, AgentStatus, FoundDevice, InventoryHealth, MonitorEvent, PROTOCOL_VERSION, + PairingUpdate, }; use openlogi_core::config::Lighting; use openlogi_core::device::{ @@ -60,7 +61,7 @@ fn assert_wire(value: &T, golden: &str) { /// that makes that visible in the same diff. #[test] fn protocol_version_is_pinned() { - assert_eq!(PROTOCOL_VERSION, 3); + assert_eq!(PROTOCOL_VERSION, 4); } /// tarpc encodes the request enum's variant index, so trait *method order* is @@ -80,6 +81,26 @@ fn request_variant_order() { "040008463030444341464501fb4006", ); assert_wire(&AgentRequest::NextPairing {}, "0d"); + assert_wire(&AgentRequest::PollEventMonitor {}, "0e"); +} + +#[test] +fn monitor_events() { + assert_wire( + &MonitorEvent::Button { + button: "Back".into(), + pressed: true, + }, + "00044261636b01", + ); + assert_wire( + &MonitorEvent::Scroll { + delta_x: 0.0, + delta_y: 1.0, + }, + "01000000000000803f", + ); + assert_wire(&MonitorEvent::CaptureInterrupted, "02"); } #[test] diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs index 2dbd7ec4..c0d9e881 100644 --- a/crates/openlogi-agent/src/main.rs +++ b/crates/openlogi-agent/src/main.rs @@ -20,6 +20,7 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use std::time::Duration; +use openlogi_agent_core::event_monitor::EventMonitor; use openlogi_agent_core::orchestrator::Orchestrator; use openlogi_agent_core::{hook_runtime, watchers}; use openlogi_core::config::Config; @@ -124,6 +125,12 @@ async fn run(config: Config) { let shared = orchestrator.lock().await.shared(); let hook_installed = Arc::new(AtomicBool::new(false)); + // Live event monitor: shared between the hook callback (which mirrors events + // into it) and the IPC server (which the GUI polls). The janitor turns it + // back off once the GUI stops polling. + let event_monitor = Arc::new(EventMonitor::default()); + tokio::spawn(Arc::clone(&event_monitor).run_idle_janitor()); + // Pairing runs in the agent (it owns device I/O); the GUI drives it over IPC. let pairing = Arc::new(pairing::PairingManager::new(shared.clone())); @@ -153,6 +160,7 @@ async fn run(config: Config) { shared: shared.clone(), hook_installed: Arc::clone(&hook_installed), pairing: Arc::clone(&pairing), + event_monitor: Arc::clone(&event_monitor), }; tokio::spawn(server::run(server)); @@ -201,6 +209,7 @@ async fn run(config: Config) { shared.hook_maps.clone(), shared.dpi_cycle.clone(), shared.capture_channel.clone(), + Arc::clone(&event_monitor), ); hook_installed.store(hook.is_some(), Ordering::Relaxed); } diff --git a/crates/openlogi-agent/src/server.rs b/crates/openlogi-agent/src/server.rs index eaedb1c8..d10443a6 100644 --- a/crates/openlogi-agent/src/server.rs +++ b/crates/openlogi-agent/src/server.rs @@ -9,7 +9,8 @@ use std::sync::Arc; use std::sync::atomic::{AtomicBool, Ordering}; use futures::StreamExt as _; -use openlogi_agent_core::ipc::{Agent, AgentStatus, PROTOCOL_VERSION, PairingUpdate}; +use openlogi_agent_core::event_monitor::SharedEventMonitor; +use openlogi_agent_core::ipc::{Agent, AgentStatus, MonitorEvent, PROTOCOL_VERSION, PairingUpdate}; use openlogi_agent_core::orchestrator::{Orchestrator, SharedRuntime}; use openlogi_agent_core::{hardware, transport}; use openlogi_core::config::{Config, Lighting}; @@ -35,6 +36,7 @@ pub struct AgentServer { pub shared: SharedRuntime, pub hook_installed: Arc, pub pairing: Arc, + pub event_monitor: SharedEventMonitor, } impl Agent for AgentServer { @@ -136,6 +138,10 @@ impl Agent for AgentServer { async fn next_pairing(self, _: Context) -> Option { self.pairing.next_update().await } + + async fn poll_event_monitor(self, _: Context) -> Vec { + self.event_monitor.poll() + } } /// Bind the agent's IPC socket and serve [`Agent`] requests until the process From e7e4d1047603c6db8872d7fdd7052536022bd529 Mon Sep 17 00:00:00 2001 From: AprilNEA Date: Mon, 15 Jun 2026 20:23:38 +0800 Subject: [PATCH 4/4] feat(gui): Diagnostics page with input-conflict detection + live monitor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- crates/openlogi-gui/locales/da.yml | 5 + crates/openlogi-gui/locales/de.yml | 5 + crates/openlogi-gui/locales/el.yml | 5 + crates/openlogi-gui/locales/en.yml | 5 + crates/openlogi-gui/locales/es.yml | 5 + crates/openlogi-gui/locales/fi.yml | 5 + crates/openlogi-gui/locales/fr.yml | 5 + crates/openlogi-gui/locales/it.yml | 5 + crates/openlogi-gui/locales/ja.yml | 5 + crates/openlogi-gui/locales/ko.yml | 5 + crates/openlogi-gui/locales/nb.yml | 5 + crates/openlogi-gui/locales/nl.yml | 5 + crates/openlogi-gui/locales/pl.yml | 5 + crates/openlogi-gui/locales/pt-BR.yml | 5 + crates/openlogi-gui/locales/pt-PT.yml | 5 + crates/openlogi-gui/locales/ru.yml | 5 + crates/openlogi-gui/locales/sv.yml | 5 + crates/openlogi-gui/locales/zh-CN.yml | 5 + crates/openlogi-gui/locales/zh-HK.yml | 5 + crates/openlogi-gui/locales/zh-TW.yml | 5 + crates/openlogi-gui/src/ipc_client.rs | 9 + crates/openlogi-gui/src/state.rs | 26 +++ crates/openlogi-gui/src/windows/settings.rs | 218 +++++++++++++++++++- 23 files changed, 342 insertions(+), 11 deletions(-) diff --git a/crates/openlogi-gui/locales/da.yml b/crates/openlogi-gui/locales/da.yml index 367e0fdf..3bf8ffc9 100644 --- a/crates/openlogi-gui/locales/da.yml +++ b/crates/openlogi-gui/locales/da.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Diagnostik" +"Input interception": "Opfangning af input" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Registrerer andre apps, der opfanger musens hændelsesstrøm – en almindelig årsag til markørforsinkelse." +"No other app is intercepting mouse input.": "Ingen anden app opfanger museinput." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "En anden app opfanger museinput, hvilket kan forårsage markørforsinkelse eller dublerede knaphandlinger: %{apps}" diff --git a/crates/openlogi-gui/locales/de.yml b/crates/openlogi-gui/locales/de.yml index f5932e34..d97963b1 100644 --- a/crates/openlogi-gui/locales/de.yml +++ b/crates/openlogi-gui/locales/de.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Diagnose" +"Input interception": "Abfangen von Eingaben" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Erkennt andere Apps, die den Maus-Ereignisstrom abgreifen – eine häufige Ursache für Zeigerverzögerung." +"No other app is intercepting mouse input.": "Keine andere App fängt Mauseingaben ab." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Eine andere App fängt Mauseingaben ab, was zu Zeigerverzögerung oder doppelten Tastenaktionen führen kann: %{apps}" diff --git a/crates/openlogi-gui/locales/el.yml b/crates/openlogi-gui/locales/el.yml index bcf7abf4..68727371 100644 --- a/crates/openlogi-gui/locales/el.yml +++ b/crates/openlogi-gui/locales/el.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Διαγνωστικά" +"Input interception": "Υποκλοπή εισόδου" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Εντοπίζει άλλες εφαρμογές που υποκλέπτουν τη ροή συμβάντων του ποντικιού — μια συνηθισμένη αιτία καθυστέρησης του δείκτη." +"No other app is intercepting mouse input.": "Καμία άλλη εφαρμογή δεν υποκλέπτει την είσοδο του ποντικιού." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Μια άλλη εφαρμογή υποκλέπτει την είσοδο του ποντικιού, κάτι που μπορεί να προκαλέσει καθυστέρηση του δείκτη ή διπλές ενέργειες κουμπιών: %{apps}" diff --git a/crates/openlogi-gui/locales/en.yml b/crates/openlogi-gui/locales/en.yml index 841c7858..6b835295 100644 --- a/crates/openlogi-gui/locales/en.yml +++ b/crates/openlogi-gui/locales/en.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Diagnostics" +"Input interception": "Input interception" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Detects other apps tapping the mouse event stream — a common cause of pointer lag." +"No other app is intercepting mouse input.": "No other app is intercepting mouse input." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}" diff --git a/crates/openlogi-gui/locales/es.yml b/crates/openlogi-gui/locales/es.yml index e49190b8..218ff7e9 100644 --- a/crates/openlogi-gui/locales/es.yml +++ b/crates/openlogi-gui/locales/es.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Diagnóstico" +"Input interception": "Interceptación de entrada" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Detecta otras apps que interceptan el flujo de eventos del ratón, una causa habitual de retraso del puntero." +"No other app is intercepting mouse input.": "Ninguna otra app está interceptando la entrada del ratón." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Otra app está interceptando la entrada del ratón, lo que puede causar retraso del puntero o acciones de botón duplicadas: %{apps}" diff --git a/crates/openlogi-gui/locales/fi.yml b/crates/openlogi-gui/locales/fi.yml index 3c444b7f..7f6b3d0b 100644 --- a/crates/openlogi-gui/locales/fi.yml +++ b/crates/openlogi-gui/locales/fi.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Diagnostiikka" +"Input interception": "Syötteen sieppaus" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Tunnistaa muut sovellukset, jotka sieppaavat hiiren tapahtumavirtaa – yleinen osoittimen viiveen syy." +"No other app is intercepting mouse input.": "Mikään muu sovellus ei sieppaa hiiren syötettä." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Toinen sovellus sieppaa hiiren syötettä, mikä voi aiheuttaa osoittimen viivettä tai painikkeiden kaksoistoimintoja: %{apps}" diff --git a/crates/openlogi-gui/locales/fr.yml b/crates/openlogi-gui/locales/fr.yml index ff824093..631c1ac7 100644 --- a/crates/openlogi-gui/locales/fr.yml +++ b/crates/openlogi-gui/locales/fr.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Diagnostics" +"Input interception": "Interception des entrées" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Détecte les autres applications qui interceptent le flux d'événements de la souris — une cause fréquente de latence du pointeur." +"No other app is intercepting mouse input.": "Aucune autre application n'intercepte les entrées de la souris." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Une autre application intercepte les entrées de la souris, ce qui peut provoquer une latence du pointeur ou des actions de bouton en double : %{apps}" diff --git a/crates/openlogi-gui/locales/it.yml b/crates/openlogi-gui/locales/it.yml index 0fc888db..343d5103 100644 --- a/crates/openlogi-gui/locales/it.yml +++ b/crates/openlogi-gui/locales/it.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Diagnostica" +"Input interception": "Intercettazione input" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Rileva altre app che intercettano il flusso di eventi del mouse, una causa comune di lentezza del puntatore." +"No other app is intercepting mouse input.": "Nessun'altra app sta intercettando l'input del mouse." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Un'altra app sta intercettando l'input del mouse, il che può causare lentezza del puntatore o azioni dei pulsanti duplicate: %{apps}" diff --git a/crates/openlogi-gui/locales/ja.yml b/crates/openlogi-gui/locales/ja.yml index a54347ab..6199f20a 100644 --- a/crates/openlogi-gui/locales/ja.yml +++ b/crates/openlogi-gui/locales/ja.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "診断" +"Input interception": "入力の横取り" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "マウスイベントストリームを監視している他のアプリを検出します。ポインタ遅延のよくある原因です。" +"No other app is intercepting mouse input.": "マウス入力を横取りしている他のアプリはありません。" +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "別のアプリがマウス入力を横取りしています。ポインタの遅延やボタンの二重動作の原因になることがあります:%{apps}" diff --git a/crates/openlogi-gui/locales/ko.yml b/crates/openlogi-gui/locales/ko.yml index 8a419f89..2e422374 100644 --- a/crates/openlogi-gui/locales/ko.yml +++ b/crates/openlogi-gui/locales/ko.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "진단" +"Input interception": "입력 가로채기" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "마우스 이벤트 스트림을 가로채는 다른 앱을 감지합니다. 포인터 지연의 흔한 원인입니다." +"No other app is intercepting mouse input.": "마우스 입력을 가로채는 다른 앱이 없습니다." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "다른 앱이 마우스 입력을 가로채고 있어 포인터 지연이나 버튼 중복 동작이 발생할 수 있습니다: %{apps}" diff --git a/crates/openlogi-gui/locales/nb.yml b/crates/openlogi-gui/locales/nb.yml index 4c7a05cb..eb9d0302 100644 --- a/crates/openlogi-gui/locales/nb.yml +++ b/crates/openlogi-gui/locales/nb.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Diagnostikk" +"Input interception": "Oppfanging av inndata" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Oppdager andre apper som fanger opp musens hendelsesstrøm – en vanlig årsak til pekerforsinkelse." +"No other app is intercepting mouse input.": "Ingen annen app fanger opp museinndata." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "En annen app fanger opp museinndata, noe som kan føre til pekerforsinkelse eller dupliserte knappehandlinger: %{apps}" diff --git a/crates/openlogi-gui/locales/nl.yml b/crates/openlogi-gui/locales/nl.yml index 7d66fd0b..b2108d62 100644 --- a/crates/openlogi-gui/locales/nl.yml +++ b/crates/openlogi-gui/locales/nl.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Diagnostiek" +"Input interception": "Invoeronderschepping" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Detecteert andere apps die de muisgebeurtenissenstroom onderscheppen — een veelvoorkomende oorzaak van vertraging van de aanwijzer." +"No other app is intercepting mouse input.": "Geen andere app onderschept muisinvoer." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Een andere app onderschept muisinvoer, wat kan leiden tot vertraging van de aanwijzer of dubbele knopacties: %{apps}" diff --git a/crates/openlogi-gui/locales/pl.yml b/crates/openlogi-gui/locales/pl.yml index 882356ce..59ced088 100644 --- a/crates/openlogi-gui/locales/pl.yml +++ b/crates/openlogi-gui/locales/pl.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Diagnostyka" +"Input interception": "Przechwytywanie danych wejściowych" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Wykrywa inne aplikacje przechwytujące strumień zdarzeń myszy — częstą przyczynę opóźnień wskaźnika." +"No other app is intercepting mouse input.": "Żadna inna aplikacja nie przechwytuje danych wejściowych myszy." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Inna aplikacja przechwytuje dane wejściowe myszy, co może powodować opóźnienia wskaźnika lub zdublowane działania przycisków: %{apps}" diff --git a/crates/openlogi-gui/locales/pt-BR.yml b/crates/openlogi-gui/locales/pt-BR.yml index aa54d061..3334c529 100644 --- a/crates/openlogi-gui/locales/pt-BR.yml +++ b/crates/openlogi-gui/locales/pt-BR.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Diagnóstico" +"Input interception": "Interceptação de entrada" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Detecta outros apps que interceptam o fluxo de eventos do mouse — uma causa comum de lentidão do ponteiro." +"No other app is intercepting mouse input.": "Nenhum outro app está interceptando a entrada do mouse." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Outro app está interceptando a entrada do mouse, o que pode causar lentidão do ponteiro ou ações de botão duplicadas: %{apps}" diff --git a/crates/openlogi-gui/locales/pt-PT.yml b/crates/openlogi-gui/locales/pt-PT.yml index b9f1aa8b..349670be 100644 --- a/crates/openlogi-gui/locales/pt-PT.yml +++ b/crates/openlogi-gui/locales/pt-PT.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Diagnóstico" +"Input interception": "Interceção de entrada" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Deteta outras apps que intercetam o fluxo de eventos do rato — uma causa comum de lentidão do ponteiro." +"No other app is intercepting mouse input.": "Nenhuma outra app está a intercetar a entrada do rato." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Outra app está a intercetar a entrada do rato, o que pode causar lentidão do ponteiro ou ações de botão duplicadas: %{apps}" diff --git a/crates/openlogi-gui/locales/ru.yml b/crates/openlogi-gui/locales/ru.yml index 1ebc5fa9..f9840399 100644 --- a/crates/openlogi-gui/locales/ru.yml +++ b/crates/openlogi-gui/locales/ru.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Диагностика" +"Input interception": "Перехват ввода" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Обнаруживает другие приложения, перехватывающие поток событий мыши, — частая причина задержки указателя." +"No other app is intercepting mouse input.": "Никакое другое приложение не перехватывает ввод мыши." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "Другое приложение перехватывает ввод мыши, что может вызывать задержку указателя или дублирование действий кнопок: %{apps}" diff --git a/crates/openlogi-gui/locales/sv.yml b/crates/openlogi-gui/locales/sv.yml index 4d6b4dbd..c3b278cc 100644 --- a/crates/openlogi-gui/locales/sv.yml +++ b/crates/openlogi-gui/locales/sv.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "Diagnostik" +"Input interception": "Avlyssning av indata" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "Upptäcker andra appar som fångar upp musens händelseström – en vanlig orsak till pekarfördröjning." +"No other app is intercepting mouse input.": "Ingen annan app fångar upp musinmatning." +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "En annan app fångar upp musinmatning, vilket kan orsaka pekarfördröjning eller dubblerade knappåtgärder: %{apps}" diff --git a/crates/openlogi-gui/locales/zh-CN.yml b/crates/openlogi-gui/locales/zh-CN.yml index 0d5b859a..dbfd69f3 100644 --- a/crates/openlogi-gui/locales/zh-CN.yml +++ b/crates/openlogi-gui/locales/zh-CN.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "诊断" +"Input interception": "输入拦截" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "检测其它正在监听鼠标事件流的应用——这是指针卡顿的常见原因。" +"No other app is intercepting mouse input.": "没有其它应用在拦截鼠标输入。" +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "另一个应用正在拦截鼠标输入,可能导致指针卡顿或按键重复触发:%{apps}" diff --git a/crates/openlogi-gui/locales/zh-HK.yml b/crates/openlogi-gui/locales/zh-HK.yml index cb007266..df85f8d4 100644 --- a/crates/openlogi-gui/locales/zh-HK.yml +++ b/crates/openlogi-gui/locales/zh-HK.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "診斷" +"Input interception": "輸入攔截" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "偵測其他正在監聽滑鼠事件流的應用程式——這是指標延遲的常見原因。" +"No other app is intercepting mouse input.": "沒有其他應用程式正在攔截滑鼠輸入。" +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "另一個應用程式正在攔截滑鼠輸入,可能導致指標延遲或按鍵重複觸發:%{apps}" diff --git a/crates/openlogi-gui/locales/zh-TW.yml b/crates/openlogi-gui/locales/zh-TW.yml index b7dd6e87..02ce01d7 100644 --- a/crates/openlogi-gui/locales/zh-TW.yml +++ b/crates/openlogi-gui/locales/zh-TW.yml @@ -235,3 +235,8 @@ _version: 1 "Reading supported DPI values…": "Reading supported DPI values…" "Couldn't read DPI — click to retry.": "Couldn't read DPI — click to retry." "This device did not report Adjustable DPI support.": "This device did not report Adjustable DPI support." +"Diagnostics": "診斷" +"Input interception": "輸入攔截" +"Detects other apps tapping the mouse event stream — a common cause of pointer lag.": "偵測其他正在監聽滑鼠事件流的應用程式——這是指標延遲的常見原因。" +"No other app is intercepting mouse input.": "沒有其他應用程式在攔截滑鼠輸入。" +"Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}": "另一個應用程式正在攔截滑鼠輸入,可能導致指標延遲或按鍵重複觸發:%{apps}" diff --git a/crates/openlogi-gui/src/ipc_client.rs b/crates/openlogi-gui/src/ipc_client.rs index 71ad5444..fe672266 100644 --- a/crates/openlogi-gui/src/ipc_client.rs +++ b/crates/openlogi-gui/src/ipc_client.rs @@ -95,6 +95,11 @@ pub enum Command { StartPairing(ReceiverSelector), PairDevice([u8; 6]), CancelPairing, + /// Drain the agent's live event-monitor buffer for the debug Diagnostics + /// monitor. The first poll enables monitoring agent-side; the agent + /// auto-disables it once polls stop. + #[cfg(all(target_os = "macos", debug_assertions))] + PollEventMonitor(oneshot::Sender>), } /// Handle the GUI holds to talk to the agent: a stream of poll updates, a @@ -714,6 +719,10 @@ async fn handle(client: &mut Option, cmd: Command) -> Result<(), () } Command::PairDevice(address) => client.pair_device(ctx, address).await.map_err(|_| ())?, Command::CancelPairing => client.cancel_pairing(ctx).await.map_err(|_| ())?, + #[cfg(all(target_os = "macos", debug_assertions))] + Command::PollEventMonitor(reply) => { + let _ = reply.send(rpc_result(client.poll_event_monitor(ctx).await)?); + } } Ok(()) } diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs index 5fd7b055..9ae95cc4 100644 --- a/crates/openlogi-gui/src/state.rs +++ b/crates/openlogi-gui/src/state.rs @@ -328,6 +328,11 @@ pub struct AppState { /// snapshots, so an agent restart's empty pre-enumeration list never /// blanks a report copied during the reconnect window. last_inventory: Vec, + /// Recent events streamed from the agent's hook for the debug live monitor + /// on the Diagnostics page. Bounded; only filled while the Settings window's + /// poll loop runs (debug macOS builds only). + #[cfg(all(target_os = "macos", debug_assertions))] + monitor_events: std::collections::VecDeque, } impl AppState { @@ -371,6 +376,8 @@ impl AppState { config, ipc_commands, last_inventory: Vec::new(), + #[cfg(all(target_os = "macos", debug_assertions))] + monitor_events: std::collections::VecDeque::new(), }; state.button_bindings = state.bindings_for_current(); state.gesture_bindings = state.gesture_bindings_for_current(); @@ -421,6 +428,25 @@ impl AppState { &self.last_inventory } + /// Append a batch of live-monitor events, capping the retained history so the + /// buffer can't grow without bound while the monitor is open. + #[cfg(all(target_os = "macos", debug_assertions))] + pub fn push_monitor_events(&mut self, events: Vec) { + const MAX: usize = 200; + self.monitor_events.extend(events); + let overflow = self.monitor_events.len().saturating_sub(MAX); + self.monitor_events.drain(..overflow); + } + + /// Recent live-monitor events, oldest first. + #[cfg(all(target_os = "macos", debug_assertions))] + #[must_use] + pub fn monitor_events( + &self, + ) -> &std::collections::VecDeque { + &self.monitor_events + } + /// Config schema version and the number of devices with saved configuration. #[must_use] pub fn config_summary(&self) -> (u32, usize) { diff --git a/crates/openlogi-gui/src/windows/settings.rs b/crates/openlogi-gui/src/windows/settings.rs index 08222016..74a257de 100644 --- a/crates/openlogi-gui/src/windows/settings.rs +++ b/crates/openlogi-gui/src/windows/settings.rs @@ -11,9 +11,9 @@ use gpui::StatefulInteractiveElement as _; #[cfg(any(target_os = "macos", target_os = "linux"))] use gpui::rgb; use gpui::{ - AnyElement, App, AppContext as _, BorrowAppContext as _, Context, Entity, InteractiveElement, - IntoElement, ParentElement as _, Render, SharedString, Size, Styled as _, Subscription, Window, - div, prelude::FluentBuilder as _, px, + AnyElement, App, AppContext as _, Axis, BorrowAppContext as _, Context, Entity, + InteractiveElement, IntoElement, ParentElement as _, Render, SharedString, Size, Styled as _, + Subscription, Window, div, prelude::FluentBuilder as _, px, }; use gpui_component::{ IconName, IndexPath, Sizable, h_flex, @@ -25,6 +25,10 @@ use gpui_component::{ use openlogi_core::config::{ DEFAULT_THUMBWHEEL_SENSITIVITY, MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY, }; +// Event-tap enumeration is a macOS (`CGEventTap`) concept; the Diagnostics page +// that surfaces it is macOS-only. +#[cfg(target_os = "macos")] +use openlogi_hook::Hook; use crate::app_menu::{CloseWindow, Minimize, Zoom}; #[cfg(target_os = "macos")] @@ -46,6 +50,11 @@ pub struct SettingsView { /// re-walking the cache on every render. A snapshot — reopen to refresh /// after a Clear. asset_cache_desc: SharedString, + /// Drives the debug live event monitor: polls the agent on a timer while the + /// Settings window is open. Dropping it with the view stops polling, which + /// lets the agent's idle janitor turn monitoring back off. + #[cfg(all(target_os = "macos", debug_assertions))] + _monitor_task: gpui::Task<()>, } impl SettingsView { @@ -77,11 +86,38 @@ impl SettingsView { cx.subscribe_in(&sensitivity_slider, window, Self::on_sensitivity_slider) .detach(); + // Poll the agent's live event monitor while this window is open. The task + // is held in the view, so closing Settings drops it, polling stops, and + // the agent disables monitoring on its own. + #[cfg(all(target_os = "macos", debug_assertions))] + let monitor_task = cx.spawn(async move |_view, cx| { + loop { + let sender = cx.update_global::(|s, _| s.ipc_sender()); + let (tx, rx) = tokio::sync::oneshot::channel(); + if sender + .send(crate::ipc_client::Command::PollEventMonitor(tx)) + .is_ok() + && let Ok(events) = rx.await + && !events.is_empty() + { + cx.update_global::(|state, cx| { + state.push_monitor_events(events); + cx.refresh_windows(); + }); + } + cx.background_executor() + .timer(std::time::Duration::from_millis(300)) + .await; + } + }); + Self { appearance_obs: None, language_select, sensitivity_slider, asset_cache_desc: cache_size_description(), + #[cfg(all(target_os = "macos", debug_assertions))] + _monitor_task: monitor_task, } } @@ -152,6 +188,17 @@ impl Render for SettingsView { fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { let pal = theme::palette(cx); + let settings = Settings::new("settings") + .sidebar_width(px(210.)) + .page(general_page(self.sensitivity_slider.clone())) + .page(permissions_page(pal)) + .page(assets_page(pal, self.asset_cache_desc.clone())) + .page(language_page(self.language_select.clone())); + // Surfaces competing macOS event taps (a pointer-lag cause) and, in debug + // builds, the full tap list and a live event monitor. + #[cfg(target_os = "macos")] + let settings = settings.page(diagnostics_page(pal)); + div() .size_full() .bg(pal.bg) @@ -159,14 +206,7 @@ impl Render for SettingsView { .on_action(|_: &CloseWindow, window, _| window.remove_window()) .on_action(|_: &Minimize, window, _| window.minimize_window()) .on_action(|_: &Zoom, window, _| window.zoom_window()) - .child( - Settings::new("settings") - .sidebar_width(px(210.)) - .page(general_page(self.sensitivity_slider.clone())) - .page(permissions_page(pal)) - .page(assets_page(pal, self.asset_cache_desc.clone())) - .page(language_page(self.language_select.clone())), - ) + .child(settings) } } @@ -252,6 +292,162 @@ fn general_page(sensitivity_slider: Entity) -> SettingPage { .group(group) } +/// The Diagnostics page (macOS): flags other apps intercepting the mouse event +/// stream — a common pointer-lag cause — and, in debug builds, dumps the full +/// event-tap list. The live event monitor is added in [`SettingsView`]. +#[cfg(target_os = "macos")] +fn diagnostics_page(pal: Palette) -> SettingPage { + SettingPage::new(tr!("Diagnostics")) + .icon(IconName::Info) + .resettable(false) + .group( + SettingGroup::new().item( + SettingItem::new( + tr!("Input interception"), + SettingField::render(move |_, _, cx| input_conflict_field(pal, cx)), + ) + .description(tr!( + "Detects other apps tapping the mouse event stream — a common cause of pointer lag." + )) + // Vertical: the status + tap list are wide, multi-line content, + // not a compact right-side control — stacking them full-width + // below the title lets the lines wrap instead of overflowing. + .layout(Axis::Vertical), + ), + ) +} + +/// Live status: the curated known-conflict check over the current event taps, +/// 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(); + + // Dedup the product names of input-gating taps owned by known conflicts. + let mut conflicts: Vec<&'static str> = Vec::new(); + for tap in &taps { + if tap.gates_input() + && let Some(name) = tap.known_input_conflict() + && !conflicts.contains(&name) + { + conflicts.push(name); + } + } + + let mut col = v_flex().w_full().gap_1(); + if conflicts.is_empty() { + col = col.child( + div() + .text_xs() + .text_color(rgb(theme::STATUS_CONNECTED)) + .child(tr!("No other app is intercepting mouse input.")), + ); + } else { + col = col.child( + div() + .text_sm() + .text_color(rgb(theme::STATUS_CONNECTING)) + .child(tr!( + "Another app is intercepting mouse input, which can cause pointer lag or duplicated button actions: %{apps}", + apps => conflicts.join(", ") + )), + ); + } + + #[cfg(debug_assertions)] + { + col = col.child(debug_tap_list(&taps, pal)); + col = col.child(monitor_list(pal, cx)); + } + #[cfg(not(debug_assertions))] + { + let _ = (pal, cx); + } + + col.into_any_element() +} + +/// Debug-only live event monitor: the events the agent's hook has observed, +/// newest first. Polled into [`AppState`] by [`SettingsView`]'s task. +#[cfg(all(target_os = "macos", debug_assertions))] +fn monitor_list(pal: Palette, cx: &mut App) -> impl IntoElement { + let lines: Vec = cx + .try_global::() + .map(|s| { + s.monitor_events() + .iter() + .rev() + .take(20) + .map(format_monitor_event) + .collect() + }) + .unwrap_or_default(); + + let mut col = v_flex().w_full().mt_2().gap_1().child( + div() + .text_xs() + .text_color(pal.text_muted) + .child("Live events (newest first)"), + ); + if lines.is_empty() { + col = col.child( + div() + .text_xs() + .text_color(pal.text_muted) + .child("(click or scroll to see what the hook receives)"), + ); + } else { + for line in lines { + col = col.child(div().text_xs().text_color(pal.text_primary).child(line)); + } + } + col +} + +#[cfg(all(target_os = "macos", debug_assertions))] +fn format_monitor_event(event: &openlogi_agent_core::ipc::MonitorEvent) -> String { + use openlogi_agent_core::ipc::MonitorEvent; + match event { + MonitorEvent::Button { button, pressed } => { + format!("button {button} {}", if *pressed { "down" } else { "up" }) + } + MonitorEvent::Scroll { delta_x, delta_y } => { + format!("scroll dx={delta_x:.1} dy={delta_y:.1}") + } + MonitorEvent::CaptureInterrupted => "capture interrupted".to_string(), + } +} + +/// Debug-only raw dump of every event tap: owner, location, mode, enabled. Taps +/// that gate the HID stream are highlighted, since those are the lag-relevant +/// ones. English-only by design — a developer aid, not a shipped string. +#[cfg(all(target_os = "macos", debug_assertions))] +fn debug_tap_list(taps: &[openlogi_hook::EventTapInfo], pal: Palette) -> impl IntoElement { + let mut col = v_flex().w_full().mt_2().gap_1().child( + div() + .text_xs() + .text_color(pal.text_muted) + .child(format!("{} event tap(s)", taps.len())), + ); + for tap in taps { + let owner = tap.owner_name.as_deref().unwrap_or("(unknown)"); + let mode = if tap.active { "active" } else { "listen" }; + let line = format!( + "{owner} (pid {}) — {:?} {mode} enabled={}", + tap.owner_pid, tap.location, tap.enabled + ); + let row = div().text_xs().child(line); + let row = if tap.gates_input() { + row.text_color(rgb(theme::STATUS_CONNECTING)) + } else { + row.text_color(pal.text_muted) + }; + col = col.child(row); + } + col +} + #[cfg_attr( not(any(target_os = "macos", target_os = "linux")), allow(unused_variables)