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