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