Skip to main content

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;