#![expect(
unsafe_code,
reason = "the event tap uses Core Graphics / Core Foundation C APIs, and workspace observation uses typed Objective-C notification APIs"
)]
mod foreground;
mod grant;
pub(crate) mod pointer;
mod sender;
mod translate;
mod watchdog;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::{Arc, mpsc};
use std::thread;
use std::time::Duration;
use core_foundation::runloop::{
CFRunLoop, CFRunLoopRunResult, kCFRunLoopCommonModes, kCFRunLoopDefaultMode,
};
use core_graphics::event::{
CGEvent, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement,
CGEventTapProxy, CGEventType, CallbackResult,
};
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
use objc2_app_kit::NSWorkspace;
use objc2_application_services::{AXIsProcessTrusted, AXIsProcessTrustedWithOptions};
use tracing::{debug, error, warn};
use crate::{
CursorPosition, Delivery, EventDisposition, EventTapInfo, ForegroundApp, HookBackend,
HookError, HookEvent, MouseEvent, TapLocation,
};
pub use foreground::ForegroundApplicationObserver;
use foreground::observe_frontmost_application;
pub(crate) use foreground::{frontmost_safari_pid, watch_frontmost_application_activations};
use grant::{ProbeCue, can_filter_events};
use translate::{VERTICAL, translate, translate_key};
use watchdog::{
CALLBACK_POLL_INTERVAL, CallbackActivity, CallbackWatchdog, LIFECYCLE_POLL_INTERVAL,
LifecycleDecision, LifecycleExitReason, LifecycleObservation, LifecycleWatchdog, PowerEpoch,
RearmBudget, TapPhase, WatchdogSignals,
};
pub(crate) struct HookInner {
thread: thread::JoinHandle<()>,
lifecycle_watchdog: thread::JoinHandle<()>,
run_loop: CFRunLoop,
signals: Arc<WatchdogSignals>,
}
unsafe impl Send for HookInner {}
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
fn CGEventTapEnable(tap: core_foundation::mach_port::CFMachPortRef, enable: bool);
fn CGEventTapIsEnabled(tap: core_foundation::mach_port::CFMachPortRef) -> bool;
}
const FREEZE_HAZARD_EXIT_CODE: i32 = 78;
pub(crate) struct Backend;
impl HookBackend for Backend {
type Running = HookInner;
fn start(
cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
) -> Result<HookInner, HookError> {
if !Self::has_accessibility() {
return Err(HookError::AccessibilityDenied);
}
let _ = Self::frontmost_app();
let cb: Arc<dyn Fn(HookEvent) -> EventDisposition + Send + Sync> = Arc::new(cb);
let signals = Arc::new(WatchdogSignals::default());
let lifecycle_watchdog = spawn_lifecycle_watchdog(Arc::clone(&signals))?;
let (rl_tx, rl_rx) = mpsc::channel::<CFRunLoop>();
let thread = {
let thread_signals = Arc::clone(&signals);
match thread::Builder::new()
.name("openlogi-hook".into())
.spawn(move || thread_main(cb, rl_tx, thread_signals))
{
Ok(thread) => thread,
Err(error) => {
signals.set_phase(TapPhase::ThreadExited);
lifecycle_watchdog.thread().unpark();
let _ = lifecycle_watchdog.join();
return Err(HookError::MacOsTap(error.to_string()));
}
}
};
let Ok(run_loop) = rl_rx.recv() else {
let error = HookError::MacOsTap(
"background thread exited before the run loop started; \
CGEventTapCreate likely returned null"
.into(),
);
if let Err(panic) = thread.join() {
error!(?panic, "hook thread panicked during startup");
}
lifecycle_watchdog.thread().unpark();
if let Err(panic) = lifecycle_watchdog.join() {
error!(?panic, "hook lifecycle watchdog panicked during startup");
}
return Err(error);
};
Ok(HookInner {
thread,
lifecycle_watchdog,
run_loop,
signals,
})
}
fn stop(inner: HookInner) {
inner.signals.request_stop();
inner.lifecycle_watchdog.thread().unpark();
inner.run_loop.stop();
if let Err(e) = inner.thread.join() {
error!("hook thread panicked on shutdown: {e:?}");
}
inner.lifecycle_watchdog.thread().unpark();
if let Err(e) = inner.lifecycle_watchdog.join() {
error!("hook lifecycle watchdog panicked on shutdown: {e:?}");
}
}
fn has_accessibility() -> bool {
let trusted = unsafe { AXIsProcessTrusted() };
trusted && can_filter_events()
}
fn prompt_accessibility() {
use objc2_application_services::kAXTrustedCheckOptionPrompt;
use objc2_core_foundation::{CFDictionary, kCFBooleanTrue};
let (key, value) = unsafe { (kAXTrustedCheckOptionPrompt, kCFBooleanTrue) };
let Some(value) = value else { return };
let options = CFDictionary::from_slices(&[key], &[value]);
let _trusted = unsafe { AXIsProcessTrustedWithOptions(Some(options.as_opaque())) };
}
fn list_event_taps() -> Vec<EventTapInfo> {
let mut count: u32 = 0;
let err = unsafe { CGGetEventTapList(0, std::ptr::null_mut(), &raw mut count) };
if err != 0 || count == 0 {
return Vec::new();
}
let mut taps: Vec<CGEventTapInformation> =
vec![unsafe { std::mem::zeroed() }; count as usize];
let err = unsafe { CGGetEventTapList(count, taps.as_mut_ptr(), &raw mut count) };
if err != 0 {
return Vec::new();
}
taps.truncate(count as usize);
taps.into_iter()
.map(|t| EventTapInfo {
tap_id: t.event_tap_id,
location: match t.tap_point {
0 => TapLocation::Hid,
1 => TapLocation::Session,
2 => TapLocation::AnnotatedSession,
other => TapLocation::Other(other),
},
active: t.options == 0,
enabled: t.enabled,
owner_pid: t.tapping_process,
owner_name: process_name(t.tapping_process),
target_pid: (t.process_being_tapped != 0).then_some(t.process_being_tapped),
})
.collect()
}
fn frontmost_app() -> Option<ForegroundApp> {
use objc2::rc::autoreleasepool;
autoreleasepool(|pool| {
let app = NSWorkspace::sharedWorkspace().frontmostApplication();
observe_frontmost_application(app.as_deref(), pool)
})
}
fn cursor_position() -> Option<CursorPosition> {
let source = CGEventSource::new(CGEventSourceStateID::HIDSystemState).ok()?;
let point = CGEvent::new(source).ok()?.location();
Some(CursorPosition {
x: point.x,
y: point.y,
})
}
}
fn hooked_event_types() -> Vec<CGEventType> {
vec![
CGEventType::LeftMouseDown,
CGEventType::LeftMouseUp,
CGEventType::RightMouseDown,
CGEventType::RightMouseUp,
CGEventType::OtherMouseDown,
CGEventType::OtherMouseUp,
CGEventType::ScrollWheel,
CGEventType::MouseMoved,
CGEventType::LeftMouseDragged,
CGEventType::RightMouseDragged,
CGEventType::OtherMouseDragged,
CGEventType::KeyDown,
CGEventType::KeyUp,
CGEventType::FlagsChanged,
]
}
fn wheel_scroll_to_invert(event: &HookEvent) -> bool {
matches!(
event,
HookEvent::Mouse(MouseEvent::Scroll {
delta,
from_trackpad: false,
device,
..
}) if delta.y() != 0.0 && crate::source_is_remappable(device.as_ref())
)
}
fn invert_scroll_event(event: &CGEvent) {
let lines = event.get_integer_value_field(VERTICAL.line);
let finer =
[VERTICAL.fixed, VERTICAL.point].map(|field| (field, event.get_double_value_field(field)));
if lines != 0 {
event.set_integer_value_field(VERTICAL.line, -lines);
}
for (field, value) in finer {
if lines != 0 || value != 0.0 {
event.set_double_value_field(field, -value);
}
}
}
fn run_tap_callback(
cb: &dyn Fn(HookEvent) -> EventDisposition,
etype: CGEventType,
event: &CGEvent,
) -> CallbackResult {
let result = catch_unwind(AssertUnwindSafe(|| {
let hook_event = if let Some(mouse_event) = translate(etype, event) {
HookEvent::Mouse(mouse_event)
} else if let Some(key_event) = translate_key(etype, event) {
HookEvent::Key(key_event)
} else {
return CallbackResult::Keep;
};
let invert = crate::scroll_inversion() && wheel_scroll_to_invert(&hook_event);
dispatch(cb, hook_event, invert, event)
}));
if let Ok(disposition) = result {
disposition
} else {
error!(
"OS mouse-hook callback panicked — passing event through to \
avoid wedging system input"
);
CallbackResult::Keep
}
}
fn dispatch(
cb: &dyn Fn(HookEvent) -> EventDisposition,
hook_event: HookEvent,
invert: bool,
event: &CGEvent,
) -> CallbackResult {
let delivery = Delivery::run(hook_event, invert, cb);
if delivery.invert_native {
invert_scroll_event(event);
}
match delivery.disposition {
EventDisposition::PassThrough => CallbackResult::Keep,
EventDisposition::Suppress => CallbackResult::Drop,
}
}
fn spawn_callback_watchdog(
signals: Arc<WatchdogSignals>,
callback_activity: Arc<CallbackActivity>,
) -> std::io::Result<()> {
thread::Builder::new()
.name("openlogi-hook-watchdog".into())
.spawn(move || {
let mut watchdog =
CallbackWatchdog::watching_since(signals.now_millis(), power_epoch());
loop {
let phase = signals.phase();
if matches!(phase, TapPhase::TapStopped | TapPhase::ThreadExited) {
return;
}
thread::sleep(CALLBACK_POLL_INTERVAL);
let entered = callback_activity.entered_at_ms();
let Some(stuck) =
watchdog.evaluate(signals.now_millis(), entered, power_epoch())
else {
continue;
};
if callback_activity.entered_at_ms() != entered {
continue;
}
if signals.phase() != TapPhase::Armed {
continue;
}
error!(
stalled_ms = duration_millis(stuck.stalled),
watched_ms = duration_millis(stuck.watched),
"OS mouse-hook callback stuck past budget — exiting agent to \
restore system input (HID CGEventTap freeze hazard)"
);
#[expect(
clippy::exit,
reason = "this watchdog thread has no caller to return to and the stuck callback owns the active HID tap, which serialises every pointer event machine-wide; only process death makes macOS tear the tap down"
)]
std::process::exit(FREEZE_HAZARD_EXIT_CODE);
}
})
.map(|_| ())
}
fn power_epoch() -> PowerEpoch {
PowerEpoch {
slept_us: kernel_instant_us(c"kern.sleeptime"),
woke_us: kernel_instant_us(c"kern.waketime"),
}
}
fn kernel_instant_us(name: &std::ffi::CStr) -> i64 {
let mut value = libc::timeval {
tv_sec: 0,
tv_usec: 0,
};
let mut len = std::mem::size_of::<libc::timeval>();
let rc = unsafe {
libc::sysctlbyname(
name.as_ptr(),
(&raw mut value).cast(),
&raw mut len,
std::ptr::null_mut(),
0,
)
};
if rc != 0 || len != std::mem::size_of::<libc::timeval>() {
return 0;
}
value
.tv_sec
.saturating_mul(1_000_000)
.saturating_add(i64::from(value.tv_usec))
}
fn spawn_lifecycle_watchdog(
signals: Arc<WatchdogSignals>,
) -> Result<thread::JoinHandle<()>, HookError> {
thread::Builder::new()
.name("openlogi-hook-lifecycle-watchdog".into())
.spawn(move || {
let mut watchdog = LifecycleWatchdog::default();
loop {
let observation = LifecycleObservation {
phase: signals.phase(),
stop_requested: signals.stop_requested(),
tap_progress_at: signals.tap_progress_at(),
power: power_epoch(),
};
match watchdog.evaluate(signals.now(), observation) {
LifecycleDecision::Continue => {
thread::park_timeout(LIFECYCLE_POLL_INTERVAL);
}
LifecycleDecision::Complete => return,
LifecycleDecision::Exit {
reason,
watched,
stalled,
} => {
let phase = signals.phase();
let still_hazardous = match reason {
LifecycleExitReason::TapThreadStalled => {
matches!(
phase,
TapPhase::Arming | TapPhase::Armed | TapPhase::Probing
)
}
LifecycleExitReason::StopTimedOut => phase != TapPhase::ThreadExited,
};
if !still_hazardous {
continue;
}
let reason = match reason {
LifecycleExitReason::TapThreadStalled if phase == TapPhase::Arming => {
"HID tap creation or activation stopped making progress"
}
LifecycleExitReason::TapThreadStalled if phase == TapPhase::Probing => {
"HID tap capability probe stopped making progress"
}
LifecycleExitReason::TapThreadStalled => {
"HID tap thread stopped making progress while tap remained active"
}
LifecycleExitReason::StopTimedOut => {
"hook stop requested but tap thread did not exit"
}
};
error!(
reason,
stalled_ms = duration_millis(stalled),
watched_ms = duration_millis(watched),
?phase,
"HID CGEventTap lifecycle did not make progress before deadline — \
exiting agent to restore system input"
);
#[expect(
clippy::exit,
reason = "the tap thread is wedged (TCC revocation can stall it inside CoreGraphics), so no unwinding path can reach it from this watchdog thread; a live HID tap left behind freezes all input until the process dies"
)]
std::process::exit(FREEZE_HAZARD_EXIT_CODE);
}
}
}
})
.map_err(|error| HookError::MacOsTap(format!("could not spawn tap watchdog: {error}")))
}
fn service_tap(
tap: &CGEventTap<'_>,
signals: &WatchdogSignals,
tap_disabled: &AtomicBool,
probe: &ProbeCue,
) {
let mut rearm = RearmBudget::default();
loop {
if signals.stop_requested() {
break;
}
signals.mark_tap_progress();
match CFRunLoop::run_in_mode(
unsafe { kCFRunLoopDefaultMode },
Duration::from_millis(500),
false,
) {
CFRunLoopRunResult::Stopped | CFRunLoopRunResult::Finished => break,
CFRunLoopRunResult::TimedOut | CFRunLoopRunResult::HandledSource => {}
}
signals.mark_tap_progress();
signals.set_phase(TapPhase::Probing);
if probe.take() && !Backend::has_accessibility() {
warn!(
"Accessibility revoked while the event tap was live — \
disabling the tap to avoid wedging system input"
);
break;
}
let was_disabled = tap_disabled.swap(false, Ordering::AcqRel) || !tap_is_enabled(tap);
if was_disabled && !rearm.allow(signals.now()) {
error!(
"the OS keeps disabling the HID tap — releasing it instead of \
re-arming a tap nothing is servicing"
);
break;
}
tap.enable();
signals.mark_armed();
}
}
#[expect(
clippy::needless_pass_by_value,
reason = "rl_tx must be owned: dropping it signals the parent's recv() to return Err on failure paths"
)]
fn thread_main(
cb: Arc<dyn Fn(HookEvent) -> EventDisposition + Send + Sync>,
rl_tx: mpsc::Sender<CFRunLoop>,
signals: Arc<WatchdogSignals>,
) {
let _thread_exit = signals.thread_exit_guard();
signals.mark_tap_progress();
signals.set_phase(TapPhase::Arming);
let callback_activity = Arc::new(CallbackActivity::default());
let tap_disabled = Arc::new(AtomicBool::new(false));
let tap_result = {
let callback_signals = Arc::clone(&signals);
let callback_activity = Arc::clone(&callback_activity);
let tap_disabled = Arc::clone(&tap_disabled);
CGEventTap::new(
CGEventTapLocation::HID,
CGEventTapPlacement::HeadInsertEventTap,
CGEventTapOptions::Default,
hooked_event_types(),
move |_proxy: CGEventTapProxy, etype: CGEventType, event: &CGEvent| {
if matches!(
etype,
CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput
) {
tap_disabled.store(true, Ordering::Release);
}
callback_activity.enter(callback_signals.now_millis());
let disposition = run_tap_callback(cb.as_ref(), etype, event);
callback_activity.exit();
disposition
},
)
};
let Ok(tap) = tap_result else {
error!("CGEventTapCreate returned null — Accessibility may have been revoked");
return;
};
signals.mark_tap_progress();
let Ok(loop_source) = tap.mach_port().create_runloop_source(0) else {
error!("CFRunLoopSourceCreate failed for event tap");
return;
};
signals.mark_tap_progress();
let run_loop = CFRunLoop::get_current();
unsafe {
run_loop.add_source(&loop_source, kCFRunLoopCommonModes);
}
signals.mark_tap_progress();
if let Err(error) =
spawn_callback_watchdog(Arc::clone(&signals), Arc::clone(&callback_activity))
{
error!(%error, "could not spawn callback watchdog — refusing to arm HID tap");
return;
}
signals.mark_tap_progress();
tap.enable();
signals.mark_armed();
if rl_tx.send(run_loop.clone()).is_err() {
debug!("hook parent dropped before run loop was ready; stopping");
disable_tap(&tap);
run_loop.remove_source(&loop_source, unsafe { kCFRunLoopCommonModes });
drop(loop_source);
drop(tap);
signals.set_phase(TapPhase::TapStopped);
return;
}
let probe = ProbeCue::arm();
service_tap(&tap, &signals, &tap_disabled, &probe);
signals.mark_armed();
disable_tap(&tap);
run_loop.remove_source(&loop_source, unsafe { kCFRunLoopCommonModes });
drop(loop_source);
drop(tap);
signals.set_phase(TapPhase::TapStopped);
}
fn duration_millis(duration: Duration) -> u64 {
u64::try_from(duration.as_millis()).unwrap_or(u64::MAX)
}
fn tap_is_enabled(tap: &CGEventTap<'_>) -> bool {
use core_foundation::base::TCFType as _;
unsafe { CGEventTapIsEnabled(tap.mach_port().as_concrete_TypeRef()) }
}
fn disable_tap(tap: &CGEventTap<'_>) {
use core_foundation::base::TCFType as _;
unsafe { CGEventTapEnable(tap.mach_port().as_concrete_TypeRef(), false) };
}
#[repr(C)]
#[derive(Clone, Copy)]
struct CGEventTapInformation {
event_tap_id: u32,
tap_point: u32,
options: u32,
events_of_interest: u64,
tapping_process: i32,
process_being_tapped: i32,
enabled: bool,
min_usec_latency: f32,
avg_usec_latency: f32,
max_usec_latency: f32,
}
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
fn CGGetEventTapList(
max_number_of_taps: u32,
tap_list: *mut CGEventTapInformation,
event_tap_count: *mut u32,
) -> i32;
}
#[link(name = "System", kind = "dylib")]
unsafe extern "C" {
fn proc_pidpath(pid: i32, buffer: *mut std::ffi::c_void, buffersize: u32) -> i32;
}
fn process_name(pid: i32) -> Option<String> {
const BUF_LEN: u32 = 4096;
if pid <= 0 {
return None;
}
let mut buf = vec![0u8; BUF_LEN as usize];
let len = unsafe { proc_pidpath(pid, buf.as_mut_ptr().cast(), BUF_LEN) };
if len <= 0 {
return None;
}
buf.truncate(len.unsigned_abs() as usize);
let path = String::from_utf8_lossy(&buf);
Some(path.rsplit('/').next().unwrap_or(&path).to_string())
}
#[cfg(test)]
mod tests {
use std::cell::Cell;
use core_graphics::event::ScrollEventUnit;
use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
use super::*;
use crate::ScrollDelta;
#[test]
fn tap_callback_suppresses_normally_and_passes_through_panics() {
let source = CGEventSource::new(CGEventSourceStateID::Private)
.expect("CGEventSourceCreate must succeed");
let event = CGEvent::new(source).expect("CGEventCreate must succeed");
assert!(matches!(
run_tap_callback(
&|_| EventDisposition::Suppress,
CGEventType::MouseMoved,
&event
),
CallbackResult::Drop
));
assert!(matches!(
run_tap_callback(
&|_| panic!("test callback panic"),
CGEventType::MouseMoved,
&event
),
CallbackResult::Keep
));
}
fn wheel_notch(lines: i32) -> CGEvent {
let source = CGEventSource::new(CGEventSourceStateID::Private)
.expect("CGEventSourceCreate must succeed");
CGEvent::new_scroll_event(source, ScrollEventUnit::LINE, 1, lines, 0, 0)
.expect("CGEventCreateScrollWheelEvent must succeed")
}
fn vertical_fields(event: &CGEvent) -> (i64, f64, f64) {
(
event.get_integer_value_field(VERTICAL.line),
event.get_double_value_field(VERTICAL.point),
event.get_double_value_field(VERTICAL.fixed),
)
}
fn reversed_once(event: &CGEvent) -> bool {
let (line, point, fixed) = vertical_fields(event);
line < 0 && point < 0.0 && fixed < 0.0
}
fn dispatch_notch(
invert: bool,
disposition: EventDisposition,
) -> (Option<ScrollDelta>, CallbackResult, CGEvent) {
let event = wheel_notch(3);
let hook_event = HookEvent::Mouse(
translate(CGEventType::ScrollWheel, &event).expect("a wheel notch translates"),
);
let seen = Cell::new(None);
let result = dispatch(
&|hook_event| {
if let HookEvent::Mouse(MouseEvent::Scroll { delta, .. }) = hook_event {
seen.set(Some(delta));
}
disposition
},
hook_event,
invert,
&event,
);
(seen.get(), result, event)
}
fn notch_delta() -> ScrollDelta {
match translate(CGEventType::ScrollWheel, &wheel_notch(3)) {
Some(MouseEvent::Scroll { delta, .. }) => delta,
other => panic!("a wheel notch translates to a scroll, got {other:?}"),
}
}
#[test]
fn inverted_wheel_reaches_the_callback_reversed_and_the_desktop_reversed_once() {
let delta = notch_delta();
let untouched = vertical_fields(&wheel_notch(3));
assert!(
untouched.0 > 0 && untouched.1 > 0.0 && untouched.2 > 0.0,
"the notch carries every vertical field: {untouched:?}"
);
let (seen, result, event) = dispatch_notch(true, EventDisposition::PassThrough);
assert_eq!(seen, Some(delta.negate_y()));
assert!(matches!(result, CallbackResult::Keep));
assert!(
reversed_once(&event),
"forwarded {:?}, untouched {untouched:?}",
vertical_fields(&event)
);
let (seen, result, event) = dispatch_notch(true, EventDisposition::Suppress);
assert_eq!(seen, Some(delta.negate_y()));
assert!(matches!(result, CallbackResult::Drop));
assert_eq!(vertical_fields(&event), untouched);
}
#[test]
fn wheel_is_left_alone_when_inversion_is_off() {
let delta = notch_delta();
let untouched = vertical_fields(&wheel_notch(3));
for disposition in [EventDisposition::PassThrough, EventDisposition::Suppress] {
let (seen, _, event) = dispatch_notch(false, disposition);
assert_eq!(seen, Some(delta));
assert_eq!(vertical_fields(&event), untouched);
}
}
#[test]
fn invert_scroll_event_keeps_every_magnitude() {
for (unit, amount) in [
(ScrollEventUnit::LINE, 3),
(ScrollEventUnit::PIXEL, 40),
(ScrollEventUnit::PIXEL, 5),
] {
let source = CGEventSource::new(CGEventSourceStateID::Private)
.expect("CGEventSourceCreate must succeed");
let event = CGEvent::new_scroll_event(source, unit, 1, amount, 0, 0)
.expect("CGEventCreateScrollWheelEvent must succeed");
let (line, point, fixed) = vertical_fields(&event);
invert_scroll_event(&event);
assert_eq!(
vertical_fields(&event),
(-line, -point, -fixed),
"unit {unit}, amount {amount}"
);
}
}
}