use core_foundation::array::{CFArray, CFArrayRef};
use core_foundation::base::{CFTypeRef, TCFType};
use core_foundation::string::CFString;
use std::collections::hash_map::DefaultHasher;
use std::hash::{Hash, Hasher};
use std::ptr;
use std::sync::OnceLock;
use std::time::{Duration, Instant};
use crate::accessibility::{get_attribute, AXUIElementRef};
use crate::error::AXResult;
const ESPRESSOMAC_SERVICE_NAME: &str = "com.apple.EspressoMac.xpc";
const ESPRESSOMAC_SELECTOR_IDLE: &str = "isIdle";
#[allow(dead_code)]
const ESPRESSOMAC_SELECTOR_WAIT: &str = "waitForIdle";
#[repr(C)]
struct xpc_object_s {
_private: [u8; 0],
}
#[allow(non_camel_case_types)]
type xpc_connection_t = *mut xpc_object_s;
#[allow(non_camel_case_types)]
type xpc_object_t = *mut xpc_object_s;
#[allow(non_camel_case_types)]
type xpc_handler_t = *const fn(xpc_object_t);
#[allow(improper_ctypes, dead_code)]
extern "C" {
fn xpc_connection_create_mach_service(
name: *const i8,
target_queue: *const std::ffi::c_void,
flags: u64,
) -> xpc_connection_t;
fn xpc_connection_set_event_handler(connection: xpc_connection_t, handler: xpc_handler_t);
fn xpc_connection_resume(connection: xpc_connection_t);
fn xpc_connection_send_message_with_reply_sync(
connection: xpc_connection_t,
message: xpc_object_t,
) -> xpc_object_t;
fn xpc_connection_cancel(connection: xpc_connection_t);
fn xpc_release(object: xpc_object_t);
fn xpc_dictionary_create(
keys: *const *const i8,
values: *const xpc_object_t,
count: usize,
) -> xpc_object_t;
fn xpc_dictionary_get_bool(dict: xpc_object_t, key: *const i8) -> bool;
fn xpc_dictionary_get_int64(dict: xpc_object_t, key: *const i8) -> i64;
fn xpc_string_create(string: *const i8) -> xpc_object_t;
fn xpc_int64_create(value: i64) -> xpc_object_t;
fn xpc_bool_create(value: bool) -> xpc_object_t;
fn xpc_get_type(object: xpc_object_t) -> *const std::ffi::c_void;
fn xpc_dictionary_get_value(dict: xpc_object_t, key: *const i8) -> xpc_object_t;
}
const XPC_TYPE_ERROR_SENTINEL: usize = 1;
#[allow(dead_code)]
pub struct EspressoMacClient {
connection: Option<xpc_connection_t>,
pid: i32,
}
impl EspressoMacClient {
#[must_use]
pub fn connect(pid: i32) -> Option<Self> {
unsafe {
let service_name = format!("{ESPRESSOMAC_SERVICE_NAME}.{pid}");
let service_cstr = std::ffi::CString::new(service_name).ok()?;
let connection =
xpc_connection_create_mach_service(service_cstr.as_ptr(), ptr::null(), 0);
if connection.is_null() {
return None;
}
extern "C" fn event_handler(_event: xpc_object_t) {
}
let handler: xpc_handler_t = event_handler as *const fn(xpc_object_t);
xpc_connection_set_event_handler(connection, handler);
xpc_connection_resume(connection);
let test_msg = Self::create_message("ping", &[]);
let reply = xpc_connection_send_message_with_reply_sync(connection, test_msg);
xpc_release(test_msg);
let reply_type = xpc_get_type(reply);
let is_error = reply_type as usize == XPC_TYPE_ERROR_SENTINEL;
xpc_release(reply);
if is_error {
xpc_connection_cancel(connection);
xpc_release(connection as xpc_object_t);
return None;
}
Some(Self {
connection: Some(connection),
pid,
})
}
}
#[must_use]
pub fn is_idle(&self) -> bool {
let Some(connection) = self.connection else {
return false;
};
unsafe {
let msg = Self::create_message(ESPRESSOMAC_SELECTOR_IDLE, &[]);
let reply = xpc_connection_send_message_with_reply_sync(connection, msg);
xpc_release(msg);
let idle_key = std::ffi::CString::new("idle").unwrap();
let idle = xpc_dictionary_get_bool(reply, idle_key.as_ptr());
xpc_release(reply);
idle
}
}
pub async fn wait_for_idle(&self, timeout: Duration) -> bool {
let start = Instant::now();
let poll_interval = Duration::from_millis(10);
while start.elapsed() < timeout {
if self.is_idle() {
return true;
}
tokio::time::sleep(poll_interval).await;
}
false
}
fn create_message(selector: &str, args: &[(&str, MessageValue)]) -> xpc_object_t {
unsafe {
let mut keys: Vec<*const i8> = Vec::new();
let mut values: Vec<xpc_object_t> = Vec::new();
let selector_key = std::ffi::CString::new("selector").unwrap();
let selector_value = std::ffi::CString::new(selector).unwrap_or_default();
keys.push(selector_key.as_ptr());
values.push(xpc_string_create(selector_value.as_ptr()));
for (key, value) in args {
let key_cstr = std::ffi::CString::new(*key).unwrap_or_default();
keys.push(key_cstr.as_ptr());
values.push(value.to_xpc_object());
}
let dict = xpc_dictionary_create(keys.as_ptr(), values.as_ptr(), keys.len());
for value in values {
xpc_release(value);
}
dict
}
}
}
impl Drop for EspressoMacClient {
fn drop(&mut self) {
if let Some(connection) = self.connection.take() {
unsafe {
xpc_connection_cancel(connection);
xpc_release(connection as xpc_object_t);
}
}
}
}
unsafe impl Send for EspressoMacClient {}
unsafe impl Sync for EspressoMacClient {}
#[allow(dead_code)]
enum MessageValue {
Bool(bool),
Int(i64),
String(String),
}
impl MessageValue {
fn to_xpc_object(&self) -> xpc_object_t {
unsafe {
match self {
MessageValue::Bool(b) => xpc_bool_create(*b),
MessageValue::Int(i) => xpc_int64_create(*i),
MessageValue::String(s) => {
let cstr = std::ffi::CString::new(s.as_str()).unwrap_or_default();
xpc_string_create(cstr.as_ptr())
}
}
}
}
}
pub struct HeuristicSync {
pid: i32,
app_element: AXUIElementRef,
}
impl HeuristicSync {
#[must_use]
pub fn new(pid: i32, element: AXUIElementRef) -> Self {
Self {
pid,
app_element: element,
}
}
#[must_use]
pub fn wait_for_stable(&self, timeout: Duration) -> bool {
let start = Instant::now();
let mut stable_count = 0;
let mut last_hash = 0u64;
let poll_interval = Duration::from_millis(50);
while start.elapsed() < timeout {
let current_hash = self.hash_tree();
if current_hash == last_hash {
stable_count += 1;
if stable_count >= 3 {
return true;
}
} else {
stable_count = 0;
last_hash = current_hash;
}
std::thread::sleep(poll_interval);
}
false
}
#[must_use]
pub fn hash_tree(&self) -> u64 {
let mut hasher = DefaultHasher::new();
self.pid.hash(&mut hasher);
self.hash_element(self.app_element, &mut hasher, 0);
hasher.finish()
}
fn hash_element(&self, element: AXUIElementRef, hasher: &mut DefaultHasher, depth: usize) {
const MAX_DEPTH: usize = 20;
if depth >= MAX_DEPTH {
return;
}
if let Ok(role) = self.get_string_attribute(element, "AXRole") {
role.hash(hasher);
}
if let Ok(title) = self.get_string_attribute(element, "AXTitle") {
title.hash(hasher);
}
if let Ok(identifier) = self.get_string_attribute(element, "AXIdentifier") {
identifier.hash(hasher);
}
if let Ok(position) = self.get_position(element) {
let (x, y) = position;
(x as i32).hash(hasher);
(y as i32).hash(hasher);
}
if let Ok(size) = self.get_size(element) {
let (w, h) = size;
(w as i32).hash(hasher);
(h as i32).hash(hasher);
}
if let Ok(children) = self.get_children(element) {
children.len().hash(hasher);
for child in children {
self.hash_element(child, hasher, depth + 1);
}
}
}
fn get_string_attribute(&self, element: AXUIElementRef, attribute: &str) -> AXResult<String> {
let value = get_attribute(element, attribute)?;
unsafe {
let cf_string = CFString::wrap_under_get_rule(value.cast());
Ok(cf_string.to_string())
}
}
fn get_position(&self, element: AXUIElementRef) -> AXResult<(f64, f64)> {
let _value = get_attribute(element, "AXPosition")?;
Ok((0.0, 0.0))
}
fn get_size(&self, element: AXUIElementRef) -> AXResult<(f64, f64)> {
let _value = get_attribute(element, "AXSize")?;
Ok((0.0, 0.0))
}
fn get_children(&self, element: AXUIElementRef) -> AXResult<Vec<AXUIElementRef>> {
let value = get_attribute(element, "AXChildren")?;
unsafe {
let cf_array = CFArray::<CFTypeRef>::wrap_under_get_rule(value as CFArrayRef);
let mut children: Vec<AXUIElementRef> = Vec::new();
for i in 0..cf_array.len() {
if let Some(child_ref) = cf_array.get(i) {
let child_ptr: AXUIElementRef = *child_ref;
children.push(child_ptr);
}
}
Ok(children)
}
}
}
unsafe impl Send for HeuristicSync {}
unsafe impl Sync for HeuristicSync {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum SyncMode {
XPC,
Heuristic,
#[default]
Auto,
}
pub struct SyncEngine {
mode: SyncMode,
xpc: Option<EspressoMacClient>,
heuristic: HeuristicSync,
}
impl SyncEngine {
#[must_use]
pub fn new(pid: i32, element: AXUIElementRef) -> Self {
let xpc: Option<EspressoMacClient> = None;
let mode = if xpc.is_some() {
SyncMode::XPC
} else {
SyncMode::Heuristic
};
Self {
mode,
xpc,
heuristic: HeuristicSync::new(pid, element),
}
}
#[must_use]
pub fn with_mode(pid: i32, element: AXUIElementRef, mode: SyncMode) -> Self {
let xpc: Option<EspressoMacClient> = None;
let _ = mode;
Self {
mode,
xpc,
heuristic: HeuristicSync::new(pid, element),
}
}
#[must_use]
pub fn wait_for_idle(&self, timeout: Duration) -> bool {
static RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
let runtime = RUNTIME.get_or_init(|| {
tokio::runtime::Runtime::new().expect("Failed to create tokio runtime")
});
match self.mode {
SyncMode::XPC => {
if let Some(ref xpc) = self.xpc {
runtime.block_on(xpc.wait_for_idle(timeout))
} else {
self.heuristic.wait_for_stable(timeout)
}
}
SyncMode::Heuristic => self.heuristic.wait_for_stable(timeout),
SyncMode::Auto => {
if let Some(ref xpc) = self.xpc {
runtime.block_on(xpc.wait_for_idle(timeout))
} else {
self.heuristic.wait_for_stable(timeout)
}
}
}
}
#[must_use]
pub fn is_idle(&self) -> bool {
match self.mode {
SyncMode::XPC => self.xpc.as_ref().is_some_and(EspressoMacClient::is_idle),
SyncMode::Heuristic => {
let hash1 = self.heuristic.hash_tree();
std::thread::sleep(Duration::from_millis(100));
let hash2 = self.heuristic.hash_tree();
hash1 == hash2
}
SyncMode::Auto => {
if let Some(ref xpc) = self.xpc {
xpc.is_idle()
} else {
let hash1 = self.heuristic.hash_tree();
std::thread::sleep(Duration::from_millis(100));
let hash2 = self.heuristic.hash_tree();
hash1 == hash2
}
}
}
}
#[must_use]
pub fn mode(&self) -> SyncMode {
self.mode
}
#[must_use]
pub fn has_xpc(&self) -> bool {
self.xpc.is_some()
}
}
unsafe impl Send for SyncEngine {}
unsafe impl Sync for SyncEngine {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_sync_mode_default() {
assert_eq!(SyncMode::default(), SyncMode::Auto);
}
#[test]
fn test_message_value_bool() {
unsafe {
let msg = MessageValue::Bool(true);
let obj = msg.to_xpc_object();
assert!(!obj.is_null());
xpc_release(obj);
}
}
#[test]
fn test_message_value_int() {
unsafe {
let msg = MessageValue::Int(42);
let obj = msg.to_xpc_object();
assert!(!obj.is_null());
xpc_release(obj);
}
}
#[test]
fn test_message_value_string() {
unsafe {
let msg = MessageValue::String("test".to_string());
let obj = msg.to_xpc_object();
assert!(!obj.is_null());
xpc_release(obj);
}
}
#[test]
fn test_xpc_message_creation() {
let msg = EspressoMacClient::create_message("test", &[]);
assert!(!msg.is_null());
unsafe {
xpc_release(msg);
}
}
#[test]
fn test_xpc_message_with_args() {
let msg = EspressoMacClient::create_message(
"test",
&[
("key1", MessageValue::Bool(true)),
("key2", MessageValue::Int(123)),
("key3", MessageValue::String("value".to_string())),
],
);
assert!(!msg.is_null());
unsafe {
xpc_release(msg);
}
}
#[test]
#[ignore = "XPC calls with non-existent service cause SIGBUS"]
fn test_espressomac_client_connect_no_service() {
let client = EspressoMacClient::connect(99999);
assert!(client.is_none());
}
mod heuristic_tests {
use super::*;
fn mock_element() -> AXUIElementRef {
std::ptr::null()
}
#[test]
fn test_heuristic_sync_creation() {
let sync = HeuristicSync::new(1234, mock_element());
assert_eq!(sync.pid, 1234);
}
#[test]
fn test_heuristic_hash_stable() {
let sync = HeuristicSync::new(1234, mock_element());
let hash1 = sync.hash_tree();
let hash2 = sync.hash_tree();
assert_eq!(hash1, hash2);
}
#[test]
fn test_heuristic_wait_for_stable_timeout() {
let sync = HeuristicSync::new(1234, mock_element());
let timeout = Duration::from_millis(100);
let start = Instant::now();
let _stable = sync.wait_for_stable(timeout);
let elapsed = start.elapsed();
assert!(elapsed <= timeout + Duration::from_millis(50));
}
}
mod sync_engine_tests {
use super::*;
fn mock_element() -> AXUIElementRef {
std::ptr::null()
}
#[test]
#[ignore = "Requires real AXUIElement - null pointer causes SIGBUS"]
fn test_sync_engine_creation() {
let engine = SyncEngine::new(1234, mock_element());
assert_eq!(engine.mode(), SyncMode::Heuristic);
assert!(!engine.has_xpc());
}
#[test]
#[ignore = "Requires real AXUIElement - null pointer causes SIGBUS"]
fn test_sync_engine_explicit_mode_heuristic() {
let engine = SyncEngine::with_mode(1234, mock_element(), SyncMode::Heuristic);
assert_eq!(engine.mode(), SyncMode::Heuristic);
assert!(!engine.has_xpc());
}
#[test]
#[ignore = "Requires real AXUIElement - null pointer causes SIGBUS"]
fn test_sync_engine_explicit_mode_auto() {
let engine = SyncEngine::with_mode(1234, mock_element(), SyncMode::Auto);
assert_eq!(engine.mode(), SyncMode::Auto);
}
#[test]
#[ignore = "Requires real AXUIElement - null pointer causes SIGBUS"]
fn test_sync_engine_wait_for_idle_heuristic() {
let engine = SyncEngine::with_mode(1234, mock_element(), SyncMode::Heuristic);
let timeout = Duration::from_millis(100);
let start = Instant::now();
let _idle = engine.wait_for_idle(timeout);
let elapsed = start.elapsed();
assert!(elapsed <= timeout + Duration::from_millis(100));
}
#[test]
#[ignore = "Requires real AXUIElement - null pointer causes SIGBUS"]
fn test_sync_engine_is_idle_heuristic() {
let engine = SyncEngine::with_mode(1234, mock_element(), SyncMode::Heuristic);
let start = Instant::now();
let _idle = engine.is_idle();
let elapsed = start.elapsed();
assert!(elapsed >= Duration::from_millis(90));
assert!(elapsed <= Duration::from_millis(200));
}
}
mod integration_tests {
use super::*;
#[test]
#[ignore] fn test_real_app_xpc_connection() {
let pid = std::env::var("TEST_APP_PID")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
let client = EspressoMacClient::connect(pid);
println!("XPC connection available: {}", client.is_some());
}
#[test]
#[ignore] fn test_real_app_heuristic_sync() {
use crate::accessibility::create_application_element;
let pid = std::env::var("TEST_APP_PID")
.ok()
.and_then(|s| s.parse().ok())
.unwrap_or(1);
if let Ok(element) = create_application_element(pid) {
let sync = HeuristicSync::new(pid, element);
let hash1 = sync.hash_tree();
std::thread::sleep(Duration::from_millis(100));
let hash2 = sync.hash_tree();
println!(
"Hash stability: {} == {} = {}",
hash1,
hash2,
hash1 == hash2
);
} else {
println!("Accessibility not enabled or app not found");
}
}
}
}