use std::cell::RefCell;
use std::collections::HashMap;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
use std::sync::{Arc, mpsc};
use std::thread;
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use core_foundation::base::{CFTypeRef, TCFType as _};
use core_foundation::number::CFNumber;
use core_foundation::runloop::{
CFRunLoop, CFRunLoopRunResult, kCFRunLoopCommonModes, kCFRunLoopDefaultMode,
};
use core_foundation::string::{CFString, CFStringRef};
use core_graphics::event::{
CGEvent, CGEventField, CGEventFlags, CGEventTap, CGEventTapLocation, CGEventTapOptions,
CGEventTapPlacement, CGEventTapProxy, CGEventType, CallbackResult, EventField,
};
use foreign_types_shared::ForeignType as _;
use tracing::{debug, error, warn};
use crate::{
ButtonId, EventDevice, EventDisposition, EventTapInfo, HookError, HookEvent, KeyEvent,
KeyModifiers, MouseEvent, TapLocation,
};
pub(crate) struct HookInner {
thread: thread::JoinHandle<()>,
run_loop: CFRunLoop,
stop: Arc<AtomicBool>,
}
unsafe impl Send for HookInner {}
#[link(name = "ApplicationServices", kind = "framework")]
unsafe extern "C" {
fn AXIsProcessTrustedWithOptions(options: *const std::ffi::c_void) -> bool;
static kAXTrustedCheckOptionPrompt: core_foundation::string::CFStringRef;
}
type IOHIDEventRef = *mut std::ffi::c_void;
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
fn CGEventCopyIOHIDEvent(event: *const std::ffi::c_void) -> IOHIDEventRef;
}
#[link(name = "IOKit", kind = "framework")]
unsafe extern "C" {
fn IOHIDEventGetSenderID(event: IOHIDEventRef) -> u64;
}
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
fn CFRelease(cf: *const std::ffi::c_void);
}
fn event_sender_id(event: &CGEvent) -> Option<u64> {
let hid = unsafe { CGEventCopyIOHIDEvent(event.as_ptr().cast()) };
if hid.is_null() {
return None;
}
let sender = unsafe { IOHIDEventGetSenderID(hid) };
unsafe { CFRelease(hid) };
Some(sender)
}
type IoObjectT = u32;
#[link(name = "IOKit", kind = "framework")]
unsafe extern "C" {
fn IORegistryEntryIDMatching(entry_id: u64) -> *mut std::ffi::c_void;
fn IOServiceGetMatchingService(main_port: u32, matching: *const std::ffi::c_void) -> IoObjectT;
fn IORegistryEntrySearchCFProperty(
entry: IoObjectT,
plane: *const std::ffi::c_char,
key: CFStringRef,
allocator: CFTypeRef,
options: u32,
) -> CFTypeRef;
fn IOObjectRelease(object: IoObjectT) -> i32;
}
const IO_REGISTRY_ITERATE_RECURSIVELY: u32 = 1;
const IO_REGISTRY_ITERATE_PARENTS: u32 = 2;
fn open_service(sender_id: u64) -> Option<IoObjectT> {
let matching = unsafe { IORegistryEntryIDMatching(sender_id) };
if matching.is_null() {
return None;
}
let service = unsafe { IOServiceGetMatchingService(0, matching) };
(service != 0).then_some(service)
}
fn service_property(service: IoObjectT, key: &str) -> Option<CFTypeRef> {
let cf_key = CFString::new(key);
let plane = c"IOService";
let prop = unsafe {
IORegistryEntrySearchCFProperty(
service,
plane.as_ptr(),
cf_key.as_concrete_TypeRef(),
std::ptr::null(),
IO_REGISTRY_ITERATE_RECURSIVELY | IO_REGISTRY_ITERATE_PARENTS,
)
};
(!prop.is_null()).then_some(prop)
}
#[derive(Clone, Default)]
struct SenderDeviceInfo {
event_device: EventDevice,
is_trackpad: bool,
}
fn sender_device_info(sender_id: u64) -> SenderDeviceInfo {
thread_local! {
static CACHE: RefCell<HashMap<u64, SenderDeviceInfo>> = RefCell::new(HashMap::new());
}
CACHE.with_borrow_mut(|cache| {
cache
.entry(sender_id)
.or_insert_with(|| {
let Some(service) = open_service(sender_id) else {
return SenderDeviceInfo::default();
};
let string_prop = |k| {
service_property(service, k)
.map(|p| unsafe { CFString::wrap_under_create_rule(p.cast()) }.to_string())
};
let num_prop = |k| {
service_property(service, k)
.and_then(|p| {
unsafe { CFNumber::wrap_under_create_rule(p.cast()) }.to_i64()
})
.and_then(|n| u32::try_from(n).ok())
};
let product_name = string_prop("Product");
let info = SenderDeviceInfo {
is_trackpad: product_name
.as_deref()
.is_some_and(|p| p.to_lowercase().contains("trackpad")),
event_device: EventDevice {
vendor_id: num_prop("VendorID").or_else(|| num_prop("idVendor")),
product_id: num_prop("ProductID").or_else(|| num_prop("idProduct")),
product_name,
},
};
unsafe { IOObjectRelease(service) };
info
})
.clone()
})
}
pub(crate) fn has_accessibility() -> bool {
unsafe { AXIsProcessTrustedWithOptions(std::ptr::null()) }
}
pub(crate) fn prompt_accessibility() {
use core_foundation::base::TCFType as _;
use core_foundation::boolean::CFBoolean;
use core_foundation::dictionary::CFDictionary;
use core_foundation::string::CFString;
let key = unsafe { CFString::wrap_under_get_rule(kAXTrustedCheckOptionPrompt) };
let options =
CFDictionary::from_CFType_pairs(&[(key.as_CFType(), CFBoolean::true_value().as_CFType())]);
let _trusted = unsafe { AXIsProcessTrustedWithOptions(options.as_concrete_TypeRef().cast()) };
}
pub(crate) fn frontmost_bundle_id() -> Option<String> {
use objc2::rc::autoreleasepool;
use objc2_app_kit::NSWorkspace;
autoreleasepool(|pool| {
let app = NSWorkspace::sharedWorkspace().frontmostApplication()?;
let bundle_id = app.bundleIdentifier()?;
Some(unsafe { bundle_id.to_str(pool) }.to_owned())
})
}
fn button_number_to_id(n: i64) -> Option<ButtonId> {
match n {
0 => Some(ButtonId::LeftClick),
1 => Some(ButtonId::RightClick),
2 => Some(ButtonId::MiddleClick),
3 => Some(ButtonId::Back),
4 => Some(ButtonId::Forward),
_ => None,
}
}
fn button_source(event: &CGEvent) -> Option<crate::EventDevice> {
event_sender_id(event).map(|id| sender_device_info(id).event_device)
}
fn modifiers_from_flags(flags: CGEventFlags) -> KeyModifiers {
KeyModifiers {
shift: flags.contains(CGEventFlags::CGEventFlagShift),
control: flags.contains(CGEventFlags::CGEventFlagControl),
option: flags.contains(CGEventFlags::CGEventFlagAlternate),
command: flags.contains(CGEventFlags::CGEventFlagCommand),
}
}
fn translate_key(etype: CGEventType, event: &CGEvent) -> Option<KeyEvent> {
let pressed = match etype {
CGEventType::KeyDown => true,
CGEventType::KeyUp => false,
_ => return None,
};
let keycode = event.get_integer_value_field(EventField::KEYBOARD_EVENT_KEYCODE);
let keycode = u16::try_from(keycode).ok()?;
Some(KeyEvent {
keycode,
pressed,
modifiers: modifiers_from_flags(event.get_flags()),
})
}
fn translate(etype: CGEventType, event: &CGEvent) -> Option<MouseEvent> {
let can_be_synthetic = matches!(
etype,
CGEventType::LeftMouseDown
| CGEventType::LeftMouseUp
| CGEventType::RightMouseDown
| CGEventType::RightMouseUp
| CGEventType::OtherMouseDown
| CGEventType::OtherMouseUp
| CGEventType::ScrollWheel
);
if can_be_synthetic
&& event.get_integer_value_field(EventField::EVENT_SOURCE_USER_DATA)
== openlogi_inject::SYNTHETIC_EVENT_USER_DATA
{
return None;
}
match etype {
CGEventType::LeftMouseDown => Some(MouseEvent::Button {
id: ButtonId::LeftClick,
pressed: true,
device: button_source(event),
}),
CGEventType::LeftMouseUp => Some(MouseEvent::Button {
id: ButtonId::LeftClick,
pressed: false,
device: button_source(event),
}),
CGEventType::RightMouseDown => Some(MouseEvent::Button {
id: ButtonId::RightClick,
pressed: true,
device: button_source(event),
}),
CGEventType::RightMouseUp => Some(MouseEvent::Button {
id: ButtonId::RightClick,
pressed: false,
device: button_source(event),
}),
CGEventType::OtherMouseDown => {
let n = event.get_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER);
button_number_to_id(n).map(|id| MouseEvent::Button {
id,
pressed: true,
device: button_source(event),
})
}
CGEventType::OtherMouseUp => {
let n = event.get_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER);
button_number_to_id(n).map(|id| MouseEvent::Button {
id,
pressed: false,
device: button_source(event),
})
}
CGEventType::ScrollWheel => {
let dy = usable_scroll_delta(event, VERTICAL);
let dx = usable_scroll_delta(event, HORIZONTAL);
let phase = event.get_integer_value_field(SCROLL_PHASE) != 0
|| event.get_integer_value_field(MOMENTUM_PHASE) != 0
|| event.get_integer_value_field(SCROLL_COUNT) != 0;
let sender = event_sender_id(event);
let device_info = sender.map(sender_device_info);
let from_trackpad = device_info.as_ref().map_or(phase, |info| info.is_trackpad);
#[allow(
clippy::cast_possible_truncation,
reason = "scroll deltas are small fractional values that fit comfortably in f32"
)]
Some(MouseEvent::Scroll {
delta_x: dx as f32,
delta_y: dy as f32,
from_trackpad,
device: device_info.map(|info| info.event_device),
})
}
CGEventType::MouseMoved
| CGEventType::LeftMouseDragged
| CGEventType::RightMouseDragged
| CGEventType::OtherMouseDragged => {
let dx = event.get_integer_value_field(EventField::MOUSE_EVENT_DELTA_X);
let dy = event.get_integer_value_field(EventField::MOUSE_EVENT_DELTA_Y);
#[allow(
clippy::cast_possible_truncation,
reason = "per-event pointer deltas are small integers, far within i32"
)]
Some(MouseEvent::Moved {
delta_x: dx as i32,
delta_y: dy as i32,
})
}
CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput => {
debug!("CGEventTap disabled by OS (type={etype:?}); re-enabling, cancelling any hold");
Some(MouseEvent::CaptureInterrupted)
}
_ => None,
}
}
#[derive(Clone, Copy)]
struct ScrollAxisFields {
line: CGEventField,
fixed: CGEventField,
point: CGEventField,
}
const VERTICAL: ScrollAxisFields = ScrollAxisFields {
line: EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_1,
fixed: EventField::SCROLL_WHEEL_EVENT_FIXED_POINT_DELTA_AXIS_1,
point: EventField::SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_1,
};
const HORIZONTAL: ScrollAxisFields = ScrollAxisFields {
line: EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_2,
fixed: EventField::SCROLL_WHEEL_EVENT_FIXED_POINT_DELTA_AXIS_2,
point: EventField::SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_2,
};
const SCROLL_PHASE: CGEventField = 99; const SCROLL_COUNT: CGEventField = 100; const MOMENTUM_PHASE: CGEventField = 123;
#[allow(
clippy::cast_precision_loss,
reason = "scroll line deltas are small integers, exact in f64"
)]
fn usable_scroll_delta(event: &CGEvent, axis: ScrollAxisFields) -> f64 {
let point = event.get_double_value_field(axis.point);
if point != 0.0 {
return point;
}
let fixed = event.get_double_value_field(axis.fixed);
if fixed != 0.0 {
return fixed;
}
event.get_integer_value_field(axis.line) as f64
}
pub(crate) fn start(
cb: impl Fn(HookEvent) -> EventDisposition + Send + Sync + 'static,
) -> Result<HookInner, HookError> {
if !has_accessibility() {
return Err(HookError::AccessibilityDenied);
}
let cb: Arc<dyn Fn(HookEvent) -> EventDisposition + Send + Sync> = Arc::new(cb);
let stop = Arc::new(AtomicBool::new(false));
let (rl_tx, rl_rx) = mpsc::channel::<CFRunLoop>();
let thread = {
let stop = Arc::clone(&stop);
thread::Builder::new()
.name("openlogi-hook".into())
.spawn(move || thread_main(cb, rl_tx, stop))
.map_err(|e| HookError::MacOsTap(e.to_string()))?
};
let run_loop = rl_rx.recv().map_err(|_| {
HookError::MacOsTap(
"background thread exited before the run loop started; \
CGEventTapCreate likely returned null"
.into(),
)
})?;
Ok(HookInner {
thread,
run_loop,
stop,
})
}
const CALLBACK_STUCK_BUDGET: Duration = Duration::from_millis(200);
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(
stop: Arc<AtomicBool>,
in_callback: Arc<AtomicBool>,
entered_at_ms: Arc<AtomicU64>,
) {
let budget_ms = u64::try_from(CALLBACK_STUCK_BUDGET.as_millis()).unwrap_or(200);
let _ = thread::Builder::new()
.name("openlogi-hook-watchdog".into())
.spawn(move || {
while !stop.load(Ordering::Relaxed) {
thread::sleep(Duration::from_millis(20));
if !in_callback.load(Ordering::Acquire) {
continue;
}
let entered = entered_at_ms.load(Ordering::Acquire);
if entered == 0 {
continue;
}
let elapsed = unix_now_ms().saturating_sub(entered);
if elapsed < budget_ms {
continue;
}
if !in_callback.load(Ordering::Acquire)
|| entered_at_ms.load(Ordering::Acquire) != entered
{
continue;
}
error!(
stuck_ms = elapsed,
"OS mouse-hook callback stuck past budget — exiting agent to \
restore system input (HID CGEventTap freeze hazard)"
);
std::process::exit(78);
}
});
}
#[allow(
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>,
stop: Arc<AtomicBool>,
) {
let in_callback = Arc::new(AtomicBool::new(false));
let entered_at_ms = Arc::new(AtomicU64::new(0));
let tap_result = {
let in_callback = Arc::clone(&in_callback);
let entered_at_ms = Arc::clone(&entered_at_ms);
CGEventTap::new(
CGEventTapLocation::HID,
CGEventTapPlacement::HeadInsertEventTap,
CGEventTapOptions::Default,
hooked_event_types(),
move |_proxy: CGEventTapProxy, etype: CGEventType, event: &CGEvent| {
entered_at_ms.store(unix_now_ms(), Ordering::Relaxed);
in_callback.store(true, Ordering::Release);
let disposition = run_tap_callback(cb.as_ref(), etype, event);
in_callback.store(false, Ordering::Release);
disposition
},
)
};
let Ok(tap) = tap_result else {
error!("CGEventTapCreate returned null — Accessibility may have been revoked");
return;
};
let Ok(loop_source) = tap.mach_port().create_runloop_source(0) else {
error!("CFRunLoopSourceCreate failed for event tap");
return;
};
let run_loop = CFRunLoop::get_current();
unsafe {
run_loop.add_source(&loop_source, kCFRunLoopCommonModes);
}
tap.enable();
spawn_callback_watchdog(
Arc::clone(&stop),
Arc::clone(&in_callback),
Arc::clone(&entered_at_ms),
);
if rl_tx.send(run_loop).is_err() {
debug!("hook parent dropped before run loop was ready; stopping");
disable_tap(&tap);
return;
}
loop {
if stop.load(Ordering::Relaxed) {
break;
}
match CFRunLoop::run_in_mode(
unsafe { kCFRunLoopDefaultMode },
Duration::from_millis(500),
false,
) {
CFRunLoopRunResult::Stopped | CFRunLoopRunResult::Finished => break,
CFRunLoopRunResult::TimedOut | CFRunLoopRunResult::HandledSource => {}
}
if !has_accessibility() {
warn!(
"Accessibility revoked while the event tap was live — \
disabling the tap to avoid wedging system input"
);
break;
}
tap.enable();
}
disable_tap(&tap);
}
fn unix_now_ms() -> u64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map_or(0, |d| u64::try_from(d.as_millis()).unwrap_or(u64::MAX))
}
fn disable_tap(tap: &CGEventTap) {
use core_foundation::base::TCFType as _;
#[link(name = "CoreGraphics", kind = "framework")]
unsafe extern "C" {
fn CGEventTapEnable(tap: core_foundation::mach_port::CFMachPortRef, enable: bool);
}
unsafe { CGEventTapEnable(tap.mach_port().as_concrete_TypeRef(), false) };
}
#[repr(C)]
#[derive(Clone, Copy)]
#[allow(
dead_code,
reason = "events_of_interest and the latency floats are unread but must \
exist so the struct keeps CoreGraphics' exact 48-byte stride; \
CGGetEventTapList writes whole records into the buffer"
)]
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;
}
pub(crate) 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 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())
}
pub(crate) fn stop(inner: HookInner) {
inner.stop.store(true, Ordering::Relaxed);
inner.run_loop.stop();
if let Err(e) = inner.thread.join() {
error!("hook thread panicked on shutdown: {e:?}");
}
}