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#[derive(Debug, thiserror::Error)]
284pub enum HookError {
285    /// This platform has no hook implementation (neither macOS, Linux, nor
286    /// Windows).
287    #[error("mouse event hook is not supported on this platform")]
288    Unsupported,
289    /// macOS Accessibility permission has not been granted to this process.
290    #[error(
291        "macOS Accessibility permission is required to capture mouse events; \
292         grant it in System Settings → Privacy & Security → Accessibility"
293    )]
294    AccessibilityDenied,
295    /// `CGEventTapCreate` returned null, or the run loop source could not be
296    /// created. The inner string carries the context.
297    #[error("CGEventTap setup failed: {0}")]
298    MacOsTap(String),
299    /// No mouse device was found under `/dev/input`. Either no pointing device
300    /// is connected, or the process lacks read permission on the device nodes
301    /// (add the user to the `input` group, or add a `udev` rule).
302    #[cfg(target_os = "linux")]
303    #[error(
304        "no mouse device found under /dev/input; \
305         ensure a pointing device is connected and the process has read permission \
306         (add user to the `input` group or add a udev rule)"
307    )]
308    NoDeviceFound,
309    /// A Linux-specific I/O error occurred while setting up or running the hook.
310    #[cfg(target_os = "linux")]
311    #[error("Linux input error: {0}")]
312    Linux(#[source] std::io::Error),
313    /// `SetWindowsHookExW` failed, or the hook thread could not be started.
314    #[error("Windows mouse hook setup failed: {0}")]
315    WindowsHook(String),
316}
317
318/// A running OS-level mouse hook. Call [`Hook::stop`] to tear down.
319///
320/// On macOS a dedicated thread runs a `CFRunLoop` draining a `CGEventTap`.
321/// On Linux one thread per physical mouse device reads `evdev` events and
322/// re-injects pass-through events via a `uinput` virtual device. On Windows a
323/// dedicated thread owns a `WH_MOUSE_LL` hook and pumps its message loop.
324/// Call `stop` (or let the value drop) to shut down all threads and release
325/// grabbed devices.
326pub struct Hook {
327    #[cfg(target_os = "macos")]
328    inner: Option<macos::HookInner>,
329    #[cfg(target_os = "linux")]
330    inner: Option<linux::HookInner>,
331    #[cfg(target_os = "windows")]
332    inner: Option<windows::HookInner>,
333    /// Makes `Hook` uninhabited on unsupported targets so [`Hook::start`] can
334    /// only ever return `Err` there and the type can never be constructed.
335    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
336    never: std::convert::Infallible,
337}
338
339impl Drop for Hook {
340    fn drop(&mut self) {
341        self.shutdown();
342    }
343}
344
345impl Hook {
346    /// Install the input hook and start delivering events to `cb`.
347    ///
348    /// The callback runs on a private background thread for every mouse
349    /// button, scroll, or (macOS / Windows) keyboard event. It must return
350    /// [`EventDisposition`] quickly — blocking it stalls input delivery
351    /// system-wide.
352    ///
353    /// On macOS, returns [`HookError::AccessibilityDenied`] when Accessibility
354    /// permission has not been granted. On Linux, returns
355    /// `HookError::NoDeviceFound` when no mouse device is accessible (key
356    /// events are not yet captured there). On Windows, installs `WH_MOUSE_LL`
357    /// and `WH_KEYBOARD_LL` low-level hooks.
358    pub fn start(
359        cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
360    ) -> Result<Self, HookError> {
361        cfg_select! {
362            target_os = "macos" => {
363                macos::start(cb).map(|inner| Self { inner: Some(inner) })
364            }
365            target_os = "linux" => {
366                linux::start(cb).map(|inner| Self { inner: Some(inner) })
367            }
368            target_os = "windows" => {
369                windows::start(cb).map(|inner| Self { inner: Some(inner) })
370            }
371            _ => {
372                let _ = cb;
373                Err(HookError::Unsupported)
374            }
375        }
376    }
377
378    /// Stop the hook and release OS resources.
379    ///
380    /// Signals background threads to exit and blocks until they join. Calling
381    /// this explicitly is preferred over relying on `Drop` when errors in
382    /// cleanup should be visible. `Drop` calls this automatically.
383    pub fn stop(mut self) {
384        self.shutdown();
385    }
386
387    /// Tear down the platform hook if it is still running. Idempotent: the
388    /// first call takes `inner`, so the `Drop` after an explicit [`Self::stop`]
389    /// is a no-op.
390    fn shutdown(&mut self) {
391        cfg_select! {
392            target_os = "macos" => {
393                if let Some(inner) = self.inner.take() {
394                    macos::stop(inner);
395                }
396            }
397            target_os = "linux" => {
398                if let Some(inner) = self.inner.take() {
399                    linux::stop(inner);
400                }
401            }
402            target_os = "windows" => {
403                if let Some(inner) = self.inner.take() {
404                    windows::stop(inner);
405                }
406            }
407            _ => {
408                // Unreachable: `never: Infallible` makes `Hook` uninhabited here.
409            }
410        }
411    }
412
413    /// Returns `true` when the process has the permissions required to install
414    /// the hook.
415    ///
416    /// On macOS, checks the Accessibility entitlement. On Linux and Windows
417    /// this always returns `true`; those platforms enforce permissions at a
418    /// lower layer (device-node ownership / group membership on Linux; the
419    /// Windows low-level hook needs no separate privacy grant).
420    #[must_use]
421    pub fn has_accessibility() -> bool {
422        cfg_select! {
423            target_os = "macos" => { macos::has_accessibility() }
424            _ => { true }
425        }
426    }
427
428    /// Show the macOS Accessibility permission dialog and register this
429    /// process in System Settings → Privacy & Security → Accessibility.
430    ///
431    /// Unlike [`Self::has_accessibility`], this passes the
432    /// `kAXTrustedCheckOptionPrompt` option, so macOS surfaces the native
433    /// "open System Settings" dialog the first time and lists the app there
434    /// (otherwise the user would have to add the binary by hand). Called for
435    /// its side effect; the resulting trust state is observed separately via
436    /// [`Self::has_accessibility`]. No-op on non-macOS.
437    pub fn prompt_accessibility() {
438        cfg_select! {
439            target_os = "macos" => { macos::prompt_accessibility(); }
440            _ => {}
441        }
442    }
443
444    /// Enumerate every event tap currently installed in this login session.
445    ///
446    /// A read-only diagnostic snapshot for spotting input contention — e.g. a
447    /// competing app holding an *active* [`TapLocation::Hid`] tap (the classic
448    /// "another driver is also intercepting the mouse" cause of pointer lag),
449    /// or OpenLogi's own tap being unexpectedly disabled. Needs no Accessibility
450    /// grant; the call sees every process's taps regardless of who asks.
451    ///
452    /// Returns an empty vector on non-macOS targets, which have no equivalent
453    /// global tap registry.
454    #[must_use]
455    pub fn list_event_taps() -> Vec<EventTapInfo> {
456        cfg_select! {
457            target_os = "macos" => { macos::list_event_taps() }
458            _ => { Vec::new() }
459        }
460    }
461}
462
463/// Return an opaque string identifying the currently frontmost application.
464///
465/// On macOS this is the bundle identifier, e.g. `"com.microsoft.VSCode"`.
466/// On Linux (X11 / XWayland) this is the `WM_CLASS` class component,
467/// e.g. `"Code"` or `"Firefox"`. Pure Wayland windows (not running under
468/// XWayland) are not visible through this path and return `None`. On Windows
469/// this is the lower-cased executable path of the foreground process.
470///
471/// `None` when no app is frontmost, when reading fails, or on unsupported
472/// platforms. Costs one X11 round-trip on Linux, four `objc_msgSend`s on
473/// macOS — well under a millisecond at the 1 Hz polling cadence in
474/// `openlogi-desktop::app_watcher`.
475#[must_use]
476pub fn frontmost_bundle_id() -> Option<String> {
477    cfg_select! {
478        target_os = "macos" => { macos::frontmost_bundle_id() }
479        target_os = "linux" => { linux::frontmost_bundle_id() }
480        target_os = "windows" => { windows::frontmost_process_path() }
481        _ => { None }
482    }
483}
484
485/// Return the current global cursor position without installing an input hook.
486///
487/// Returns `None` on unsupported platforms and on native Wayland, where the
488/// compositor deliberately does not expose global pointer coordinates.
489#[must_use]
490pub fn cursor_position() -> Option<CursorPosition> {
491    cfg_select! {
492        target_os = "macos" => { macos::cursor_position() }
493        target_os = "linux" => { linux::cursor_position() }
494        target_os = "windows" => { windows::cursor_position() }
495        _ => { None }
496    }
497}
498
499#[cfg(target_os = "macos")]
500mod macos;
501
502#[cfg(target_os = "linux")]
503mod linux;
504
505#[cfg(target_os = "windows")]
506mod windows;
507
508#[cfg(test)]
509mod tests;