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;