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