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 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) 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..7a2dbd90 100644 --- a/crates/openlogi-hook/src/lib.rs +++ b/crates/openlogi-hook/src/lib.rs @@ -70,6 +70,91 @@ 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, +} + +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 { @@ -243,6 +328,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(); 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 + ); +}