use std::cell::RefCell;
use std::collections::HashMap;
use std::sync::{Arc, mpsc};
use std::thread;
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, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement,
CGEventTapProxy, CGEventType, CallbackResult, EventField,
};
use foreign_types_shared::ForeignType as _;
use tracing::{debug, error, warn};
use crate::{ButtonId, EventDevice, EventDisposition, HookError, MouseEvent};
pub(crate) struct HookInner {
thread: thread::JoinHandle<()>,
run_loop: CFRunLoop,
}
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 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,
}),
CGEventType::LeftMouseUp => Some(MouseEvent::Button {
id: ButtonId::LeftClick,
pressed: false,
}),
CGEventType::RightMouseDown => Some(MouseEvent::Button {
id: ButtonId::RightClick,
pressed: true,
}),
CGEventType::RightMouseUp => Some(MouseEvent::Button {
id: ButtonId::RightClick,
pressed: false,
}),
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 })
}
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 })
}
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(MouseEvent) -> EventDisposition + Send + Sync + 'static,
) -> Result<HookInner, HookError> {
if !has_accessibility() {
return Err(HookError::AccessibilityDenied);
}
let cb: Arc<dyn Fn(MouseEvent) -> EventDisposition + Send + Sync> = Arc::new(cb);
let (rl_tx, rl_rx) = mpsc::channel::<CFRunLoop>();
let thread = thread::Builder::new()
.name("openlogi-hook".into())
.spawn(move || thread_main(cb, rl_tx))
.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 })
}
#[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(MouseEvent) -> EventDisposition + Send + Sync>,
rl_tx: mpsc::Sender<CFRunLoop>,
) {
let event_types = vec![
CGEventType::LeftMouseDown,
CGEventType::LeftMouseUp,
CGEventType::RightMouseDown,
CGEventType::RightMouseUp,
CGEventType::OtherMouseDown,
CGEventType::OtherMouseUp,
CGEventType::ScrollWheel,
CGEventType::MouseMoved,
CGEventType::LeftMouseDragged,
CGEventType::RightMouseDragged,
CGEventType::OtherMouseDragged,
];
let tap_result = CGEventTap::new(
CGEventTapLocation::HID,
CGEventTapPlacement::HeadInsertEventTap,
CGEventTapOptions::Default,
event_types,
move |_proxy: CGEventTapProxy, etype: CGEventType, event: &CGEvent| {
let Some(mouse_event) = translate(etype, event) else {
return CallbackResult::Keep;
};
match cb(mouse_event) {
EventDisposition::PassThrough => CallbackResult::Keep,
EventDisposition::Suppress => CallbackResult::Drop,
}
},
);
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();
if rl_tx.send(run_loop).is_err() {
debug!("hook parent dropped before run loop was ready; stopping");
return;
}
loop {
match CFRunLoop::run_in_mode(
unsafe { kCFRunLoopDefaultMode },
std::time::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 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) };
}
pub(crate) fn stop(inner: HookInner) {
inner.run_loop.stop();
if let Err(e) = inner.thread.join() {
error!("hook thread panicked on shutdown: {e:?}");
}
}