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