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;