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 (motion is edge-clamped) |
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/// Logitech's USB/Bluetooth vendor id (`0x046D`), widened from
33/// [`openlogi_core::hid::LOGITECH_VENDOR_ID`] because the hook's identity
34/// sources (IOKit, evdev) hand it back as a `u32`.
35pub const LOGITECH_VENDOR_ID: u32 = openlogi_core::hid::LOGITECH_VENDOR_ID as u32;
36
37/// Cursor position in the operating system's global screen coordinate space.
38#[derive(Clone, Copy, Debug, PartialEq)]
39pub struct CursorPosition {
40    /// Horizontal screen coordinate.
41    pub x: f64,
42    /// Vertical screen coordinate.
43    pub y: f64,
44}
45
46/// Best-effort identity for the physical device that produced an OS event.
47///
48/// Platform hooks fill the stable fields they can read cheaply from the native
49/// event. Consumers use this to apply host-side settings per device rather than
50/// through the currently selected UI device.
51#[derive(Clone, Debug, Default, PartialEq, Eq)]
52pub struct EventDevice {
53    /// USB/Bluetooth vendor id when the platform exposes it.
54    pub vendor_id: Option<u32>,
55    /// USB/Bluetooth/HID product id when the platform exposes it.
56    pub product_id: Option<u32>,
57    /// Human-readable product name, normalized by consumers before matching.
58    pub product_name: Option<String>,
59}
60
61impl EventDevice {
62    /// Whether this looks like a trackpad/touchpad (must never be remapped).
63    #[must_use]
64    pub fn is_trackpad_like(&self) -> bool {
65        self.product_name.as_deref().is_some_and(|n| {
66            let n = n.to_ascii_lowercase();
67            n.contains("trackpad") || n.contains("touchpad") || n.contains("touch pad")
68        })
69    }
70
71    /// Whether this is a Logitech product OpenLogi may remap buttons for.
72    #[must_use]
73    pub fn is_logitech(&self) -> bool {
74        if self.vendor_id == Some(LOGITECH_VENDOR_ID) {
75            return true;
76        }
77        self.product_name.as_deref().is_some_and(|n| {
78            let n = n.to_ascii_lowercase();
79            n.contains("logitech") || n.starts_with("logi ")
80        })
81    }
82}
83
84/// Whether the OS hook may suppress/remap a button event from this source.
85///
86/// Fail-closed on macOS-style attribution: only a known Logitech non-trackpad
87/// source is remappable. Unknown / non-Logitech / trackpad sources always pass
88/// through so a wedged remap policy can never brick the system pointer.
89#[must_use]
90pub fn source_is_remappable(device: Option<&EventDevice>) -> bool {
91    match device {
92        Some(d) if d.is_trackpad_like() => false,
93        Some(d) => d.is_logitech(),
94        None => false,
95    }
96}
97
98/// Which modifier keys were held when a key event fired. Mirrors the
99/// detectable macOS modifier flags. Note `Fn` is deliberately absent — it is
100/// firmware-internal and never reported on non-function-row keys (see the
101/// function-key-remapper spec, Appendix A).
102#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
103#[expect(
104    clippy::struct_excessive_bools,
105    reason = "four independent modifier flags from OS event bits"
106)]
107pub struct KeyModifiers {
108    pub shift: bool,
109    pub control: bool,
110    pub option: bool,
111    pub command: bool,
112}
113
114/// A keyboard event observed by the hook.
115#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub struct KeyEvent {
117    /// Platform virtual keycode (macOS: `kVK_*`, e.g. 122 = F1, 53 = Escape).
118    pub keycode: u16,
119    /// `true` = key down; `false` = key up.
120    pub pressed: bool,
121    /// Which modifiers were held.
122    pub modifiers: KeyModifiers,
123}
124
125/// Anything the OS hook can observe. `Mouse` preserves the existing callback
126/// payload; `Key` is the keyboard path added by the function-key remapper.
127/// Wrapping both in a union means `Hook::start`'s callback widens once and
128/// stays stable as further event classes arrive.
129#[derive(Clone, Debug)]
130pub enum HookEvent {
131    /// Mouse button / scroll / move event.
132    Mouse(MouseEvent),
133    /// Keyboard event (function-key remapper path).
134    Key(KeyEvent),
135}
136
137/// An event captured at the OS layer.
138#[derive(Clone, Debug)]
139pub enum MouseEvent {
140    /// A mouse button was pressed or released.
141    Button {
142        /// Which button.
143        id: ButtonId,
144        /// `true` = button down; `false` = button up.
145        pressed: bool,
146        /// Best-effort physical source. `None` when the platform cannot
147        /// attribute the event (Windows today) or it was synthetic.
148        device: Option<EventDevice>,
149    },
150    /// A scroll-wheel tick (or continuous momentum scroll).
151    Scroll {
152        /// Positive = right, negative = left.
153        delta_x: f32,
154        /// Positive = down, negative = up.
155        delta_y: f32,
156        /// `true` when the OS attributes this scroll to a trackpad / Magic Mouse
157        /// gesture rather than a mouse wheel, so a consumer can transform the
158        /// wheel while leaving native trackpad scrolling alone (issue #126).
159        ///
160        /// On macOS this is resolved from the `IOHIDEvent` sender's IOKit device
161        /// identity, because Logitech free-spin wheels can carry the same phase
162        /// flags as a trackpad. Sender-less events fall back to the phase fields.
163        /// Always `false` on Linux/Windows, where the wheel and trackpad arrive
164        /// as distinct event types rather than one flagged stream.
165        from_trackpad: bool,
166        /// Best-effort physical source of the scroll event. `None` means the
167        /// platform could not attribute the event to a device, or the event was
168        /// synthetic.
169        device: Option<EventDevice>,
170    },
171    /// Pointer movement, in device units. Emitted so a held gesture button can
172    /// accumulate a swipe; the callback passes these through (the cursor keeps
173    /// moving) and only reads them while a gesture button is down.
174    Moved {
175        /// Positive = right, negative = left.
176        delta_x: i32,
177        /// Positive = down, negative = up.
178        delta_y: i32,
179    },
180    /// The OS interrupted event capture (on macOS, the tap was disabled by a
181    /// timeout or by competing user input). Any in-progress gesture hold must be
182    /// cancelled: a button-up dropped during the gap would otherwise leave a
183    /// stale hold that the next stray pointer move turns into a phantom swipe.
184    /// Carries no data and is always passed through.
185    CaptureInterrupted,
186}
187
188/// What the hook callback wants the OS to do with the captured event.
189#[derive(Clone, Copy, Debug, PartialEq, Eq)]
190pub enum EventDisposition {
191    /// Let the event reach its original target unchanged.
192    PassThrough,
193    /// Drop the event; the target application never sees it.
194    Suppress,
195}
196
197/// Where in the event stream a tap is inserted (macOS `CGEventTapLocation`).
198#[derive(Clone, Copy, Debug, PartialEq, Eq)]
199pub enum TapLocation {
200    /// `kCGHIDEventTap` — the lowest level, ahead of the window server. An
201    /// *active* tap here gates raw device input for the whole system, so a slow
202    /// or wedged owner adds latency to every event. This is where OpenLogi (and
203    /// Logi Options+) install.
204    Hid,
205    /// `kCGSessionEventTap` — scoped to the current login session.
206    Session,
207    /// `kCGAnnotatedSessionEventTap` — session tap that also sees annotations.
208    AnnotatedSession,
209    /// A location value newer than this enum knows about.
210    Other(u32),
211}
212
213/// A live event tap installed somewhere in the system, as reported by
214/// [`Hook::list_event_taps`]. Read-only diagnostic snapshot — enumerating taps
215/// needs no Accessibility grant and any process in the session sees them all.
216///
217/// The per-tap latency figures `CGEventTapInformation` carries are deliberately
218/// omitted: empirically they hold uninitialised sentinel values that change
219/// between samples, so they are not a trustworthy lag signal.
220#[derive(Clone, Debug)]
221pub struct EventTapInfo {
222    /// The system-assigned tap identifier.
223    pub tap_id: u32,
224    /// Where the tap sits in the event stream.
225    pub location: TapLocation,
226    /// `true` for an *active* tap (`kCGEventTapOptionDefault`) that can modify
227    /// or suppress events; `false` for a passive *listen-only* tap, which
228    /// physically cannot stall input.
229    pub active: bool,
230    /// Whether the tap is currently enabled (servicing events).
231    pub enabled: bool,
232    /// PID of the process that installed the tap.
233    pub owner_pid: i32,
234    /// Best-effort executable file name of the owner, or `None` if the process
235    /// has exited or its path is unreadable.
236    pub owner_name: Option<String>,
237    /// PID of the single process whose events this tap intercepts, or `None`
238    /// for a global tap (one that sees every process's events).
239    pub target_pid: Option<i32>,
240}
241
242impl EventTapInfo {
243    /// `true` when this tap sits *active* at the [`TapLocation::Hid`] level and
244    /// is enabled — the one configuration that inserts the owner into the path
245    /// of every event and can therefore add latency system-wide. Listen-only,
246    /// disabled, or session-level taps cannot stall input this way.
247    #[must_use]
248    pub fn gates_input(&self) -> bool {
249        self.active && self.enabled && self.location == TapLocation::Hid
250    }
251
252    /// If this tap's owner is a known third-party input driver that competes
253    /// with OpenLogi for the mouse stream, return its product name — used to
254    /// warn the user about a likely pointer-lag cause.
255    ///
256    /// Matches on the owner executable name only; callers should combine it with
257    /// [`Self::gates_input`] so a competitor's *inactive* helper isn't flagged.
258    #[must_use]
259    pub fn known_input_conflict(&self) -> Option<&'static str> {
260        // (lower-cased executable-name substring, product display name). Brand
261        // names are not localised; only the surrounding warning copy is.
262        const KNOWN: &[(&str, &str)] = &[
263            ("logioptionsplus", "Logi Options+"),
264            ("logioptions", "Logitech Options"),
265            ("logimgr", "Logitech Options"),
266            ("lccdaemon", "Logitech Control Center"),
267            ("steermouse", "SteerMouse"),
268            ("bettermouse", "BetterMouse"),
269            ("usboverdrive", "USB Overdrive"),
270            ("mac mouse fix", "Mac Mouse Fix"),
271            ("linearmouse", "LinearMouse"),
272            ("smoothscroll", "SmoothScroll"),
273        ];
274        let name = self.owner_name.as_deref()?.to_ascii_lowercase();
275        KNOWN
276            .iter()
277            .find(|(needle, _)| name.contains(needle))
278            .map(|&(_, label)| label)
279    }
280}
281
282/// Errors that [`Hook::start`] and related functions can produce.
283///
284/// The same shape on every target: a platform-conditional enum would compile on
285/// the maintainer's macOS and break an exhaustive `match` on Linux, and one
286/// unreachable variant costs nothing.
287#[derive(Debug, thiserror::Error)]
288pub enum HookError {
289    /// This platform has no hook implementation (neither macOS, Linux, nor
290    /// Windows).
291    #[error("mouse event hook is not supported on this platform")]
292    Unsupported,
293    /// macOS Accessibility permission has not been granted to this process.
294    #[error(
295        "macOS Accessibility permission is required to capture mouse events; \
296         grant it in System Settings → Privacy & Security → Accessibility"
297    )]
298    AccessibilityDenied,
299    /// `CGEventTapCreate` returned null, or the run loop source could not be
300    /// created. The inner string carries the context.
301    #[error("CGEventTap setup failed: {0}")]
302    MacOsTap(String),
303    /// No mouse device was found under `/dev/input`. Either no pointing device
304    /// is connected, or the process lacks read permission on the device nodes
305    /// (add the user to the `input` group, or add a `udev` rule).
306    #[error(
307        "no mouse device found under /dev/input; \
308         ensure a pointing device is connected and the process has read permission \
309         (add user to the `input` group or add a udev rule)"
310    )]
311    NoDeviceFound,
312    /// A Linux-specific I/O error occurred while setting up or running the hook.
313    #[error("Linux input error: {0}")]
314    Linux(#[source] std::io::Error),
315    /// `SetWindowsHookExW` failed, or the hook thread could not be started.
316    #[error("Windows mouse hook setup failed: {0}")]
317    WindowsHook(String),
318}
319
320/// Everything one operating system has to provide for [`Hook`] to work.
321///
322/// Exactly one backend is compiled in — see the `Backend` alias below — so this
323/// is a compile-time contract, not runtime polymorphism. It earns its place by
324/// keeping the crate's per-OS `cfg` down to that single site, and by making the
325/// platform surface a list the compiler checks instead of a naming convention.
326/// Everything only some platforms can answer carries its do-nothing default
327/// here, so a backend implements exactly what it has.
328trait HookBackend {
329    /// Whatever the platform holds on to while the hook runs; handed back to
330    /// [`Self::stop`] to tear it down.
331    type Running;
332
333    /// Install the hook. [`Hook::start`] documents the contract owed to callers.
334    fn start(
335        cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
336    ) -> Result<Self::Running, HookError>;
337
338    /// Stop the hook and join its threads.
339    fn stop(running: Self::Running);
340
341    /// See [`Hook::has_accessibility`]. Platforms that gate the hook below the
342    /// privacy layer answer `true`.
343    fn has_accessibility() -> bool {
344        true
345    }
346
347    /// See [`Hook::prompt_accessibility`]. Nothing to prompt for by default.
348    fn prompt_accessibility() {}
349
350    /// See [`Hook::list_event_taps`]. Empty where the OS keeps no tap registry.
351    fn list_event_taps() -> Vec<EventTapInfo> {
352        Vec::new()
353    }
354
355    /// See [`crate::frontmost_bundle_id`].
356    fn frontmost_app() -> Option<String> {
357        None
358    }
359
360    /// See [`crate::cursor_position`].
361    fn cursor_position() -> Option<CursorPosition> {
362        None
363    }
364}
365
366/// The backend for a platform with no hook: every default, and a
367/// [`HookBackend::start`] that can only fail. Compiled only where it is the
368/// one selected below, so it never sits unused in a real build.
369#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
370struct Unsupported;
371
372#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
373impl HookBackend for Unsupported {
374    /// Uninhabited, so [`Hook`] can never hold a running hook here.
375    type Running = std::convert::Infallible;
376
377    fn start(
378        _cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
379    ) -> Result<Self::Running, HookError> {
380        Err(HookError::Unsupported)
381    }
382
383    fn stop(running: Self::Running) {
384        match running {}
385    }
386}
387
388// The backend this build talks to — the crate's one platform switch.
389cfg_select! {
390    target_os = "macos" => { type Backend = macos::Backend; }
391    target_os = "linux" => { type Backend = linux::Backend; }
392    target_os = "windows" => { type Backend = windows::Backend; }
393    _ => { type Backend = Unsupported; }
394}
395
396/// A running OS-level mouse hook. Call [`Hook::stop`] to tear down.
397///
398/// On macOS a dedicated thread runs a `CFRunLoop` draining a `CGEventTap`.
399/// On Linux one thread per physical mouse device reads `evdev` events and
400/// re-injects pass-through events via a `uinput` virtual device. On Windows a
401/// dedicated thread owns a `WH_MOUSE_LL` hook and pumps its message loop.
402/// Call `stop` (or let the value drop) to shut down all threads and release
403/// grabbed devices.
404pub struct Hook {
405    inner: Option<<Backend as HookBackend>::Running>,
406}
407
408impl Drop for Hook {
409    fn drop(&mut self) {
410        self.shutdown();
411    }
412}
413
414impl Hook {
415    /// Install the input hook and start delivering events to `cb`.
416    ///
417    /// The callback runs on a private background thread for every mouse
418    /// button, scroll, or (macOS / Windows) keyboard event. It must return
419    /// [`EventDisposition`] quickly — blocking it stalls input delivery
420    /// system-wide.
421    ///
422    /// On macOS, returns [`HookError::AccessibilityDenied`] when Accessibility
423    /// permission has not been granted. On Linux, returns
424    /// `HookError::NoDeviceFound` when no mouse device is accessible (key
425    /// events are not yet captured there). On Windows, installs `WH_MOUSE_LL`
426    /// and `WH_KEYBOARD_LL` low-level hooks.
427    pub fn start(
428        cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
429    ) -> Result<Self, HookError> {
430        Backend::start(cb).map(|inner| Self { inner: Some(inner) })
431    }
432
433    /// Stop the hook and release OS resources.
434    ///
435    /// Signals background threads to exit and blocks until they join. Calling
436    /// this explicitly is preferred over relying on `Drop` when errors in
437    /// cleanup should be visible. `Drop` calls this automatically.
438    pub fn stop(mut self) {
439        self.shutdown();
440    }
441
442    /// Tear down the platform hook if it is still running. Idempotent: the
443    /// first call takes `inner`, so the `Drop` after an explicit [`Self::stop`]
444    /// is a no-op.
445    fn shutdown(&mut self) {
446        if let Some(inner) = self.inner.take() {
447            Backend::stop(inner);
448        }
449    }
450
451    /// Returns `true` when the process has the permissions required to install
452    /// the hook.
453    ///
454    /// On macOS this is a live capability check, not just a read of the
455    /// Accessibility trust flag: that flag keeps reporting `true` after the
456    /// user deletes the app's row from System Settings, so it is paired with a
457    /// throwaway event tap that only succeeds while the grant really stands.
458    /// Poll it for as long as a hook is installed — an active tap that outlives
459    /// its permission wedges system input until reboot. On Linux and Windows
460    /// this always returns `true`; those platforms enforce permissions at a
461    /// lower layer (device-node ownership / group membership on Linux; the
462    /// Windows low-level hook needs no separate privacy grant).
463    #[must_use]
464    pub fn has_accessibility() -> bool {
465        Backend::has_accessibility()
466    }
467
468    /// Show the macOS Accessibility permission dialog and register this
469    /// process in System Settings → Privacy & Security → Accessibility.
470    ///
471    /// Unlike [`Self::has_accessibility`], this passes the
472    /// `kAXTrustedCheckOptionPrompt` option, so macOS surfaces the native
473    /// "open System Settings" dialog the first time and lists the app there
474    /// (otherwise the user would have to add the binary by hand). Called for
475    /// its side effect; the resulting trust state is observed separately via
476    /// [`Self::has_accessibility`]. No-op on non-macOS.
477    pub fn prompt_accessibility() {
478        Backend::prompt_accessibility();
479    }
480
481    /// Enumerate every event tap currently installed in this login session.
482    ///
483    /// A read-only diagnostic snapshot for spotting input contention — e.g. a
484    /// competing app holding an *active* [`TapLocation::Hid`] tap (the classic
485    /// "another driver is also intercepting the mouse" cause of pointer lag),
486    /// or OpenLogi's own tap being unexpectedly disabled. Needs no Accessibility
487    /// grant; the call sees every process's taps regardless of who asks.
488    ///
489    /// Returns an empty vector on non-macOS targets, which have no equivalent
490    /// global tap registry.
491    #[must_use]
492    pub fn list_event_taps() -> Vec<EventTapInfo> {
493        Backend::list_event_taps()
494    }
495}
496
497/// Return an opaque string identifying the currently frontmost application.
498///
499/// On macOS this is the bundle identifier, e.g. `"com.microsoft.VSCode"`.
500/// On Linux (X11 / XWayland) this is the `WM_CLASS` class component,
501/// e.g. `"Code"` or `"Firefox"`. Pure Wayland windows (not running under
502/// XWayland) are not visible through this path and return `None`. On Windows
503/// this is the lower-cased executable path of the foreground process.
504///
505/// `None` when no app is frontmost, when reading fails, or on unsupported
506/// platforms. Costs one X11 round-trip on Linux, four `objc_msgSend`s on
507/// macOS — well under a millisecond at the 1 Hz polling cadence in
508/// `openlogi-desktop::app_watcher`.
509#[must_use]
510pub fn frontmost_bundle_id() -> Option<String> {
511    Backend::frontmost_app()
512}
513
514/// Return the current global cursor position without installing an input hook.
515///
516/// Returns `None` on unsupported platforms and on native Wayland, where the
517/// compositor deliberately does not expose global pointer coordinates.
518#[must_use]
519pub fn cursor_position() -> Option<CursorPosition> {
520    Backend::cursor_position()
521}
522
523#[cfg(target_os = "macos")]
524mod macos;
525
526#[cfg(target_os = "linux")]
527mod linux;
528
529#[cfg(target_os = "windows")]
530mod windows;
531
532#[cfg(test)]
533mod tests;