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