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