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 |
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
28pub use openlogi_core::binding::ButtonId;
29
30/// Best-effort identity for the physical device that produced an OS event.
31///
32/// Platform hooks fill the stable fields they can read cheaply from the native
33/// event. Consumers use this to apply host-side settings per device rather than
34/// through the currently selected UI device.
35#[derive(Clone, Debug, Default, PartialEq, Eq)]
36pub struct EventDevice {
37    /// USB/Bluetooth vendor id when the platform exposes it.
38    pub vendor_id: Option<u32>,
39    /// USB/Bluetooth/HID product id when the platform exposes it.
40    pub product_id: Option<u32>,
41    /// Human-readable product name, normalized by consumers before matching.
42    pub product_name: Option<String>,
43}
44
45/// An event captured at the OS layer.
46#[derive(Clone, Debug)]
47pub enum MouseEvent {
48    /// A mouse button was pressed or released.
49    Button {
50        /// Which button.
51        id: ButtonId,
52        /// `true` = button down; `false` = button up.
53        pressed: bool,
54    },
55    /// A scroll-wheel tick (or continuous momentum scroll).
56    Scroll {
57        /// Positive = right, negative = left.
58        delta_x: f32,
59        /// Positive = down, negative = up.
60        delta_y: f32,
61        /// `true` when the OS attributes this scroll to a trackpad / Magic Mouse
62        /// gesture rather than a mouse wheel, so a consumer can transform the
63        /// wheel while leaving native trackpad scrolling alone (issue #126).
64        ///
65        /// On macOS this is resolved from the `IOHIDEvent` sender's IOKit device
66        /// identity, because Logitech free-spin wheels can carry the same phase
67        /// flags as a trackpad. Sender-less events fall back to the phase fields.
68        /// Always `false` on Linux/Windows, where the wheel and trackpad arrive
69        /// as distinct event types rather than one flagged stream.
70        from_trackpad: bool,
71        /// Best-effort physical source of the scroll event. `None` means the
72        /// platform could not attribute the event to a device, or the event was
73        /// synthetic.
74        device: Option<EventDevice>,
75    },
76    /// Pointer movement, in device units. Emitted so a held gesture button can
77    /// accumulate a swipe; the callback passes these through (the cursor keeps
78    /// moving) and only reads them while a gesture button is down.
79    Moved {
80        /// Positive = right, negative = left.
81        delta_x: i32,
82        /// Positive = down, negative = up.
83        delta_y: i32,
84    },
85    /// The OS interrupted event capture (on macOS, the tap was disabled by a
86    /// timeout or by competing user input). Any in-progress gesture hold must be
87    /// cancelled: a button-up dropped during the gap would otherwise leave a
88    /// stale hold that the next stray pointer move turns into a phantom swipe.
89    /// Carries no data and is always passed through.
90    CaptureInterrupted,
91}
92
93/// What the hook callback wants the OS to do with the captured event.
94#[derive(Clone, Copy, Debug, PartialEq, Eq)]
95pub enum EventDisposition {
96    /// Let the event reach its original target unchanged.
97    PassThrough,
98    /// Drop the event; the target application never sees it.
99    Suppress,
100}
101
102/// Errors that [`Hook::start`] and related functions can produce.
103#[derive(Debug, thiserror::Error)]
104pub enum HookError {
105    /// This platform has no hook implementation (neither macOS, Linux, nor
106    /// Windows).
107    #[error("mouse event hook is not supported on this platform")]
108    Unsupported,
109    /// macOS Accessibility permission has not been granted to this process.
110    #[error(
111        "macOS Accessibility permission is required to capture mouse events; \
112         grant it in System Settings → Privacy & Security → Accessibility"
113    )]
114    AccessibilityDenied,
115    /// `CGEventTapCreate` returned null, or the run loop source could not be
116    /// created. The inner string carries the context.
117    #[error("CGEventTap setup failed: {0}")]
118    MacOsTap(String),
119    /// No mouse device was found under `/dev/input`. Either no pointing device
120    /// is connected, or the process lacks read permission on the device nodes
121    /// (add the user to the `input` group, or add a `udev` rule).
122    #[cfg(target_os = "linux")]
123    #[error(
124        "no mouse device found under /dev/input; \
125         ensure a pointing device is connected and the process has read permission \
126         (add user to the `input` group or add a udev rule)"
127    )]
128    NoDeviceFound,
129    /// A Linux-specific I/O error occurred while setting up or running the hook.
130    #[cfg(target_os = "linux")]
131    #[error("Linux input error: {0}")]
132    Linux(#[source] std::io::Error),
133    /// `SetWindowsHookExW` failed, or the hook thread could not be started.
134    #[error("Windows mouse hook setup failed: {0}")]
135    WindowsHook(String),
136}
137
138/// A running OS-level mouse hook. Call [`Hook::stop`] to tear down.
139///
140/// On macOS a dedicated thread runs a `CFRunLoop` draining a `CGEventTap`.
141/// On Linux one thread per physical mouse device reads `evdev` events and
142/// re-injects pass-through events via a `uinput` virtual device. On Windows a
143/// dedicated thread owns a `WH_MOUSE_LL` hook and pumps its message loop.
144/// Call `stop` (or let the value drop) to shut down all threads and release
145/// grabbed devices.
146pub struct Hook {
147    #[cfg(target_os = "macos")]
148    inner: Option<macos::HookInner>,
149    #[cfg(target_os = "linux")]
150    inner: Option<linux::HookInner>,
151    #[cfg(target_os = "windows")]
152    inner: Option<windows::HookInner>,
153    /// Makes `Hook` uninhabited on unsupported targets so [`Hook::start`] can
154    /// only ever return `Err` there and the type can never be constructed.
155    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
156    never: std::convert::Infallible,
157}
158
159impl Drop for Hook {
160    fn drop(&mut self) {
161        #[cfg(target_os = "macos")]
162        if let Some(inner) = self.inner.take() {
163            macos::stop(inner);
164        }
165        #[cfg(target_os = "linux")]
166        if let Some(inner) = self.inner.take() {
167            linux::stop(inner);
168        }
169        #[cfg(target_os = "windows")]
170        if let Some(inner) = self.inner.take() {
171            windows::stop(inner);
172        }
173        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
174        // Unreachable: `never: Infallible` makes `Hook` uninhabited here.
175        {}
176    }
177}
178
179impl Hook {
180    /// Install the mouse hook and start delivering events to `cb`.
181    ///
182    /// The callback runs on a private background thread for every mouse button
183    /// or scroll event. It must return [`EventDisposition`] quickly — blocking
184    /// it stalls input delivery system-wide.
185    ///
186    /// On macOS, returns [`HookError::AccessibilityDenied`] when Accessibility
187    /// permission has not been granted. On Linux, returns
188    /// [`HookError::NoDeviceFound`] when no mouse device is accessible. On
189    /// Windows, installs a `WH_MOUSE_LL` low-level mouse hook.
190    pub fn start(
191        cb: impl Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static,
192    ) -> Result<Self, HookError> {
193        #[cfg(target_os = "macos")]
194        {
195            macos::start(cb).map(|inner| Self { inner: Some(inner) })
196        }
197        #[cfg(target_os = "linux")]
198        {
199            linux::start(cb).map(|inner| Self { inner: Some(inner) })
200        }
201        #[cfg(target_os = "windows")]
202        {
203            windows::start(cb).map(|inner| Self { inner: Some(inner) })
204        }
205        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
206        {
207            let _ = cb;
208            Err(HookError::Unsupported)
209        }
210    }
211
212    /// Stop the hook and release OS resources.
213    ///
214    /// Signals background threads to exit and blocks until they join. Calling
215    /// this explicitly is preferred over relying on `Drop` when errors in
216    /// cleanup should be visible. `Drop` calls this automatically.
217    #[cfg_attr(
218        not(any(target_os = "macos", target_os = "linux", target_os = "windows")),
219        allow(
220            unused_mut,
221            reason = "`mut self` is only consumed by platform teardown paths"
222        )
223    )]
224    pub fn stop(mut self) {
225        #[cfg(target_os = "macos")]
226        if let Some(inner) = self.inner.take() {
227            macos::stop(inner);
228        }
229        #[cfg(target_os = "linux")]
230        if let Some(inner) = self.inner.take() {
231            linux::stop(inner);
232        }
233        #[cfg(target_os = "windows")]
234        if let Some(inner) = self.inner.take() {
235            windows::stop(inner);
236        }
237        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
238        match self.never {}
239    }
240
241    /// Returns `true` when the process has the permissions required to install
242    /// the hook.
243    ///
244    /// On macOS, checks the Accessibility entitlement. On Linux and Windows
245    /// this always returns `true`; those platforms enforce permissions at a
246    /// lower layer (device-node ownership / group membership on Linux; the
247    /// Windows low-level hook needs no separate privacy grant).
248    #[must_use]
249    pub fn has_accessibility() -> bool {
250        #[cfg(target_os = "macos")]
251        {
252            macos::has_accessibility()
253        }
254        #[cfg(not(target_os = "macos"))]
255        {
256            true
257        }
258    }
259
260    /// Show the macOS Accessibility permission dialog and register this
261    /// process in System Settings → Privacy & Security → Accessibility.
262    ///
263    /// Unlike [`Self::has_accessibility`], this passes the
264    /// `kAXTrustedCheckOptionPrompt` option, so macOS surfaces the native
265    /// "open System Settings" dialog the first time and lists the app there
266    /// (otherwise the user would have to add the binary by hand). Called for
267    /// its side effect; the resulting trust state is observed separately via
268    /// [`Self::has_accessibility`]. No-op on non-macOS.
269    pub fn prompt_accessibility() {
270        #[cfg(target_os = "macos")]
271        {
272            macos::prompt_accessibility();
273        }
274    }
275}
276
277/// Return an opaque string identifying the currently frontmost application.
278///
279/// On macOS this is the bundle identifier, e.g. `"com.microsoft.VSCode"`.
280/// On Linux (X11 / XWayland) this is the `WM_CLASS` class component,
281/// e.g. `"Code"` or `"Firefox"`. Pure Wayland windows (not running under
282/// XWayland) are not visible through this path and return `None`. On Windows
283/// this is the lower-cased executable path of the foreground process.
284///
285/// `None` when no app is frontmost, when reading fails, or on unsupported
286/// platforms. Costs one X11 round-trip on Linux, four `objc_msgSend`s on
287/// macOS — well under a millisecond at the 1 Hz polling cadence in
288/// `openlogi-gui::app_watcher`.
289#[must_use]
290pub fn frontmost_bundle_id() -> Option<String> {
291    #[cfg(target_os = "macos")]
292    {
293        macos::frontmost_bundle_id()
294    }
295    #[cfg(target_os = "linux")]
296    {
297        linux::frontmost_bundle_id()
298    }
299    #[cfg(target_os = "windows")]
300    {
301        windows::frontmost_process_path()
302    }
303    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
304    {
305        None
306    }
307}
308
309#[cfg(target_os = "macos")]
310mod macos;
311
312#[cfg(target_os = "linux")]
313mod linux;
314
315#[cfg(target_os = "windows")]
316mod windows;
317
318#[cfg(test)]
319mod tests;