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`] defines the platform-specific identifier that per-app
518/// profiles match. [`ForegroundApp::display_name`] is the platform's application
519/// name, falling back to the identifier.
520///
521/// `None` when no app is frontmost, when reading fails, or on an unsupported
522/// platform — including a pure-Wayland session with no backend (see
523/// `linux::detect_frontmost_source`). Costs one X11 round-trip on Linux and a
524/// handful of `objc_msgSend`s on macOS. Callers can use
525/// [`watch_frontmost_application_changes`] to drive reads from native platform
526/// events instead of polling.
527#[must_use]
528pub fn frontmost_application() -> Option<ForegroundApp> {
529 Backend::frontmost_app()
530}
531
532/// Return the Safari process captured by the latest macOS foreground-app
533/// observation without querying AppKit on the caller's thread.
534///
535/// This is a nonblocking atomic snapshot for input callbacks. It returns
536/// `None` when Safari is not frontmost and on non-macOS platforms.
537#[must_use]
538pub fn frontmost_safari_pid() -> Option<i32> {
539 #[cfg(target_os = "macos")]
540 {
541 macos::frontmost_safari_pid()
542 }
543 #[cfg(not(target_os = "macos"))]
544 {
545 None
546 }
547}
548
549/// Failure to install or operate a native foreground-application observer.
550#[derive(Debug, Error)]
551#[non_exhaustive]
552pub enum ForegroundApplicationObserverError {
553 /// This target has no foreground-application event source.
554 #[error("foreground-application observation is unsupported on this platform")]
555 Unsupported,
556 /// The selected platform observer failed.
557 #[error("foreground-application observer failed: {0}")]
558 Platform(String),
559}
560
561/// RAII owner of the current platform's foreground-application observer.
562///
563/// Dropping this value synchronously unregisters the native observer and stops
564/// any worker it owns.
565#[must_use]
566pub struct ForegroundApplicationObserver {
567 #[cfg(target_os = "macos")]
568 platform: macos::ForegroundApplicationObserver,
569 #[cfg(target_os = "linux")]
570 platform: linux::ForegroundApplicationObserver,
571 #[cfg(target_os = "windows")]
572 platform: windows::foreground::ForegroundApplicationObserver,
573}
574
575impl ForegroundApplicationObserver {
576 /// Return an error if a fallible observer worker has stopped delivering.
577 ///
578 /// Native macOS registration has no independently observable worker
579 /// health, so it relies on the consumer's idle recovery read.
580 pub fn check_health(&self) -> Result<(), ForegroundApplicationObserverError> {
581 #[cfg(target_os = "linux")]
582 {
583 self.platform
584 .check_health()
585 .map_err(|error| ForegroundApplicationObserverError::Platform(error.to_owned()))
586 }
587 #[cfg(target_os = "windows")]
588 {
589 self.platform
590 .check_health()
591 .map_err(|error| ForegroundApplicationObserverError::Platform(error.to_string()))
592 }
593 #[cfg(target_os = "macos")]
594 {
595 let _ = &self.platform;
596 Ok(())
597 }
598 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
599 {
600 Err(ForegroundApplicationObserverError::Unsupported)
601 }
602 }
603}
604
605/// Observe native foreground-application changes.
606///
607/// The callback is an invalidation, not another source of application identity:
608/// call [`frontmost_application`] to read the authoritative current value. It
609/// may run on any thread, must return quickly, and is invoked once after native
610/// registration so the consumer can seed its state without a polling read.
611pub fn watch_frontmost_application_changes(
612 on_change: impl Fn() + Send + Sync + 'static,
613) -> Result<ForegroundApplicationObserver, ForegroundApplicationObserverError> {
614 let on_change: Arc<dyn Fn() + Send + Sync> = Arc::new(on_change);
615
616 #[cfg(target_os = "macos")]
617 {
618 let native_callback = Arc::clone(&on_change);
619 let platform = macos::watch_frontmost_application_activations(move |_| native_callback());
620 on_change();
621 Ok(ForegroundApplicationObserver { platform })
622 }
623 #[cfg(target_os = "linux")]
624 {
625 let platform = linux::watch_frontmost_application_activations(move |_| on_change())
626 .map_err(|error| ForegroundApplicationObserverError::Platform(error.to_string()))?;
627 Ok(ForegroundApplicationObserver { platform })
628 }
629 #[cfg(target_os = "windows")]
630 {
631 let platform = windows::foreground::watch_frontmost_application_activations(move |_| {
632 on_change();
633 })
634 .map_err(|error| ForegroundApplicationObserverError::Platform(error.to_string()))?;
635 Ok(ForegroundApplicationObserver { platform })
636 }
637 #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
638 {
639 let _ = on_change;
640 Err(ForegroundApplicationObserverError::Unsupported)
641 }
642}
643
644/// Observe macOS foreground-application activations.
645///
646/// Each callback carries the application from AppKit's activation notification;
647/// it may run on any thread and must return quickly. Dropping the returned
648/// handle unregisters the native observer and releases its block.
649#[cfg(target_os = "macos")]
650pub fn watch_frontmost_application_activations(
651 on_activation: impl Fn(Option<ForegroundApp>) + Send + Sync + 'static,
652) -> ForegroundApplicationObserver {
653 ForegroundApplicationObserver {
654 platform: macos::watch_frontmost_application_activations(on_activation),
655 }
656}
657
658/// Return the current global cursor position without installing an input hook.
659///
660/// Returns `None` on unsupported platforms and on native Wayland, where the
661/// compositor deliberately does not expose global pointer coordinates.
662#[must_use]
663pub fn cursor_position() -> Option<CursorPosition> {
664 Backend::cursor_position()
665}
666
667#[cfg(target_os = "macos")]
668mod macos;
669
670#[cfg(target_os = "linux")]
671mod linux;
672
673#[cfg(any(target_os = "windows", test))]
674mod windows;
675
676#[cfg(test)]
677mod tests;