use std::sync::OnceLock;
use accessibility_sys::{AXError, AXObserverRef, AXUIElementRef};
use objc2::MainThreadMarker;
use objc2::rc::{Retained, autoreleasepool};
use objc2_app_kit::{
NSApplication, NSApplicationActivationPolicy, NSEvent, NSEventMask, NSEventModifierFlags,
NSEventType,
};
use objc2_core_foundation::CFString;
use objc2_foundation::{NSDate, NSDefaultRunLoopMode, NSPoint, NSProcessInfo};
use std::ffi::{c_short, c_void};
use std::pin::Pin;
use std::ptr::NonNull;
use std::sync::atomic::{AtomicBool, Ordering};
use tracing::error;
use crate::config::{CONFIGURATION_FILE, Config};
use crate::errors::{Error, Result};
use crate::events::{Event, EventSender};
use crate::manager::{check_ax_privilege, check_separate_spaces};
use crate::platform::display::PinnedDisplayHandler;
use crate::platform::input::{PinnedInputHandler, TapHealth};
use crate::platform::notify::{NotifyHandler, PinnedNotifyHandler};
use crate::platform::process::PinnedProcessHandler;
use display::DisplayHandler;
use input::InputHandler;
use mission_control::MissionControlHandler;
use process::ProcessHandler;
pub use process::ProcessSerialNumber;
pub use workspace::WorkspaceObserver;
pub(crate) mod app_launcher;
mod display;
pub(crate) mod input;
mod mission_control;
pub mod notify;
mod process;
pub mod service;
mod workspace;
pub type OSStatus = i32;
pub type WinID = i32;
pub type ConnID = i64;
pub type Pid = i32;
pub type CFStringRef = *const CFString;
pub type WorkspaceId = u64;
bitflags::bitflags! {
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Modifiers: u16 {
const LALT = 1 << 0;
const RALT = 1 << 1;
const LSHIFT = 1 << 2;
const RSHIFT = 1 << 3;
const LCMD = 1 << 4;
const RCMD = 1 << 5;
const LCTRL = 1 << 6;
const RCTRL = 1 << 7;
const FN = 1 << 8;
const ALT = Self::LALT.bits() | Self::RALT.bits();
const SHIFT = Self::LSHIFT.bits() | Self::RSHIFT.bits();
const CMD = Self::LCMD.bits() | Self::RCMD.bits();
const CTRL = Self::LCTRL.bits() | Self::RCTRL.bits();
}
}
type AXObserverCallback = unsafe extern "C" fn(
observer: AXObserverRef,
element: AXUIElementRef,
notification: CFStringRef,
refcon: *mut c_void,
);
unsafe extern "C" {
pub fn AXObserverCreate(
application: Pid,
callback: AXObserverCallback,
out_observer: &mut AXObserverRef,
) -> AXError;
pub fn AXObserverAddNotification(
observer: AXObserverRef,
element: AXUIElementRef,
notification: &CFString,
refcon: *mut c_void,
) -> AXError;
pub fn AXObserverRemoveNotification(
observer: AXObserverRef,
element: AXUIElementRef,
notification: &CFString,
) -> AXError;
}
const WAKE_EVENT_SUBTYPE: c_short = 0x7061;
#[derive(Debug)]
pub struct EventLoopWaker {
pending: AtomicBool,
app: OnceLock<NonNull<NSApplication>>,
}
unsafe impl Send for EventLoopWaker {}
unsafe impl Sync for EventLoopWaker {}
impl EventLoopWaker {
pub fn new() -> Self {
Self {
pending: AtomicBool::new(false),
app: OnceLock::new(),
}
}
fn install(&self, app: &NSApplication) {
_ = self.app.set(NonNull::from(app));
}
pub fn wake(&self) {
let Some(app) = self.app.get() else {
return;
};
if self.pending.swap(true, Ordering::AcqRel) {
return;
}
let Some(event) = NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
NSEventType::ApplicationDefined,
NSPoint::ZERO,
NSEventModifierFlags::empty(),
0.0,
0,
None,
WAKE_EVENT_SUBTYPE,
0,
0,
) else {
self.pending.store(false, Ordering::Release);
error!("unable to create wake-up event");
return;
};
let app = unsafe { app.as_ref() };
app.postEvent_atStart(&event, false);
}
fn rearm(&self) {
self.pending.store(false, Ordering::Release);
}
fn is_wake_event(event: &NSEvent) -> bool {
event.r#type() == NSEventType::ApplicationDefined && event.subtype().0 == WAKE_EVENT_SUBTYPE
}
}
impl Default for EventLoopWaker {
fn default() -> Self {
Self::new()
}
}
pub struct PlatformCallbacks {
pub main_thread_marker: MainThreadMarker,
cocoa_app: Retained<NSApplication>,
events: EventSender,
process_handler: Option<PinnedProcessHandler>,
event_handler: Option<PinnedInputHandler>,
workspace_observer: Retained<WorkspaceObserver>,
mission_control_observer: MissionControlHandler,
display_handler: Option<PinnedDisplayHandler>,
notify_handler: Option<PinnedNotifyHandler>,
}
impl PlatformCallbacks {
pub fn new(events: EventSender) -> Pin<Box<Self>> {
let main_thread_marker = MainThreadMarker::new().unwrap();
let cocoa_app = NSApplication::sharedApplication(main_thread_marker);
cocoa_app.setActivationPolicy(NSApplicationActivationPolicy::Accessory);
cocoa_app.finishLaunching();
NSApplication::load();
events.waker().install(&cocoa_app);
let workspace_observer = WorkspaceObserver::new(events.clone());
Box::pin(PlatformCallbacks {
main_thread_marker,
cocoa_app,
process_handler: None,
event_handler: None,
workspace_observer,
mission_control_observer: MissionControlHandler::new(events.clone()),
display_handler: None,
notify_handler: None,
events,
})
}
pub fn setup_handlers(&mut self) -> Result<()> {
if !check_ax_privilege() {
return Err(Error::PermissionDenied(
"Accessibility permissions are required. Please enable them in System Preferences -> Security & Privacy -> Privacy -> Accessibility.".to_string(),
));
}
if !check_separate_spaces() {
error!("Option 'display has separate spaces' disabled.");
return Err(Error::InvalidConfig(
"Option 'display has separate spaces' disabled.".to_string(),
));
}
let config = Config::load(CONFIGURATION_FILE.as_deref())?;
self.events.send(Event::InitialConfig(config.clone()))?;
self.event_handler = Some(InputHandler::new(self.events.clone(), config).start()?);
self.notify_handler = Some(NotifyHandler::new(self.events.clone()).start()?);
self.display_handler = Some(DisplayHandler::new(self.events.clone()).start()?);
self.process_handler = Some(
ProcessHandler::new(self.events.clone(), self.workspace_observer.clone()).start()?,
);
self.mission_control_observer.observe()?;
self.workspace_observer.start();
self.events.send(Event::ProcessesLoaded)
}
pub fn ensure_input_tap_alive(&mut self) -> TapHealth {
let Some(handler) = self.event_handler.as_mut() else {
return TapHealth::Failed;
};
unsafe { handler.as_mut().get_unchecked_mut() }.ensure_tap_alive()
}
pub fn pump_cocoa_event_loop(&mut self, timeout: f64) -> bool {
self.events.waker().rearm();
autoreleasepool(|_| {
let mut deadline = NSDate::dateWithTimeIntervalSinceNow(timeout);
let mut dispatched = false;
while let Some(event) = unsafe {
self.cocoa_app
.nextEventMatchingMask_untilDate_inMode_dequeue(
NSEventMask::Any,
Some(&deadline),
NSDefaultRunLoopMode,
true, )
} {
deadline = NSDate::distantPast();
if EventLoopWaker::is_wake_event(&event) {
continue;
}
self.cocoa_app.sendEvent(&event);
dispatched = true;
}
if dispatched {
self.cocoa_app.updateWindows();
}
dispatched
})
}
}
impl Modifiers {
pub fn matches(self, event: Modifiers) -> bool {
const GROUPS: [Modifiers; 5] = [
Modifiers::ALT,
Modifiers::SHIFT,
Modifiers::CMD,
Modifiers::CTRL,
Modifiers::FN,
];
for group in GROUPS {
let bind_group = self & group;
let event_group = event & group;
if bind_group.is_empty() {
if !event_group.is_empty() {
return false;
}
} else {
if (bind_group & event_group).is_empty() {
return false;
}
if event_group | bind_group != bind_group {
return false;
}
}
}
true
}
}
pub fn macos_major_version() -> u32 {
static VERSION: OnceLock<u32> = OnceLock::new();
*VERSION.get_or_init(|| {
let version = NSProcessInfo::processInfo().operatingSystemVersion();
u32::try_from(version.majorVersion).unwrap_or(16)
})
}
#[cfg(test)]
mod tests {
use super::Modifiers;
#[test]
fn macos_major_version_returns_valid() {
let v = super::macos_major_version();
assert!(v >= 13, "expected macOS 13+, got {v}");
}
#[test]
fn matches_empty_binding_requires_no_modifiers() {
assert!(Modifiers::empty().matches(Modifiers::empty()));
}
#[test]
fn matches_empty_binding_rejects_any_modifier() {
assert!(!Modifiers::empty().matches(Modifiers::LALT));
assert!(!Modifiers::empty().matches(Modifiers::RSHIFT));
assert!(!Modifiers::empty().matches(Modifiers::LCMD));
assert!(!Modifiers::empty().matches(Modifiers::RCTRL));
assert!(!Modifiers::empty().matches(Modifiers::FN));
}
#[test]
fn matches_group_binding_accepts_either_side() {
let alt_cmd = Modifiers::ALT | Modifiers::CMD;
assert!(alt_cmd.matches(Modifiers::LALT | Modifiers::LCMD));
assert!(alt_cmd.matches(Modifiers::RALT | Modifiers::RCMD));
assert!(alt_cmd.matches(Modifiers::LALT | Modifiers::RCMD));
}
#[test]
fn matches_group_binding_requires_all_groups() {
let alt_cmd = Modifiers::ALT | Modifiers::CMD;
assert!(!alt_cmd.matches(Modifiers::LALT));
assert!(!alt_cmd.matches(Modifiers::LCMD));
assert!(!alt_cmd.matches(Modifiers::LALT | Modifiers::LCMD | Modifiers::LSHIFT));
}
#[test]
fn matches_rejects_extra_unlisted_groups() {
let want_alt = Modifiers::ALT;
assert!(!want_alt.matches(Modifiers::LALT | Modifiers::LSHIFT));
assert!(!want_alt.matches(Modifiers::LALT | Modifiers::LCTRL));
assert!(!want_alt.matches(Modifiers::LALT | Modifiers::FN));
}
#[test]
fn matches_specific_side_does_not_match_other_side() {
let left_alt = Modifiers::LALT;
assert!(left_alt.matches(Modifiers::LALT));
assert!(!left_alt.matches(Modifiers::RALT));
}
#[test]
fn matches_specific_side_rejects_both_sides_at_once() {
assert!(!Modifiers::RCMD.matches(Modifiers::LCMD | Modifiers::RCMD));
assert!(!Modifiers::LCTRL.matches(Modifiers::LCTRL | Modifiers::RCTRL));
}
#[test]
fn matches_right_specific_chord_rejects_left_modifier_in_same_group() {
let east = Modifiers::RCMD | Modifiers::RCTRL | Modifiers::RSHIFT | Modifiers::RALT;
assert!(
east.matches(Modifiers::RCMD | Modifiers::RCTRL | Modifiers::RSHIFT | Modifiers::RALT)
);
assert!(!east.matches(
Modifiers::LCMD
| Modifiers::RCMD
| Modifiers::RCTRL
| Modifiers::RSHIFT
| Modifiers::RALT
));
}
#[test]
fn matches_all_five_groups() {
let all =
Modifiers::ALT | Modifiers::SHIFT | Modifiers::CMD | Modifiers::CTRL | Modifiers::FN;
assert!(all.matches(
Modifiers::LALT
| Modifiers::RSHIFT
| Modifiers::LCMD
| Modifiers::RCTRL
| Modifiers::FN
));
assert!(
!all.matches(Modifiers::LALT | Modifiers::LSHIFT | Modifiers::LCMD | Modifiers::FN)
);
}
#[test]
fn matches_fn_modifier() {
let want_fn = Modifiers::FN;
assert!(want_fn.matches(Modifiers::FN));
assert!(!want_fn.matches(Modifiers::empty()));
assert!(!want_fn.matches(Modifiers::LALT | Modifiers::FN));
}
#[test]
fn matches_fn_combined_with_other_groups() {
let fn_alt = Modifiers::FN | Modifiers::ALT;
assert!(fn_alt.matches(Modifiers::FN | Modifiers::LALT));
assert!(fn_alt.matches(Modifiers::FN | Modifiers::RALT));
assert!(!fn_alt.matches(Modifiers::FN));
assert!(!fn_alt.matches(Modifiers::LALT));
assert!(!fn_alt.matches(Modifiers::FN | Modifiers::LALT | Modifiers::LSHIFT));
}
}