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