openlogi_hook/lib.rs
1//! OS-level mouse-event hook for OpenLogi.
2//!
3//! On macOS the hook is implemented with `CGEventTap` (the same primitive used
4//! by Logi Options+ and external-reference). Linux and Windows return
5//! [`HookError::Unsupported`] from [`Hook::start`] — stubs that let the
6//! workspace compile on all platforms without feature-gating callers.
7//!
8//! # Usage
9//!
10//! ```no_run
11//! use openlogi_hook::{Hook, MouseEvent, EventDisposition};
12//!
13//! if !Hook::has_accessibility() {
14//! eprintln!("grant Accessibility access first");
15//! return;
16//! }
17//!
18//! let hook = Hook::start(|event| {
19//! println!("{event:?}");
20//! EventDisposition::PassThrough
21//! }).unwrap();
22//!
23//! // … later, on shutdown:
24//! hook.stop();
25//! ```
26
27pub use openlogi_core::binding::ButtonId;
28
29/// An event captured at the OS layer.
30#[derive(Clone, Debug)]
31pub enum MouseEvent {
32 /// A mouse button was pressed or released.
33 Button {
34 /// Which button.
35 id: ButtonId,
36 /// `true` = button down; `false` = button up.
37 pressed: bool,
38 },
39 /// A scroll-wheel tick (or continuous momentum scroll).
40 Scroll {
41 /// Positive = right, negative = left.
42 delta_x: f32,
43 /// Positive = down, negative = up.
44 delta_y: f32,
45 },
46}
47
48/// What the hook callback wants the OS to do with the captured event.
49#[derive(Clone, Copy, Debug, PartialEq, Eq)]
50pub enum EventDisposition {
51 /// Let the event reach its original target unchanged.
52 PassThrough,
53 /// Drop the event; the target application never sees it.
54 Suppress,
55}
56
57/// Errors that [`Hook::start`] and related functions can produce.
58#[derive(Debug, thiserror::Error)]
59pub enum HookError {
60 /// This platform has no hook implementation yet (Linux, Windows).
61 #[error("mouse event hook is not supported on this platform")]
62 Unsupported,
63 /// macOS Accessibility permission has not been granted to this process.
64 #[error(
65 "macOS Accessibility permission is required to capture mouse events; \
66 grant it in System Settings → Privacy & Security → Accessibility"
67 )]
68 AccessibilityDenied,
69 /// `CGEventTapCreate` returned null, or the run loop source could not be
70 /// created. The inner string carries the context.
71 #[error("CGEventTap setup failed: {0}")]
72 MacOsTap(String),
73}
74
75/// A running OS-level mouse hook. Call [`Hook::stop`] to tear down.
76///
77/// Internally on macOS, a dedicated `std::thread` runs a `CFRunLoop` that
78/// drains the `CGEventTap` queue. `stop` signals that run loop and joins the
79/// thread so the process exits cleanly. Dropping a `Hook` without calling
80/// `stop` has the same effect via `Drop`.
81pub struct Hook {
82 #[cfg(target_os = "macos")]
83 inner: Option<macos::HookInner>,
84 /// Makes `Hook` uninhabited on non-macOS targets, so [`Hook::start`] can
85 /// only ever return `Err` there and the type can never be constructed.
86 #[cfg(not(target_os = "macos"))]
87 never: std::convert::Infallible,
88}
89
90impl Drop for Hook {
91 fn drop(&mut self) {
92 #[cfg(target_os = "macos")]
93 if let Some(inner) = self.inner.take() {
94 macos::stop(inner);
95 }
96 #[cfg(not(target_os = "macos"))]
97 // Unreachable: `never: Infallible` makes `Hook` uninhabited here.
98 {}
99 }
100}
101
102impl Hook {
103 /// Install the mouse hook and start delivering events to `cb`.
104 ///
105 /// The callback runs on a private background thread (not the GPUI thread)
106 /// for every mouse button or scroll event at the OS HID tap. It must
107 /// return [`EventDisposition`] quickly — blocking it stalls input delivery
108 /// system-wide.
109 ///
110 /// On macOS, returns [`HookError::AccessibilityDenied`] when the process
111 /// has not been granted Accessibility permission. On Linux and Windows,
112 /// returns [`HookError::Unsupported`].
113 pub fn start(
114 cb: impl Fn(MouseEvent) -> EventDisposition + Send + Sync + 'static,
115 ) -> Result<Self, HookError> {
116 #[cfg(target_os = "macos")]
117 {
118 macos::start(cb).map(|inner| Self { inner: Some(inner) })
119 }
120 #[cfg(not(target_os = "macos"))]
121 {
122 let _ = cb;
123 Err(HookError::Unsupported)
124 }
125 }
126
127 /// Stop the hook and release OS resources.
128 ///
129 /// Signals the background run loop to exit and blocks until the thread
130 /// joins. Calling this explicitly is preferred over relying on `Drop` when
131 /// errors in cleanup should be visible. `Drop` calls this automatically.
132 #[cfg_attr(
133 not(target_os = "macos"),
134 allow(
135 unused_mut,
136 reason = "`mut self` is only consumed by the macOS teardown path"
137 )
138 )]
139 pub fn stop(mut self) {
140 #[cfg(target_os = "macos")]
141 if let Some(inner) = self.inner.take() {
142 macos::stop(inner);
143 }
144 #[cfg(not(target_os = "macos"))]
145 match self.never {}
146 }
147
148 /// Returns `true` when the process has the macOS Accessibility entitlement
149 /// required to install an active `CGEventTap`.
150 ///
151 /// On Linux and Windows this always returns `true`; those platforms handle
152 /// permissions at a higher layer.
153 #[must_use]
154 pub fn has_accessibility() -> bool {
155 #[cfg(target_os = "macos")]
156 {
157 macos::has_accessibility()
158 }
159 #[cfg(not(target_os = "macos"))]
160 {
161 true
162 }
163 }
164
165 /// Show the macOS Accessibility permission dialog and register this
166 /// process in System Settings → Privacy & Security → Accessibility.
167 ///
168 /// Unlike [`Self::has_accessibility`], this passes the
169 /// `kAXTrustedCheckOptionPrompt` option, so macOS surfaces the native
170 /// "open System Settings" dialog the first time and lists the app there
171 /// (otherwise the user would have to add the binary by hand). Called for
172 /// its side effect; the resulting trust state is observed separately via
173 /// [`Self::has_accessibility`]. No-op on non-macOS.
174 pub fn prompt_accessibility() {
175 #[cfg(target_os = "macos")]
176 {
177 macos::prompt_accessibility();
178 }
179 }
180}
181
182/// Return the macOS bundle identifier of the currently frontmost application,
183/// e.g. `"com.microsoft.VSCode"`. `None` when no app is frontmost, when
184/// reading the value fails, or on any non-macOS platform (P1.4).
185///
186/// Costs four `objc_msgSend`s plus a UTF-8 copy — well under a millisecond
187/// at the 1 Hz polling cadence in `openlogi-gui::app_watcher`.
188#[must_use]
189pub fn frontmost_bundle_id() -> Option<String> {
190 #[cfg(target_os = "macos")]
191 {
192 macos::frontmost_bundle_id()
193 }
194 #[cfg(not(target_os = "macos"))]
195 {
196 None
197 }
198}
199
200#[cfg(target_os = "macos")]
201mod macos;
202
203#[cfg(test)]
204mod tests;