Skip to main content

openlogi_hook/
lib.rs

1//! OS-level mouse-event hook for OpenLogi.
2//!
3//! | Platform | Implementation |
4//! |----------|---------------|
5//! | macOS    | `CGEventTap` (same primitive used by Logi Options+) |
6//! | Linux    | `evdev` grab + `uinput` re-injection |
7//! | Windows  | `WH_MOUSE_LL` low-level mouse hook |
8//!
9//! # Usage
10//!
11//! ```no_run
12//! use openlogi_hook::{Hook, MouseEvent, EventDisposition};
13//!
14//! if !Hook::has_accessibility() {
15//!     eprintln!("grant Accessibility access first");
16//!     return;
17//! }
18//!
19//! let hook = Hook::start(|event| {
20//!     println!("{event:?}");
21//!     EventDisposition::PassThrough
22//! }).unwrap();
23//!
24//! // … later, on shutdown:
25//! hook.stop();
26//! ```
27
28use std::cfg_select;
29
30pub use openlogi_core::binding::ButtonId;
31
32/// Best-effort identity for the physical device that produced an OS event.
33///
34/// Platform hooks fill the stable fields they can read cheaply from the native
35/// event. Consumers use this to apply host-side settings per device rather than
36/// through the currently selected UI device.
37#[derive(Clone, Debug, Default, PartialEq, Eq)]
38pub struct EventDevice {
39    /// USB/Bluetooth vendor id when the platform exposes it.
40    pub vendor_id: Option<u32>,
41    /// USB/Bluetooth/HID product id when the platform exposes it.
42    pub product_id: Option<u32>,
43    /// Human-readable product name, normalized by consumers before matching.
44    pub product_name: Option<String>,
45}
46
47/// An event captured at the OS layer.
48#[derive(Clone, Debug)]
49pub enum MouseEvent {
50    /// A mouse button was pressed or released.
51    Button {
52        /// Which button.
53        id: ButtonId,
54        /// `true` = button down; `false` = button up.
55        pressed: bool,
56    },
57    /// A scroll-wheel tick (or continuous momentum scroll).
58    Scroll {
59        /// Positive = right, negative = left.
60        delta_x: f32,
61        /// Positive = down, negative = up.
62        delta_y: f32,
63        /// `true` when the OS attributes this scroll to a trackpad / Magic Mouse
64        /// gesture rather than a mouse wheel, so a consumer can transform the
65        /// wheel while leaving native trackpad scrolling alone (issue #126).
66        ///
67        /// On macOS this is resolved from the `IOHIDEvent` sender's IOKit device
68        /// identity, because Logitech free-spin wheels can carry the same phase
69        /// flags as a trackpad. Sender-less events fall back to the phase fields.
70        /// Always `false` on Linux/Windows, where the wheel and trackpad arrive
71        /// as distinct event types rather than one flagged stream.
72        from_trackpad: bool,
73        /// Best-effort physical source of the scroll event. `None` means the
74        /// platform could not attribute the event to a device, or the event was
75        /// synthetic.
76        device: Option<EventDevice>,
77    },
78    /// Pointer movement, in device units. Emitted so a held gesture button can
79    /// accumulate a swipe; the callback passes these through (the cursor keeps
80    /// moving) and only reads them while a gesture button is down.
81    Moved {
82        /// Positive = right, negative = left.
83        delta_x: i32,
84        /// Positive = down, negative = up.
85        delta_y: i32,
86    },
87    /// The OS interrupted event capture (on macOS, the tap was disabled by a
88    /// timeout or by competing user input). Any in-progress gesture hold must be
89    /// cancelled: a button-up dropped during the gap would otherwise leave a
90    /// stale hold that the next stray pointer move turns into a phantom swipe.
91    /// Carries no data and is always passed through.
92    CaptureInterrupted,
93}
94
95/// What the hook callback wants the OS to do with the captured event.
96#[derive(Clone, Copy, Debug, PartialEq, Eq)]
97pub enum EventDisposition {
98    /// Let the event reach its original target unchanged.
99    PassThrough,
100    /// Drop the event; the target application never sees it.
101    Suppress,
102}
103
104/// Where in the event stream a tap is inserted (macOS `CGEventTapLocation`).
105#[derive(Clone, Copy, Debug, PartialEq, Eq)]
106pub enum TapLocation {
107    /// `kCGHIDEventTap` — the lowest level, ahead of the window server. An
108    /// *active* tap here gates raw device input for the whole system, so a slow
109    /// or wedged owner adds latency to every event. This is where OpenLogi (and
110    /// Logi Options+) install.
111    Hid,
112    /// `kCGSessionEventTap` — scoped to the current login session.
113    Session,
114    /// `kCGAnnotatedSessionEventTap` — session tap that also sees annotations.
115    AnnotatedSession,
116    /// A location value newer than this enum knows about.
117    Other(u32),
118}
119
120/// A live event tap installed somewhere in the system, as reported by
121/// [`Hook::list_event_taps`]. Read-only diagnostic snapshot — enumerating taps
122/// needs no Accessibility grant and any process in the session sees them all.
123///
124/// The per-tap latency figures `CGEventTapInformation` carries are deliberately
125/// omitted: empirically they hold uninitialised sentinel values that change
126/// between samples, so they are not a trustworthy lag signal.
127#[derive(Clone, Debug)]
128pub struct EventTapInfo {
129    /// The system-assigned tap identifier.
130    pub tap_id: u32,
131    /// Where the tap sits in the event stream.
132    pub location: TapLocation,
133    /// `true` for an *active* tap (`kCGEventTapOptionDefault`) that can modify
134    /// or suppress events; `false` for a passive *listen-only* tap, which
135    /// physically cannot stall input.
136    pub active: bool,
137    /// Whether the tap is currently enabled (servicing events).
138    pub enabled: bool,
139    /// PID of the process that installed the tap.
140    pub owner_pid: i32,
141    /// Best-effort executable file name of the owner, or `None` if the process
142    /// has exited or its path is unreadable.
143    pub owner_name: Option<String>,
144    /// PID of the single process whose events this tap intercepts, or `None`
145    /// for a global tap (one that sees every process's events).
146    pub target_pid: Option<i32>,
147}
148
149impl EventTapInfo {
150    /// `true` when this tap sits *active* at the [`TapLocation::Hid`] level and
151    /// is enabled — the one configuration that inserts the owner into the path
152    /// of every event and can therefore add latency system-wide. Listen-only,
153    /// disabled, or session-level taps cannot stall input this way.
154    #[must_use]
155    pub fn gates_input(&self) -> bool {
156        self.active && self.enabled && self.location == TapLocation::Hid
157    }
158
159    /// If this tap's owner is a known third-party input driver that competes
160    /// with OpenLogi for the mouse stream, return its product name — used to
161    /// warn the user about a likely pointer-lag cause.
162    ///
163    /// Matches on the owner executable name only; callers should combine it with
164    /// [`Self::gates_input`] so a competitor's *inactive* helper isn't flagged.
165    #[must_use]
166    pub fn known_input_conflict(&self) -> Option<&'static str> {
167        // (lower-cased executable-name substring, product display name). Brand
168        // names are not localised; only the surrounding warning copy is.
169        const KNOWN: &[(&str, &str)] = &[
170            ("logioptionsplus", "Logi Options+"),
171            ("logioptions", "Logitech Options"),
172            ("logimgr", "Logitech Options"),
173            ("lccdaemon", "Logitech Control Center"),
174            ("steermouse", "SteerMouse"),
175            ("bettermouse", "BetterMouse"),
176            ("usboverdrive", "USB Overdrive"),
177            ("mac mouse fix", "Mac Mouse Fix"),
178            ("linearmouse", "LinearMouse"),
179            ("smoothscroll", "SmoothScroll"),
180        ];
181        let name = self.owner_name.as_deref()?.to_ascii_lowercase();
182        KNOWN
183            .iter()
184            .find(|(needle, _)| name.contains(needle))
185            .map(|&(_, label)| label)
186    }
187}
188
189/// Errors that [`Hook::start`] and related functions can produce.
190#[derive(Debug, thiserror::Error)]
191pub enum HookError {
192    /// This platform has no hook implementation (neither macOS, Linux, nor
193    /// Windows).
194    #[error("mouse event hook is not supported on this platform")]
195    Unsupported,
196    /// macOS Accessibility permission has not been granted to this process.
197    #[error(
198        "macOS Accessibility permission is required to capture mouse events; \
199         grant it in System Settings → Privacy & Security → Accessibility"
200    )]
201    AccessibilityDenied,
202    /// `CGEventTapCreate` returned null, or the run loop source could not be
203    /// created. The inner string carries the context.
204    #[error("CGEventTap setup failed: {0}")]
205    MacOsTap(String),
206    /// No mouse device was found under `/dev/input`. Either no pointing device
207    /// is connected, or the process lacks read permission on the device nodes
208    /// (add the user to the `input` group, or add a `udev` rule).
209    #[cfg(target_os = "linux")]
210    #[error(
211        "no mouse device found under /dev/input; \
212         ensure a pointing device is connected and the process has read permission \
213         (add user to the `input` group or add a udev rule)"
214    )]
215    NoDeviceFound,
216    /// A Linux-specific I/O error occurred while setting up or running the hook.
217    #[cfg(target_os = "linux")]
218    #[error("Linux input error: {0}")]
219    Linux(#[source] std::io::Error),
220    /// `SetWindowsHookExW` failed, or the hook thread could not be started.
221    #[error("Windows mouse hook setup failed: {0}")]
222    WindowsHook(String),
223}
224
225/// A running OS-level mouse hook. Call [`Hook::stop`] to tear down.
226///
227/// On macOS a dedicated thread runs a `CFRunLoop` draining a `CGEventTap`.
228/// On Linux one thread per physical mouse device reads `evdev` events and
229/// re-injects pass-through events via a `uinput` virtual device. On Windows a
230/// dedicated thread owns a `WH_MOUSE_LL` hook and pumps its message loop.
231/// Call `stop` (or let the value drop) to shut down all threads and release
232/// grabbed devices.
233pub struct Hook {
234    #[cfg(target_os = "macos")]
235    inner: Option<macos::HookInner>,
236    #[cfg(target_os = "linux")]
237    inner: Option<linux::HookInner>,
238    #[cfg(target_os = "windows")]
239    inner: Option<windows::HookInner>,
240    /// Makes `Hook` uninhabited on unsupported targets so [`Hook::start`] can
241    /// only ever return `Err` there and the type can never be constructed.
242    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
243    never: std::convert::Infallible,
244}
245
246impl Drop for Hook {
247    fn drop(&mut self) {
248        self.shutdown();
249    }
250}
251
252impl Hook {
253    /// Install the mouse hook and start delivering events to `cb`.
254    ///
255    /// The callback runs on a private background thread for every mouse button
256    /// or scroll event. It must return [`EventDisposition`] quickly — blocking
257    /// it stalls input delivery system-wide.
258    ///
259    /// On macOS, returns [`HookError::AccessibilityDenied`] when Accessibility
260    /// permission has not been granted. On Linux, returns
261    /// [`HookError::NoDeviceFound`] when no mouse device is accessible. On
262    /// Windows, installs a `WH_MOUSE_LL` low-level mouse hook.
263    pub fn start(
264        cb: impl Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static,
265    ) -> Result<Self, HookError> {
266        cfg_select! {
267            target_os = "macos" => {
268                macos::start(cb).map(|inner| Self { inner: Some(inner) })
269            }
270            target_os = "linux" => {
271                linux::start(cb).map(|inner| Self { inner: Some(inner) })
272            }
273            target_os = "windows" => {
274                windows::start(cb).map(|inner| Self { inner: Some(inner) })
275            }
276            _ => {
277                let _ = cb;
278                Err(HookError::Unsupported)
279            }
280        }
281    }
282
283    /// Stop the hook and release OS resources.
284    ///
285    /// Signals background threads to exit and blocks until they join. Calling
286    /// this explicitly is preferred over relying on `Drop` when errors in
287    /// cleanup should be visible. `Drop` calls this automatically.
288    pub fn stop(mut self) {
289        self.shutdown();
290    }
291
292    /// Tear down the platform hook if it is still running. Idempotent: the
293    /// first call takes `inner`, so the `Drop` after an explicit [`Self::stop`]
294    /// is a no-op.
295    fn shutdown(&mut self) {
296        cfg_select! {
297            target_os = "macos" => {
298                if let Some(inner) = self.inner.take() {
299                    macos::stop(inner);
300                }
301            }
302            target_os = "linux" => {
303                if let Some(inner) = self.inner.take() {
304                    linux::stop(inner);
305                }
306            }
307            target_os = "windows" => {
308                if let Some(inner) = self.inner.take() {
309                    windows::stop(inner);
310                }
311            }
312            _ => {
313                // Unreachable: `never: Infallible` makes `Hook` uninhabited here.
314            }
315        }
316    }
317
318    /// Returns `true` when the process has the permissions required to install
319    /// the hook.
320    ///
321    /// On macOS, checks the Accessibility entitlement. On Linux and Windows
322    /// this always returns `true`; those platforms enforce permissions at a
323    /// lower layer (device-node ownership / group membership on Linux; the
324    /// Windows low-level hook needs no separate privacy grant).
325    #[must_use]
326    pub fn has_accessibility() -> bool {
327        cfg_select! {
328            target_os = "macos" => { macos::has_accessibility() }
329            _ => { true }
330        }
331    }
332
333    /// Show the macOS Accessibility permission dialog and register this
334    /// process in System Settings → Privacy & Security → Accessibility.
335    ///
336    /// Unlike [`Self::has_accessibility`], this passes the
337    /// `kAXTrustedCheckOptionPrompt` option, so macOS surfaces the native
338    /// "open System Settings" dialog the first time and lists the app there
339    /// (otherwise the user would have to add the binary by hand). Called for
340    /// its side effect; the resulting trust state is observed separately via
341    /// [`Self::has_accessibility`]. No-op on non-macOS.
342    pub fn prompt_accessibility() {
343        cfg_select! {
344            target_os = "macos" => { macos::prompt_accessibility(); }
345            _ => {}
346        }
347    }
348
349    /// Enumerate every event tap currently installed in this login session.
350    ///
351    /// A read-only diagnostic snapshot for spotting input contention — e.g. a
352    /// competing app holding an *active* [`TapLocation::Hid`] tap (the classic
353    /// "another driver is also intercepting the mouse" cause of pointer lag),
354    /// or OpenLogi's own tap being unexpectedly disabled. Needs no Accessibility
355    /// grant; the call sees every process's taps regardless of who asks.
356    ///
357    /// Returns an empty vector on non-macOS targets, which have no equivalent
358    /// global tap registry.
359    #[must_use]
360    pub fn list_event_taps() -> Vec<EventTapInfo> {
361        cfg_select! {
362            target_os = "macos" => { macos::list_event_taps() }
363            _ => { Vec::new() }
364        }
365    }
366}
367
368/// Return an opaque string identifying the currently frontmost application.
369///
370/// On macOS this is the bundle identifier, e.g. `"com.microsoft.VSCode"`.
371/// On Linux (X11 / XWayland) this is the `WM_CLASS` class component,
372/// e.g. `"Code"` or `"Firefox"`. Pure Wayland windows (not running under
373/// XWayland) are not visible through this path and return `None`. On Windows
374/// this is the lower-cased executable path of the foreground process.
375///
376/// `None` when no app is frontmost, when reading fails, or on unsupported
377/// platforms. Costs one X11 round-trip on Linux, four `objc_msgSend`s on
378/// macOS — well under a millisecond at the 1 Hz polling cadence in
379/// `openlogi-gui::app_watcher`.
380#[must_use]
381pub fn frontmost_bundle_id() -> Option<String> {
382    cfg_select! {
383        target_os = "macos" => { macos::frontmost_bundle_id() }
384        target_os = "linux" => { linux::frontmost_bundle_id() }
385        target_os = "windows" => { windows::frontmost_process_path() }
386        _ => { None }
387    }
388}
389
390#[cfg(target_os = "macos")]
391mod macos;
392
393#[cfg(target_os = "linux")]
394mod linux;
395
396#[cfg(target_os = "windows")]
397mod windows;
398
399#[cfg(test)]
400mod tests;