use std::sync::OnceLock;
use accessibility_sys::{AXError, AXObserverRef, AXUIElementRef};
use objc2::MainThreadMarker;
use objc2::rc::{Retained, autoreleasepool};
use objc2_app_kit::{NSApplication, NSApplicationActivationPolicy, NSEventMask};
use objc2_core_foundation::CFString;
use objc2_foundation::{NSDate, NSDefaultRunLoopMode, NSProcessInfo};
use std::ffi::c_void;
use std::pin::Pin;
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;
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;
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: u8 {
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 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;
}
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();
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::new(CONFIGURATION_FILE.as_path())?;
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 pump_cocoa_event_loop(&mut self, timeout: f64) {
autoreleasepool(|_| {
let until_date = NSDate::dateWithTimeIntervalSinceNow(timeout);
while let Some(event) = unsafe {
self.cocoa_app
.nextEventMatchingMask_untilDate_inMode_dequeue(
NSEventMask::Any,
Some(&until_date),
NSDefaultRunLoopMode,
true, )
} {
self.cocoa_app.sendEvent(&event);
}
self.cocoa_app.updateWindows();
});
}
}
impl Modifiers {
pub fn matches(self, event: Modifiers) -> bool {
const GROUPS: [Modifiers; 4] = [
Modifiers::ALT,
Modifiers::SHIFT,
Modifiers::CMD,
Modifiers::CTRL,
];
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));
}
#[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));
}
#[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_four_groups() {
let all = Modifiers::ALT | Modifiers::SHIFT | Modifiers::CMD | Modifiers::CTRL;
assert!(
all.matches(Modifiers::LALT | Modifiers::RSHIFT | Modifiers::LCMD | Modifiers::RCTRL)
);
assert!(!all.matches(Modifiers::LALT | Modifiers::LSHIFT | Modifiers::LCMD));
}
}