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;