#![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;
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, EventDisposition, EventTapInfo, ForegroundApp, HookBackend, HookError,
HookEvent, TapLocation,
};
pub use foreground::ForegroundApplicationObserver;
use foreground::observe_frontmost_application;
pub(crate) use foreground::{frontmost_safari_pid, watch_frontmost_application_activations};
use translate::{translate, translate_key};
use watchdog::{
CallbackActivity, LifecycleDecision, LifecycleExitReason, LifecycleObservation,
LifecycleWatchdog, RearmBudget, TapPhase, WatchdogSignals, stuck_callback,
};
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;
}
fn can_filter_events() -> bool {
CGEventTap::new(
CGEventTapLocation::HID,
CGEventTapPlacement::HeadInsertEventTap,
CGEventTapOptions::Default,
vec![CGEventType::Null],
|_proxy: CGEventTapProxy, _etype: CGEventType, _event: &CGEvent| CallbackResult::Keep,
)
.is_ok()
}
const CALLBACK_WATCHDOG_POLL_INTERVAL: Duration = Duration::from_millis(20);
const LIFECYCLE_WATCHDOG_POLL_INTERVAL: Duration = Duration::from_millis(100);
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 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;
};
match cb(hook_event) {
EventDisposition::PassThrough => CallbackResult::Keep,
EventDisposition::Suppress => CallbackResult::Drop,
}
}));
if let Ok(disposition) = result {
disposition
} else {
error!(
"OS mouse-hook callback panicked — passing event through to \
avoid wedging system input"
);
CallbackResult::Keep
}
}
fn spawn_callback_watchdog(
signals: Arc<WatchdogSignals>,
callback_activity: Arc<CallbackActivity>,
) -> std::io::Result<()> {
thread::Builder::new()
.name("openlogi-hook-watchdog".into())
.spawn(move || {
loop {
let phase = signals.phase();
if matches!(phase, TapPhase::TapStopped | TapPhase::ThreadExited) {
return;
}
thread::sleep(CALLBACK_WATCHDOG_POLL_INTERVAL);
let Some(entered) = callback_activity.entered_at_ms() else {
continue;
};
let Some(elapsed) = stuck_callback(signals.now_millis(), entered) else {
continue;
};
if callback_activity.entered_at_ms() != Some(entered) {
continue;
}
if signals.phase() != TapPhase::Armed {
continue;
}
error!(
stuck_ms = duration_millis(elapsed),
"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 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(),
};
match watchdog.evaluate(signals.now(), observation) {
LifecycleDecision::Continue => {
thread::park_timeout(LIFECYCLE_WATCHDOG_POLL_INTERVAL);
}
LifecycleDecision::Complete => return,
LifecycleDecision::Exit { reason, elapsed } => {
let phase = signals.phase();
let still_hazardous = match reason {
LifecycleExitReason::TapThreadStalled => {
matches!(phase, TapPhase::Arming | TapPhase::Armed)
}
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 => {
"HID tap thread stopped making progress while tap remained active"
}
LifecycleExitReason::StopTimedOut => {
"hook stop requested but tap thread did not exit"
}
};
error!(
reason,
elapsed_ms = duration_millis(elapsed),
?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) {
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();
if !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();
}
}
#[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_tap_progress();
signals.set_phase(TapPhase::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;
}
service_tap(&tap, &signals, &tap_disabled);
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 core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
use super::*;
#[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
));
}
}