use std::sync::{Arc, mpsc};
use std::thread;
use core_foundation::runloop::{
CFRunLoop, CFRunLoopRunResult, kCFRunLoopCommonModes, kCFRunLoopDefaultMode,
};
use core_graphics::event::{
CGEvent, CGEventTap, CGEventTapLocation, CGEventTapOptions, CGEventTapPlacement,
CGEventTapProxy, CGEventType, CallbackResult, EventField,
};
use tracing::{debug, error, warn};
use crate::{ButtonId, 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;
}
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 cocoa::base::{id, nil};
use cocoa::foundation::{NSAutoreleasePool, NSString};
use objc::{class, msg_send, sel, sel_impl};
unsafe {
let pool = NSAutoreleasePool::new(nil);
let workspace: id = msg_send![class!(NSWorkspace), sharedWorkspace];
if workspace == nil {
let _: () = msg_send![pool, drain];
return None;
}
let app: id = msg_send![workspace, frontmostApplication];
if app == nil {
let _: () = msg_send![pool, drain];
return None;
}
let bundle_id: id = msg_send![app, bundleIdentifier];
if bundle_id == nil {
let _: () = msg_send![pool, drain];
return None;
}
let ptr: *const std::os::raw::c_char = NSString::UTF8String(bundle_id);
let result = if ptr.is_null() {
None
} else {
std::ffi::CStr::from_ptr(ptr)
.to_str()
.ok()
.map(str::to_owned)
};
let _: () = msg_send![pool, drain];
result
}
}
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> {
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 = event.get_double_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_1);
let dx = event.get_double_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_2);
#[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,
})
}
CGEventType::TapDisabledByTimeout | CGEventType::TapDisabledByUserInput => {
error!(
"CGEventTap disabled by OS (type={etype:?}); \
hook will stop receiving events until re-enabled"
);
None
}
_ => None,
}
}
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,
];
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;
}
}
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:?}");
}
}