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;