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