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