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