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 (motion is edge-clamped) |
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
28use std::cfg_select;
29use std::sync::Arc;
30use std::sync::atomic::{AtomicBool, Ordering};
31
32use thiserror::Error;
33
34pub use openlogi_core::app::ForegroundApp;
35pub use openlogi_core::binding::ButtonId;
36/// Which modifier keys were held when a key event fired — the same type a
37/// [`KeyTrigger`](openlogi_core::config::KeyTrigger) is written with, so an
38/// event's modifiers compare against a binding's without a conversion.
39pub use openlogi_core::config::KeyModifiers;
40pub use openlogi_core::scroll::ScrollDelta;
41
42mod pointer;
43pub use pointer::{
44    PointerContext, PointerTarget, pointer_context, pointer_context_supported,
45    pointer_target_is_focused,
46};
47
48/// Logitech's USB/Bluetooth vendor id (`0x046D`), widened from
49/// [`openlogi_core::hid::LOGITECH_VENDOR_ID`] because the hook's identity
50/// sources (IOKit, evdev) hand it back as a `u32`.
51pub const LOGITECH_VENDOR_ID: u32 = openlogi_core::hid::LOGITECH_VENDOR_ID as u32;
52
53/// Cursor position in the operating system's global screen coordinate space,
54/// scaled so it lines up with GPUI's own logical (DIP) display bounds — the
55/// Windows backend divides physical `GetCursorPos` pixels by the cursor's
56/// monitor DPI scale to match; macOS `CGEvent` points and Linux root-window
57/// coordinates are already resolution-independent.
58#[derive(Clone, Copy, Debug, PartialEq)]
59pub struct CursorPosition {
60    /// Horizontal screen coordinate.
61    pub x: f64,
62    /// Vertical screen coordinate.
63    pub y: f64,
64}
65
66/// Best-effort identity for the physical device that produced an OS event.
67///
68/// Platform hooks fill the stable fields they can read cheaply from the native
69/// event. Consumers use this to apply host-side settings per device rather than
70/// through the currently selected UI device.
71#[derive(Clone, Debug, Default, PartialEq, Eq)]
72pub struct EventDevice {
73    /// USB/Bluetooth vendor id when the platform exposes it.
74    pub vendor_id: Option<u32>,
75    /// USB/Bluetooth/HID product id when the platform exposes it.
76    pub product_id: Option<u32>,
77    /// Human-readable product name, normalized by consumers before matching.
78    pub product_name: Option<String>,
79}
80
81impl EventDevice {
82    /// Whether this looks like a trackpad/touchpad (must never be remapped).
83    #[must_use]
84    pub fn is_trackpad_like(&self) -> bool {
85        self.product_name.as_deref().is_some_and(|n| {
86            let n = n.to_ascii_lowercase();
87            n.contains("trackpad") || n.contains("touchpad") || n.contains("touch pad")
88        })
89    }
90
91    /// Whether this is a Logitech product OpenLogi may remap buttons for.
92    #[must_use]
93    pub fn is_logitech(&self) -> bool {
94        if self.vendor_id == Some(LOGITECH_VENDOR_ID) {
95            return true;
96        }
97        self.product_name.as_deref().is_some_and(|n| {
98            let n = n.to_ascii_lowercase();
99            n.contains("logitech") || n.starts_with("logi ")
100        })
101    }
102}
103
104/// Whether the capture layer is currently inverting the vertical wheel.
105///
106/// Software scroll inversion for devices whose firmware has no native
107/// `0x2121` invert bit (#694, #776). The flag is process-global and applies to
108/// every pointer this hook captures — the hook attributes events to a device,
109/// but nothing maps an OS event source back to the config key the setting is
110/// stored under, so a second Logitech mouse would follow the first. Documented
111/// rather than silently per-device.
112static INVERT_SCROLL: AtomicBool = AtomicBool::new(false);
113
114/// Whether this platform can invert the wheel in software.
115///
116/// Linux and macOS transform the event in place, inside the capture layer,
117/// where it still carries its native units: the fields the reader used are the
118/// fields the writer negates, so nothing is converted or rounded. Re-injecting
119/// from the normalised `delta_y` would have to pick a unit, and a macOS hi-res
120/// wheel reports pixels in one field while a detented one reports lines in
121/// another.
122///
123/// Windows is `false`, and not because `WH_MOUSE_LL` hands the callback a
124/// read-only event — that alone could be answered by suppressing and
125/// re-injecting, which is exact there. The blocker is attribution: the Windows
126/// hook cannot say which device a wheel event came from ([`MouseEvent::Scroll`]
127/// always carries `device: None`), so the inversion could not be confined to
128/// the configured mouse. Applying it to every wheel event would reverse a
129/// precision touchpad too, which is the one thing this setting must not do.
130pub const SOFTWARE_SCROLL_INVERSION: bool = !cfg!(target_os = "windows");
131
132/// Turn software scroll inversion on or off for the captured pointers.
133///
134/// Takes effect on the next scroll event; no restart of the hook is needed.
135pub fn set_scroll_inversion(inverted: bool) {
136    INVERT_SCROLL.store(inverted, Ordering::Relaxed);
137}
138
139/// Whether [`set_scroll_inversion`] is currently on.
140#[must_use]
141pub fn scroll_inversion() -> bool {
142    INVERT_SCROLL.load(Ordering::Relaxed)
143}
144
145/// Flip the vertical delta of a scroll [`MouseEvent`], leaving every other
146/// variant untouched.
147///
148/// What [`Delivery::run`] hands the callback when software scroll inversion
149/// applies: the callback must see the same inverted delta that a pass-through
150/// event gets written back with, so a suppressed impulse (smoothing, non-1x
151/// sensitivity) is reversed too instead of only a forwarded one (#1737).
152#[cfg(any(target_os = "macos", target_os = "linux"))]
153pub(crate) fn negate_scroll_y(event: MouseEvent) -> MouseEvent {
154    match event {
155        MouseEvent::Scroll {
156            delta,
157            from_trackpad,
158            device,
159        } => MouseEvent::Scroll {
160            delta: delta.negate_y(),
161            from_trackpad,
162            device,
163        },
164        other => other,
165    }
166}
167
168/// The callback's verdict on one captured event, plus what that verdict means
169/// for software scroll inversion.
170///
171/// Shared by every backend that inverts in software so the rule lives in one
172/// place: the callback always sees the reversed delta, and the native event is
173/// reversed only on its way through.
174#[cfg(any(target_os = "macos", target_os = "linux"))]
175#[derive(Clone, Copy, Debug, PartialEq, Eq)]
176pub(crate) struct Delivery {
177    /// What the backend does with the native event.
178    pub(crate) disposition: EventDisposition,
179    /// Whether the backend reverses the native event's vertical scroll before
180    /// forwarding it.
181    ///
182    /// Set only for a pass-through, so the desktop sees the wheel reversed
183    /// exactly once: a suppressed impulse is replayed by the agent from the
184    /// delta the callback received, which is already reversed.
185    pub(crate) invert_native: bool,
186}
187
188#[cfg(any(target_os = "macos", target_os = "linux"))]
189impl Delivery {
190    /// Hand `event` to `cb`, applying software scroll inversion when `invert`
191    /// is set.
192    ///
193    /// The callback receives the vertical delta already reversed, so an
194    /// impulse the agent intercepts and replays (smoothing, non-1x
195    /// sensitivity) comes out reversed as well, not only a forwarded one
196    /// (#1737).
197    pub(crate) fn run(
198        event: HookEvent,
199        invert: bool,
200        cb: impl FnOnce(HookEvent) -> EventDisposition,
201    ) -> Self {
202        let event = match event {
203            HookEvent::Mouse(mouse) if invert => HookEvent::Mouse(negate_scroll_y(mouse)),
204            other => other,
205        };
206        let disposition = cb(event);
207        Self {
208            disposition,
209            invert_native: invert && disposition == EventDisposition::PassThrough,
210        }
211    }
212
213    /// An event the backend forwards without consulting the callback, because
214    /// it does not translate into a [`MouseEvent`]. Its native form is still
215    /// reversed when `invert` is set, so it agrees with the rest of its report.
216    #[cfg(target_os = "linux")]
217    pub(crate) const fn forwarded(invert: bool) -> Self {
218        Self {
219            disposition: EventDisposition::PassThrough,
220            invert_native: invert,
221        }
222    }
223}
224
225/// Whether the OS hook may suppress/remap a button event from this source.
226///
227/// Fail-closed on macOS-style attribution: only a known Logitech non-trackpad
228/// source is remappable. Unknown / non-Logitech / trackpad sources always pass
229/// through so a wedged remap policy can never brick the system pointer.
230#[must_use]
231pub fn source_is_remappable(device: Option<&EventDevice>) -> bool {
232    match device {
233        Some(d) if d.is_trackpad_like() => false,
234        Some(d) => d.is_logitech(),
235        None => false,
236    }
237}
238
239/// A keyboard event observed by the hook.
240#[derive(Clone, Copy, Debug, PartialEq, Eq)]
241pub struct KeyEvent {
242    /// Platform virtual keycode (macOS: `kVK_*`, e.g. 122 = F1, 53 = Escape).
243    pub keycode: u16,
244    /// `true` = key down; `false` = key up.
245    pub pressed: bool,
246    /// Which modifiers were held.
247    pub modifiers: KeyModifiers,
248}
249
250/// Anything the OS hook can observe. `Mouse` preserves the existing callback
251/// payload; `Key` is the keyboard path added by the function-key remapper.
252/// Wrapping both in a union means `Hook::start`'s callback widens once and
253/// stays stable as further event classes arrive.
254#[derive(Clone, Debug)]
255pub enum HookEvent {
256    /// Mouse button / scroll / move event.
257    Mouse(MouseEvent),
258    /// Keyboard event (function-key remapper path).
259    Key(KeyEvent),
260}
261
262/// An event captured at the OS layer.
263#[derive(Clone, Debug, PartialEq)]
264pub enum MouseEvent {
265    /// A mouse button was pressed or released.
266    Button {
267        /// Which button.
268        id: ButtonId,
269        /// `true` = button down; `false` = button up.
270        pressed: bool,
271        /// Best-effort physical source. `None` when the platform cannot
272        /// attribute the event (Windows today) or it was synthetic.
273        device: Option<EventDevice>,
274    },
275    /// A scroll-wheel tick or pixel-precise continuous scroll.
276    Scroll {
277        /// Signed two-axis distance with its native unit preserved.
278        delta: ScrollDelta,
279        /// `true` when the OS attributes this scroll to a trackpad / Magic Mouse
280        /// gesture rather than a mouse wheel, so a consumer can transform the
281        /// wheel while leaving native trackpad scrolling alone (issue #126).
282        ///
283        /// On macOS this is resolved from the `IOHIDEvent` sender's IOKit device
284        /// identity, because Logitech free-spin wheels can carry the same phase
285        /// flags as a trackpad. Sender-less events fall back to the phase fields.
286        /// Always `false` on Linux/Windows, where the wheel and trackpad arrive
287        /// as distinct event types rather than one flagged stream.
288        from_trackpad: bool,
289        /// Best-effort physical source of the scroll event. `None` means the
290        /// platform could not attribute the event to a device, or the event was
291        /// synthetic.
292        device: Option<EventDevice>,
293    },
294    /// Pointer movement, in device units. Emitted so a held gesture button can
295    /// accumulate a swipe; the callback passes these through (the cursor keeps
296    /// moving) and only reads them while a gesture button is down.
297    Moved {
298        /// Positive = right, negative = left.
299        delta_x: i32,
300        /// Positive = down, negative = up.
301        delta_y: i32,
302    },
303    /// The OS interrupted event capture (on macOS, the tap was disabled by a
304    /// timeout or by competing user input). Any in-progress gesture hold must be
305    /// cancelled: a button-up dropped during the gap would otherwise leave a
306    /// stale hold that the next stray pointer move turns into a phantom swipe.
307    /// Carries no data and is always passed through.
308    CaptureInterrupted,
309}
310
311/// What the hook callback wants the OS to do with the captured event.
312#[derive(Clone, Copy, Debug, PartialEq, Eq)]
313pub enum EventDisposition {
314    /// Let the event reach its original target unchanged.
315    PassThrough,
316    /// Drop the event; the target application never sees it.
317    Suppress,
318}
319
320/// Where in the event stream a tap is inserted (macOS `CGEventTapLocation`).
321#[derive(Clone, Copy, Debug, PartialEq, Eq)]
322pub enum TapLocation {
323    /// `kCGHIDEventTap` — the lowest level, ahead of the window server. An
324    /// *active* tap here gates raw device input for the whole system, so a slow
325    /// or wedged owner adds latency to every event. This is where OpenLogi (and
326    /// Logi Options+) install.
327    Hid,
328    /// `kCGSessionEventTap` — scoped to the current login session.
329    Session,
330    /// `kCGAnnotatedSessionEventTap` — session tap that also sees annotations.
331    AnnotatedSession,
332    /// A location value newer than this enum knows about.
333    Other(u32),
334}
335
336/// A live event tap installed somewhere in the system, as reported by
337/// [`Hook::list_event_taps`]. Read-only diagnostic snapshot — enumerating taps
338/// needs no Accessibility grant and any process in the session sees them all.
339///
340/// The per-tap latency figures `CGEventTapInformation` carries are deliberately
341/// omitted: empirically they hold uninitialised sentinel values that change
342/// between samples, so they are not a trustworthy lag signal.
343#[derive(Clone, Debug)]
344pub struct EventTapInfo {
345    /// The system-assigned tap identifier.
346    pub tap_id: u32,
347    /// Where the tap sits in the event stream.
348    pub location: TapLocation,
349    /// `true` for an *active* tap (`kCGEventTapOptionDefault`) that can modify
350    /// or suppress events; `false` for a passive *listen-only* tap, which
351    /// physically cannot stall input.
352    pub active: bool,
353    /// Whether the tap is currently enabled (servicing events).
354    pub enabled: bool,
355    /// PID of the process that installed the tap.
356    pub owner_pid: i32,
357    /// Best-effort executable file name of the owner, or `None` if the process
358    /// has exited or its path is unreadable.
359    pub owner_name: Option<String>,
360    /// PID of the single process whose events this tap intercepts, or `None`
361    /// for a global tap (one that sees every process's events).
362    pub target_pid: Option<i32>,
363}
364
365impl EventTapInfo {
366    /// `true` when this tap sits *active* at the [`TapLocation::Hid`] level and
367    /// is enabled — the one configuration that inserts the owner into the path
368    /// of every event and can therefore add latency system-wide. Listen-only,
369    /// disabled, or session-level taps cannot stall input this way.
370    #[must_use]
371    pub fn gates_input(&self) -> bool {
372        self.active && self.enabled && self.location == TapLocation::Hid
373    }
374
375    /// If this tap's owner is a known third-party input driver that competes
376    /// with OpenLogi for the mouse stream, return its product name — used to
377    /// warn the user about a likely pointer-lag cause.
378    ///
379    /// Matches on the owner executable name only; callers should combine it with
380    /// [`Self::gates_input`] so a competitor's *inactive* helper isn't flagged.
381    #[must_use]
382    pub fn known_input_conflict(&self) -> Option<&'static str> {
383        // (lower-cased executable-name substring, product display name). Brand
384        // names are not localised; only the surrounding warning copy is.
385        const KNOWN: &[(&str, &str)] = &[
386            ("logioptionsplus", "Logi Options+"),
387            ("logioptions", "Logitech Options"),
388            ("logimgr", "Logitech Options"),
389            ("lccdaemon", "Logitech Control Center"),
390            ("steermouse", "SteerMouse"),
391            ("bettermouse", "BetterMouse"),
392            ("usboverdrive", "USB Overdrive"),
393            ("mac mouse fix", "Mac Mouse Fix"),
394            ("linearmouse", "LinearMouse"),
395            ("smoothscroll", "SmoothScroll"),
396        ];
397        let name = self.owner_name.as_deref()?.to_ascii_lowercase();
398        KNOWN
399            .iter()
400            .find(|(needle, _)| name.contains(needle))
401            .map(|&(_, label)| label)
402    }
403}
404
405/// Errors that [`Hook::start`] and related functions can produce.
406///
407/// The same shape on every target: a platform-conditional enum would compile on
408/// the maintainer's macOS and break an exhaustive `match` on Linux, and one
409/// unreachable variant costs nothing.
410#[derive(Debug, thiserror::Error)]
411pub enum HookError {
412    /// This platform has no hook implementation (neither macOS, Linux, nor
413    /// Windows).
414    #[error("mouse event hook is not supported on this platform")]
415    Unsupported,
416    /// macOS Accessibility permission has not been granted to this process.
417    #[error(
418        "macOS Accessibility permission is required to capture mouse events; \
419         grant it in System Settings → Privacy & Security → Accessibility"
420    )]
421    AccessibilityDenied,
422    /// `CGEventTapCreate` returned null, or the run loop source could not be
423    /// created. The inner string carries the context.
424    #[error("CGEventTap setup failed: {0}")]
425    MacOsTap(String),
426    /// No mouse device was found under `/dev/input`. Either no pointing device
427    /// is connected, or the process lacks read permission on the device nodes
428    /// (add the user to the `input` group, or add a `udev` rule).
429    #[error(
430        "no mouse device found under /dev/input; \
431         ensure a pointing device is connected and the process has read permission \
432         (add user to the `input` group or add a udev rule)"
433    )]
434    NoDeviceFound,
435    /// A Linux-specific I/O error occurred while setting up or running the hook.
436    #[error("Linux input error: {0}")]
437    Linux(#[source] std::io::Error),
438    /// `SetWindowsHookExW` failed, or the hook thread could not be started.
439    #[error("Windows mouse hook setup failed: {0}")]
440    WindowsHook(String),
441}
442
443/// Everything one operating system has to provide for [`Hook`] to work.
444///
445/// Exactly one backend is compiled in — see the `Backend` alias below — so this
446/// is a compile-time contract, not runtime polymorphism. It earns its place by
447/// keeping the crate's per-OS `cfg` down to that single site, and by making the
448/// platform surface a list the compiler checks instead of a naming convention.
449/// Everything only some platforms can answer carries its do-nothing default
450/// here, so a backend implements exactly what it has.
451trait HookBackend {
452    /// Whatever the platform holds on to while the hook runs; handed back to
453    /// [`Self::stop`] to tear it down.
454    type Running;
455
456    /// Install the hook. [`Hook::start`] documents the contract owed to callers.
457    fn start(
458        cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
459    ) -> Result<Self::Running, HookError>;
460
461    /// Stop the hook and join its threads.
462    fn stop(running: Self::Running);
463
464    /// Whether the platform worker is still delivering events. Backends whose
465    /// workers are joined only during teardown have no separate terminal
466    /// state, so their live handle is sufficient by default.
467    fn is_running(_running: &Self::Running) -> bool {
468        true
469    }
470
471    /// See [`Hook::has_accessibility`]. Platforms that gate the hook below the
472    /// privacy layer answer `true`.
473    fn has_accessibility() -> bool {
474        true
475    }
476
477    /// See [`Hook::prompt_accessibility`]. Nothing to prompt for by default.
478    fn prompt_accessibility() {}
479
480    /// See [`Hook::list_event_taps`]. Empty where the OS keeps no tap registry.
481    fn list_event_taps() -> Vec<EventTapInfo> {
482        Vec::new()
483    }
484
485    /// See [`crate::frontmost_application`].
486    fn frontmost_app() -> Option<ForegroundApp> {
487        None
488    }
489
490    /// See [`crate::cursor_position`].
491    fn cursor_position() -> Option<CursorPosition> {
492        None
493    }
494}
495
496/// The backend for a platform with no hook: every default, and a
497/// [`HookBackend::start`] that can only fail. Compiled only where it is the
498/// one selected below, so it never sits unused in a real build.
499#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
500struct Unsupported;
501
502#[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
503impl HookBackend for Unsupported {
504    /// Uninhabited, so [`Hook`] can never hold a running hook here.
505    type Running = std::convert::Infallible;
506
507    fn start(
508        _cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
509    ) -> Result<Self::Running, HookError> {
510        Err(HookError::Unsupported)
511    }
512
513    fn stop(running: Self::Running) {
514        match running {}
515    }
516}
517
518// The backend this build talks to — the crate's one platform switch.
519cfg_select! {
520    target_os = "macos" => { type Backend = macos::Backend; }
521    target_os = "linux" => { type Backend = linux::Backend; }
522    target_os = "windows" => { type Backend = windows::Backend; }
523    _ => { type Backend = Unsupported; }
524}
525
526/// A running OS-level mouse hook. Call [`Hook::stop`] to tear down.
527///
528/// On macOS a dedicated thread runs a `CFRunLoop` draining a `CGEventTap`.
529/// On Linux one thread per physical mouse device reads `evdev` events and
530/// re-injects pass-through events via a `uinput` virtual device. On Windows a
531/// dedicated thread owns a `WH_MOUSE_LL` hook and pumps its message loop.
532/// Call `stop` (or let the value drop) to shut down all threads and release
533/// grabbed devices.
534pub struct Hook {
535    inner: Option<<Backend as HookBackend>::Running>,
536}
537
538impl Drop for Hook {
539    fn drop(&mut self) {
540        self.shutdown();
541    }
542}
543
544impl Hook {
545    /// Install the input hook and start delivering events to `cb`.
546    ///
547    /// The callback runs on a private background thread for every mouse
548    /// button, scroll, or (macOS / Windows) keyboard event. It must return
549    /// [`EventDisposition`] quickly — blocking it stalls input delivery
550    /// system-wide.
551    ///
552    /// On macOS, returns [`HookError::AccessibilityDenied`] when Accessibility
553    /// permission has not been granted. On Linux, returns
554    /// `HookError::NoDeviceFound` when no mouse device is accessible (key
555    /// events are not yet captured there). On Windows, installs `WH_MOUSE_LL`
556    /// and `WH_KEYBOARD_LL` low-level hooks.
557    pub fn start(
558        cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
559    ) -> Result<Self, HookError> {
560        Backend::start(cb).map(|inner| Self { inner: Some(inner) })
561    }
562
563    /// Stop the hook and release OS resources.
564    ///
565    /// Signals background threads to exit and blocks until they join. Calling
566    /// this explicitly is preferred over relying on `Drop` when errors in
567    /// cleanup should be visible. `Drop` calls this automatically.
568    pub fn stop(mut self) {
569        self.shutdown();
570    }
571
572    /// Whether the platform worker is still able to deliver events.
573    ///
574    /// A Windows message-pump error is terminal: the worker clears its callback
575    /// so native input passes through, and this method then returns `false`
576    /// even though the [`Hook`] handle has not yet been dropped.
577    #[must_use]
578    pub fn is_running(&self) -> bool {
579        self.inner.as_ref().is_some_and(Backend::is_running)
580    }
581
582    /// Tear down the platform hook if it is still running. Idempotent: the
583    /// first call takes `inner`, so the `Drop` after an explicit [`Self::stop`]
584    /// is a no-op.
585    fn shutdown(&mut self) {
586        if let Some(inner) = self.inner.take() {
587            Backend::stop(inner);
588        }
589    }
590
591    /// Returns `true` when the process has the permissions required to install
592    /// the hook.
593    ///
594    /// On macOS this is a live capability check, not just a read of the
595    /// Accessibility trust flag: that flag keeps reporting `true` after the
596    /// user deletes the app's row from System Settings, so it is paired with a
597    /// throwaway event tap that only succeeds while the grant really stands.
598    /// Poll it for as long as a hook is installed — an active tap that outlives
599    /// its permission wedges system input until reboot. On Linux and Windows
600    /// this always returns `true`; those platforms enforce permissions at a
601    /// lower layer (device-node ownership / group membership on Linux; the
602    /// Windows low-level hook needs no separate privacy grant).
603    #[must_use]
604    pub fn has_accessibility() -> bool {
605        Backend::has_accessibility()
606    }
607
608    /// Show the macOS Accessibility permission dialog and register this
609    /// process in System Settings → Privacy & Security → Accessibility.
610    ///
611    /// Unlike [`Self::has_accessibility`], this passes the
612    /// `kAXTrustedCheckOptionPrompt` option, so macOS surfaces the native
613    /// "open System Settings" dialog the first time and lists the app there
614    /// (otherwise the user would have to add the binary by hand). Called for
615    /// its side effect; the resulting trust state is observed separately via
616    /// [`Self::has_accessibility`]. No-op on non-macOS.
617    pub fn prompt_accessibility() {
618        Backend::prompt_accessibility();
619    }
620
621    /// Enumerate every event tap currently installed in this login session.
622    ///
623    /// A read-only diagnostic snapshot for spotting input contention — e.g. a
624    /// competing app holding an *active* [`TapLocation::Hid`] tap (the classic
625    /// "another driver is also intercepting the mouse" cause of pointer lag),
626    /// or OpenLogi's own tap being unexpectedly disabled. Needs no Accessibility
627    /// grant; the call sees every process's taps regardless of who asks.
628    ///
629    /// Returns an empty vector on non-macOS targets, which have no equivalent
630    /// global tap registry.
631    #[must_use]
632    pub fn list_event_taps() -> Vec<EventTapInfo> {
633        Backend::list_event_taps()
634    }
635}
636
637/// Return the currently frontmost application.
638///
639/// [`ForegroundApp::id`] defines the platform-specific identifier that per-app
640/// profiles match. [`ForegroundApp::display_name`] is the platform's application
641/// name, falling back to the identifier.
642///
643/// `None` when no app is frontmost, when reading fails, or on an unsupported
644/// platform — including a pure-Wayland session with no backend (see
645/// `linux::detect_frontmost_source`). Costs one X11 round-trip on Linux and a
646/// handful of `objc_msgSend`s on macOS. Callers can use
647/// [`watch_frontmost_application_changes`] to drive reads from native platform
648/// events instead of polling.
649#[must_use]
650pub fn frontmost_application() -> Option<ForegroundApp> {
651    Backend::frontmost_app()
652}
653
654/// Return the Safari process captured by the latest macOS foreground-app
655/// observation without querying AppKit on the caller's thread.
656///
657/// This is a nonblocking atomic snapshot for input callbacks. It returns
658/// `None` when Safari is not frontmost and on non-macOS platforms.
659#[must_use]
660pub fn frontmost_safari_pid() -> Option<i32> {
661    #[cfg(target_os = "macos")]
662    {
663        macos::frontmost_safari_pid()
664    }
665    #[cfg(not(target_os = "macos"))]
666    {
667        None
668    }
669}
670
671/// Failure to install or operate a native foreground-application observer.
672#[derive(Debug, Error)]
673#[non_exhaustive]
674pub enum ForegroundApplicationObserverError {
675    /// This target has no foreground-application event source.
676    #[error("foreground-application observation is unsupported on this platform")]
677    Unsupported,
678    /// The selected platform observer failed.
679    #[error("foreground-application observer failed: {0}")]
680    Platform(String),
681}
682
683/// RAII owner of the current platform's foreground-application observer.
684///
685/// Dropping this value synchronously unregisters the native observer and stops
686/// any worker it owns.
687#[must_use]
688pub struct ForegroundApplicationObserver {
689    #[cfg(target_os = "macos")]
690    platform: macos::ForegroundApplicationObserver,
691    #[cfg(target_os = "linux")]
692    platform: linux::ForegroundApplicationObserver,
693    #[cfg(target_os = "windows")]
694    platform: windows::foreground::ForegroundApplicationObserver,
695}
696
697impl ForegroundApplicationObserver {
698    /// Return an error if a fallible observer worker has stopped delivering.
699    ///
700    /// Native macOS registration has no independently observable worker
701    /// health, so it relies on the consumer's idle recovery read.
702    pub fn check_health(&self) -> Result<(), ForegroundApplicationObserverError> {
703        #[cfg(target_os = "linux")]
704        {
705            self.platform
706                .check_health()
707                .map_err(|error| ForegroundApplicationObserverError::Platform(error.to_owned()))
708        }
709        #[cfg(target_os = "windows")]
710        {
711            self.platform
712                .check_health()
713                .map_err(|error| ForegroundApplicationObserverError::Platform(error.to_string()))
714        }
715        #[cfg(target_os = "macos")]
716        {
717            let _ = &self.platform;
718            Ok(())
719        }
720        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
721        {
722            Err(ForegroundApplicationObserverError::Unsupported)
723        }
724    }
725}
726
727/// Observe native foreground-application changes.
728///
729/// The callback is an invalidation, not another source of application identity:
730/// call [`frontmost_application`] to read the authoritative current value. It
731/// may run on any thread, must return quickly, and is invoked once after native
732/// registration so the consumer can seed its state without a polling read.
733pub fn watch_frontmost_application_changes(
734    on_change: impl Fn() + Send + Sync + 'static,
735) -> Result<ForegroundApplicationObserver, ForegroundApplicationObserverError> {
736    let on_change: Arc<dyn Fn() + Send + Sync> = Arc::new(on_change);
737
738    #[cfg(target_os = "macos")]
739    {
740        let native_callback = Arc::clone(&on_change);
741        let platform = macos::watch_frontmost_application_activations(move |_| native_callback());
742        on_change();
743        Ok(ForegroundApplicationObserver { platform })
744    }
745    #[cfg(target_os = "linux")]
746    {
747        let platform = linux::watch_frontmost_application_activations(move |_| on_change())
748            .map_err(|error| ForegroundApplicationObserverError::Platform(error.to_string()))?;
749        Ok(ForegroundApplicationObserver { platform })
750    }
751    #[cfg(target_os = "windows")]
752    {
753        let platform = windows::foreground::watch_frontmost_application_activations(move |_| {
754            on_change();
755        })
756        .map_err(|error| ForegroundApplicationObserverError::Platform(error.to_string()))?;
757        Ok(ForegroundApplicationObserver { platform })
758    }
759    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
760    {
761        let _ = on_change;
762        Err(ForegroundApplicationObserverError::Unsupported)
763    }
764}
765
766/// Observe macOS foreground-application activations.
767///
768/// Each callback carries the application from AppKit's activation notification;
769/// it may run on any thread and must return quickly. Dropping the returned
770/// handle unregisters the native observer and releases its block.
771#[cfg(target_os = "macos")]
772pub fn watch_frontmost_application_activations(
773    on_activation: impl Fn(Option<ForegroundApp>) + Send + Sync + 'static,
774) -> ForegroundApplicationObserver {
775    ForegroundApplicationObserver {
776        platform: macos::watch_frontmost_application_activations(on_activation),
777    }
778}
779
780/// Return the current global cursor position without installing an input hook.
781///
782/// Returns `None` on unsupported platforms and on native Wayland, where the
783/// compositor deliberately does not expose global pointer coordinates.
784#[must_use]
785pub fn cursor_position() -> Option<CursorPosition> {
786    Backend::cursor_position()
787}
788
789#[cfg(target_os = "macos")]
790mod macos;
791
792#[cfg(target_os = "linux")]
793mod linux;
794
795#[cfg(any(target_os = "windows", test))]
796mod windows;
797
798#[cfg(test)]
799mod tests;