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