From c02cb5ecb7314f053d63937437e5f5ba48de3209 Mon Sep 17 00:00:00 2001 From: Agent-Mouses Date: Tue, 2 Jun 2026 12:51:49 +0800 Subject: [PATCH] fix(macos): guard CFArray casts with type-ID check (fixes Mail.app crash) (#50) * fix(macos): guard CFArray casts with type-ID check AXUIElementCopyAttributeValue and CopyMultipleAttributeValues can return non-array CF types for attributes that are normally arrays (observed with Mail.app on macOS 26.3 beta). Casting the raw CFTypeRef to CFArray without verifying the type ID is undefined behaviour and can cause a panic with 'entered unreachable code' when the resulting fake array is iterated. Add CFGetTypeID / CFArrayGetTypeID guards in copy_ax_array and fetch_node_attrs before the unsafe casts. On mismatch, release the value and fall back gracefully (None / per-attribute fallback path). Fixes #49 * refactor: extract fetch_node_attrs_slow, remove inline comments * fix: harden macos accessibility inventory * fix: guard null ax element refs * fix: harden macos app inventory fallbacks * test: cover macos inventory edge cases * fix: keep macos launch in background * fix: tighten macos app fallback matching * fix: harden macos app inventory fallbacks * refactor: simplify macos inventory cleanup --------- Co-authored-by: Agent-Mouses Co-authored-by: Lahfir Co-authored-by: Lahfir <70215676+lahfir@users.noreply.github.com> --- crates/macos/src/actions/ax_helpers.rs | 27 +- crates/macos/src/cf_type.rs | 136 +++++++ crates/macos/src/lib.rs | 1 + crates/macos/src/system/app_inventory.rs | 123 ++++++ .../macos/src/system/app_inventory_tests.rs | 142 +++++++ crates/macos/src/system/app_list.rs | 356 +----------------- crates/macos/src/system/app_list_tests.rs | 64 ---- crates/macos/src/system/app_ops.rs | 11 +- crates/macos/src/system/app_ops_tests.rs | 6 + crates/macos/src/system/cg_window.rs | 102 +++++ crates/macos/src/system/key_dispatch.rs | 10 +- crates/macos/src/system/mod.rs | 5 + crates/macos/src/system/process_apps.rs | 73 ++++ crates/macos/src/system/process_apps_tests.rs | 53 +++ crates/macos/src/system/screenshot.rs | 71 +--- crates/macos/src/system/window_inventory.rs | 256 +++++++++++++ .../src/system/window_inventory_tests.rs | 249 ++++++++++++ crates/macos/src/system/window_list.rs | 185 +-------- crates/macos/src/system/workspace_apps.rs | 136 +++++++ .../macos/src/system/workspace_apps_tests.rs | 28 ++ crates/macos/src/tree/ax_value.rs | 79 ++++ crates/macos/src/tree/builder.rs | 43 ++- crates/macos/src/tree/capabilities.rs | 9 +- crates/macos/src/tree/element.rs | 107 +++--- crates/macos/src/tree/mod.rs | 3 + crates/macos/src/tree/node_attrs.rs | 37 ++ src/cli_args_system.rs | 2 +- 27 files changed, 1533 insertions(+), 781 deletions(-) create mode 100644 crates/macos/src/cf_type.rs create mode 100644 crates/macos/src/system/app_inventory.rs create mode 100644 crates/macos/src/system/app_inventory_tests.rs delete mode 100644 crates/macos/src/system/app_list_tests.rs create mode 100644 crates/macos/src/system/app_ops_tests.rs create mode 100644 crates/macos/src/system/cg_window.rs create mode 100644 crates/macos/src/system/process_apps.rs create mode 100644 crates/macos/src/system/process_apps_tests.rs create mode 100644 crates/macos/src/system/window_inventory.rs create mode 100644 crates/macos/src/system/window_inventory_tests.rs create mode 100644 crates/macos/src/system/workspace_apps.rs create mode 100644 crates/macos/src/system/workspace_apps_tests.rs create mode 100644 crates/macos/src/tree/ax_value.rs create mode 100644 crates/macos/src/tree/node_attrs.rs diff --git a/crates/macos/src/actions/ax_helpers.rs b/crates/macos/src/actions/ax_helpers.rs index b8976d2..d4b654c 100644 --- a/crates/macos/src/actions/ax_helpers.rs +++ b/crates/macos/src/actions/ax_helpers.rs @@ -5,16 +5,11 @@ mod imp { use super::*; use crate::tree::AXElement; use accessibility_sys::{ - AXUIElementCopyActionNames, AXUIElementIsAttributeSettable, AXUIElementPerformAction, - AXUIElementSetAttributeValue, AXUIElementSetMessagingTimeout, kAXErrorAPIDisabled, - kAXErrorCannotComplete, kAXErrorSuccess, kAXFocusedAttribute, kAXValueAttribute, - }; - use core_foundation::{ - array::CFArray, - base::{CFType, TCFType}, - boolean::CFBoolean, - string::CFString, + AXUIElementIsAttributeSettable, AXUIElementPerformAction, AXUIElementSetAttributeValue, + AXUIElementSetMessagingTimeout, kAXErrorAPIDisabled, kAXErrorCannotComplete, + kAXErrorSuccess, kAXFocusedAttribute, kAXValueAttribute, }; + use core_foundation::{base::TCFType, boolean::CFBoolean, string::CFString}; use std::os::raw::c_uchar; pub(crate) fn try_ax_action(el: &AXElement, name: &str) -> bool { @@ -105,19 +100,7 @@ mod imp { } pub(crate) fn list_ax_actions(el: &AXElement) -> Vec { - let mut actions_ref: core_foundation_sys::array::CFArrayRef = std::ptr::null(); - let err = unsafe { AXUIElementCopyActionNames(el.0, &mut actions_ref) }; - if err != kAXErrorSuccess || actions_ref.is_null() { - return Vec::new(); - } - let actions: CFArray = unsafe { TCFType::wrap_under_create_rule(actions_ref) }; - let mut result = Vec::with_capacity(actions.len() as usize); - for i in 0..actions.len() { - if let Some(name) = actions.get(i).and_then(|v| v.downcast::()) { - result.push(name.to_string()); - } - } - result + crate::tree::capabilities::copy_action_names(el) } pub(crate) fn has_ax_action(el: &AXElement, target: &str) -> bool { diff --git a/crates/macos/src/cf_type.rs b/crates/macos/src/cf_type.rs new file mode 100644 index 0000000..5f6b3dc --- /dev/null +++ b/crates/macos/src/cf_type.rs @@ -0,0 +1,136 @@ +#[cfg(target_os = "macos")] +mod imp { + use core_foundation::{ + array::CFArray, + base::{CFType, CFTypeRef, TCFType}, + dictionary::CFDictionary, + number::CFNumber, + string::CFString, + }; + use core_foundation_sys::{ + array::CFArrayGetTypeID, + base::{CFGetTypeID, CFRelease}, + dictionary::CFDictionaryGetTypeID, + number::CFNumberGetTypeID, + string::CFStringGetTypeID, + }; + + /// Takes ownership of a non-null +1 create-rule reference and releases mismatched values. + pub(crate) fn created_cf_array(value: CFTypeRef) -> Option> { + if value.is_null() { + return None; + } + if !matches_cf_type(value, unsafe { CFArrayGetTypeID() }) { + unsafe { CFRelease(value) }; + return None; + } + Some(unsafe { CFArray::::wrap_under_create_rule(value as _) }) + } + + pub(crate) fn borrowed_cf_dictionary( + value: CFTypeRef, + ) -> Option> { + if matches_cf_type(value, unsafe { CFDictionaryGetTypeID() }) { + Some(unsafe { CFDictionary::::wrap_under_get_rule(value as _) }) + } else { + None + } + } + + pub(crate) fn borrowed_cf_number(value: CFTypeRef) -> Option { + if matches_cf_type(value, unsafe { CFNumberGetTypeID() }) { + Some(unsafe { CFNumber::wrap_under_get_rule(value as _) }) + } else { + None + } + } + + pub(crate) fn borrowed_cf_string(value: CFTypeRef) -> Option { + if matches_cf_type(value, unsafe { CFStringGetTypeID() }) { + Some(unsafe { CFString::wrap_under_get_rule(value as _) }) + } else { + None + } + } + + fn matches_cf_type(value: CFTypeRef, expected: core_foundation_sys::base::CFTypeID) -> bool { + !value.is_null() && unsafe { CFGetTypeID(value) } == expected + } + + #[cfg(test)] + mod tests { + use super::*; + use core_foundation::base::CFRetain; + + #[test] + fn created_array_rejects_null() { + assert!(created_cf_array(std::ptr::null()).is_none()); + } + + #[test] + fn created_array_rejects_created_non_array_ref() { + let value = CFString::new("not-array"); + let retained = unsafe { CFRetain(value.as_CFTypeRef()) }; + + assert!(created_cf_array(retained).is_none()); + assert_eq!(value.to_string(), "not-array"); + } + + #[test] + fn created_array_wraps_created_array_ref() { + let value = CFString::new("item"); + let refs = [value.as_concrete_TypeRef()]; + let array = CFArray::from_copyable(&refs); + let retained = unsafe { CFRetain(array.as_CFTypeRef()) }; + + let wrapped = created_cf_array(retained).expect("array should wrap"); + + assert_eq!(wrapped.len(), 1); + } + + #[test] + fn borrowed_string_rejects_non_string_ref() { + let number = CFNumber::from(7); + + assert!(borrowed_cf_string(number.as_CFTypeRef()).is_none()); + } + + #[test] + fn borrowed_dictionary_accepts_dictionary_ref() { + let key = CFString::new("key"); + let value = CFString::new("value"); + let dict = CFDictionary::from_CFType_pairs(&[(key.as_CFType(), value.as_CFType())]); + + assert!(borrowed_cf_dictionary(dict.as_CFTypeRef()).is_some()); + } + + #[test] + fn borrowed_dictionary_rejects_non_dictionary_ref() { + let value = CFString::new("not-dictionary"); + + assert!(borrowed_cf_dictionary(value.as_CFTypeRef()).is_none()); + } + + #[test] + fn borrowed_number_accepts_number_ref() { + let value = CFNumber::from(7); + + assert_eq!( + borrowed_cf_number(value.as_CFTypeRef()).and_then(|n| n.to_i64()), + Some(7) + ); + } + + #[test] + fn borrowed_number_rejects_non_number_ref() { + let value = CFString::new("not-number"); + + assert!(borrowed_cf_number(value.as_CFTypeRef()).is_none()); + } + } +} + +#[cfg(target_os = "macos")] +pub(crate) use imp::{ + borrowed_cf_dictionary, borrowed_cf_number, borrowed_cf_string, created_cf_array, +}; diff --git a/crates/macos/src/lib.rs b/crates/macos/src/lib.rs index a5ac7f0..811f0c9 100644 --- a/crates/macos/src/lib.rs +++ b/crates/macos/src/lib.rs @@ -1,5 +1,6 @@ mod actions; mod adapter; +mod cf_type; mod input; mod notifications; mod system; diff --git a/crates/macos/src/system/app_inventory.rs b/crates/macos/src/system/app_inventory.rs new file mode 100644 index 0000000..5e67d44 --- /dev/null +++ b/crates/macos/src/system/app_inventory.rs @@ -0,0 +1,123 @@ +use agent_desktop_core::{ + adapter::WindowFilter, + node::{AppInfo, WindowInfo}, +}; + +use crate::system::{process_apps, window_inventory, workspace_apps}; + +pub(crate) fn list_apps() -> Vec { + let visible = window_inventory::visible_apps(); + let workspace = workspace_apps::list_apps(); + let process = process_apps::list_apps(); + tracing::debug!( + workspace_count = workspace.len(), + visible_count = visible.len(), + process_count = process.len(), + "system: app inventory sources" + ); + let mut apps = list_apps_from_sources(workspace, visible, process); + if apps.is_empty() { + let fallback = window_inventory::visible_apps(); + tracing::debug!( + fallback_count = fallback.len(), + "system: app inventory visible-window fallback" + ); + merge_apps(&mut apps, fallback); + } + sort_apps(&mut apps); + apps +} + +pub(crate) fn list_windows(filter: &WindowFilter) -> Vec { + window_inventory::list_windows(filter, |app_name, visible_apps| { + app_for_name_from_sources( + app_name, + workspace_apps::list_apps(), + visible_apps, + process_apps::list_apps, + ) + }) +} + +pub(crate) fn pid_for_app_name(app_name: &str) -> Option { + app_for_name(app_name).map(|app| app.pid) +} + +pub(crate) fn app_for_name(app_name: &str) -> Option { + app_for_name_from_sources( + app_name, + workspace_apps::list_apps(), + &window_inventory::visible_apps(), + process_apps::list_apps, + ) +} + +fn app_for_name_from_sources( + app_name: &str, + workspace: Vec, + visible: &[AppInfo], + process: impl FnOnce() -> Vec, +) -> Option { + let primary = merge_primary_sources(workspace, visible.to_vec()); + find_app_with_process_fallback(&primary, process, app_name) +} + +fn list_apps_from_sources( + workspace: Vec, + visible: Vec, + process: Vec, +) -> Vec { + let mut apps = merge_primary_sources(workspace, visible); + merge_apps(&mut apps, process); + apps +} + +fn merge_primary_sources(workspace: Vec, visible: Vec) -> Vec { + let mut apps = workspace; + merge_apps(&mut apps, visible); + apps +} + +fn find_app_with_process_fallback( + primary: &[AppInfo], + process: impl FnOnce() -> Vec, + app_name: &str, +) -> Option { + find_app_in_apps(primary, app_name).or_else(|| find_app_in_apps(&process(), app_name)) +} + +fn merge_apps(apps: &mut Vec, incoming: Vec) { + let mut seen_pids = apps + .iter() + .map(|app| app.pid) + .collect::>(); + + for app in incoming { + if seen_pids.insert(app.pid) { + apps.push(app); + } else if let Some(existing) = apps.iter_mut().find(|existing| existing.pid == app.pid) { + if existing.bundle_id.is_none() { + existing.bundle_id = app.bundle_id; + } + } + } +} + +fn sort_apps(apps: &mut [AppInfo]) { + apps.sort_by(|a, b| { + a.name + .to_ascii_lowercase() + .cmp(&b.name.to_ascii_lowercase()) + .then_with(|| a.pid.cmp(&b.pid)) + }); +} + +fn find_app_in_apps(apps: &[AppInfo], app_name: &str) -> Option { + apps.iter() + .find(|app| app.name.eq_ignore_ascii_case(app_name)) + .cloned() +} + +#[cfg(test)] +#[path = "app_inventory_tests.rs"] +mod tests; diff --git a/crates/macos/src/system/app_inventory_tests.rs b/crates/macos/src/system/app_inventory_tests.rs new file mode 100644 index 0000000..0b9d59f --- /dev/null +++ b/crates/macos/src/system/app_inventory_tests.rs @@ -0,0 +1,142 @@ +use super::*; + +fn app(name: &str, pid: i32) -> AppInfo { + AppInfo { + name: name.to_string(), + pid, + bundle_id: None, + } +} + +fn app_with_bundle(name: &str, pid: i32, bundle_id: &str) -> AppInfo { + AppInfo { + name: name.to_string(), + pid, + bundle_id: Some(bundle_id.to_string()), + } +} + +#[test] +fn merge_apps_does_not_duplicate_same_pid_with_different_name() { + let mut apps = vec![app("Preview", 42)]; + + merge_apps(&mut apps, vec![app("Preview Helper", 42)]); + + assert_eq!(apps.len(), 1); + assert_eq!(apps[0].name, "Preview"); +} + +#[test] +fn merge_apps_adds_bundle_id_for_existing_pid() { + let mut apps = vec![app("Preview", 42)]; + + merge_apps( + &mut apps, + vec![app_with_bundle("Preview Helper", 42, "com.apple.Preview")], + ); + + assert_eq!(apps.len(), 1); + assert_eq!(apps[0].bundle_id.as_deref(), Some("com.apple.Preview")); +} + +#[test] +fn merge_apps_keeps_distinct_pids_with_same_name() { + let mut apps = vec![app("Terminal", 10)]; + + merge_apps(&mut apps, vec![app("Terminal", 11)]); + + assert_eq!(apps.len(), 2); + assert_eq!(apps[1].pid, 11); +} + +#[test] +fn find_app_in_apps_prefers_exact_case_insensitive_match() { + let apps = vec![app("Finder Helper", 10), app("Finder", 11)]; + + assert_eq!( + find_app_in_apps(&apps, "finder").map(|app| app.pid), + Some(11) + ); +} + +#[test] +fn find_app_in_apps_rejects_contains_match() { + let apps = vec![app("Mail Helper", 10), app("Docker Desktop", 11)]; + + assert!(find_app_in_apps(&apps, "Mail").is_none()); + assert!(find_app_in_apps(&apps, "Docker").is_none()); +} + +#[test] +fn find_app_with_process_fallback_uses_process_entries_after_primary_miss() { + let primary = vec![app("Finder", 10)]; + + assert_eq!( + find_app_with_process_fallback(&primary, || vec![app("Mail", 11)], "Mail") + .map(|app| app.pid), + Some(11) + ); +} + +#[test] +fn find_app_with_process_fallback_prefers_primary_entries() { + let primary = vec![app("Mail", 10)]; + let mut process_called = false; + + assert_eq!( + find_app_with_process_fallback( + &primary, + || { + process_called = true; + vec![app("Mail", 11)] + }, + "Mail" + ) + .map(|app| app.pid), + Some(10) + ); + assert!(!process_called); +} + +#[test] +fn find_app_with_process_fallback_does_not_cross_match_helpers() { + let primary = Vec::new(); + + assert!( + find_app_with_process_fallback(&primary, || vec![app("Mail Helper", 11)], "Mail").is_none() + ); +} + +#[test] +fn list_apps_from_sources_includes_process_apps_when_primary_has_entries() { + let apps = list_apps_from_sources(vec![app("Finder", 10)], Vec::new(), vec![app("Mail", 11)]); + + assert_eq!( + apps.iter().map(|app| app.name.as_str()).collect::>(), + vec!["Finder", "Mail"] + ); +} + +#[test] +fn app_for_name_from_sources_uses_visible_entries_without_process_lookup() { + let mut process_called = false; + let app = app_for_name_from_sources("Finder", Vec::new(), &[app("Finder", 10)], || { + process_called = true; + Vec::new() + }); + + assert_eq!(app.map(|app| app.pid), Some(10)); + assert!(!process_called); +} + +#[test] +fn sort_apps_orders_by_name_then_pid() { + let mut apps = vec![app("Terminal", 3), app("Finder", 2), app("Finder", 1)]; + + sort_apps(&mut apps); + + assert_eq!( + apps.iter().map(|app| app.pid).collect::>(), + vec![1, 2, 3] + ); +} diff --git a/crates/macos/src/system/app_list.rs b/crates/macos/src/system/app_list.rs index 208adbc..1641285 100644 --- a/crates/macos/src/system/app_list.rs +++ b/crates/macos/src/system/app_list.rs @@ -1,365 +1,15 @@ -use agent_desktop_core::{ - adapter::WindowFilter, - error::AdapterError, - node::{AppInfo, WindowInfo}, -}; -#[cfg(target_os = "macos")] -use core_foundation::base::TCFType; -#[cfg(target_os = "macos")] -use std::sync::OnceLock; -#[cfg(target_os = "macos")] -use std::time::Duration; - -#[cfg(target_os = "macos")] -const PS_TIMEOUT: Duration = Duration::from_secs(2); +use agent_desktop_core::{error::AdapterError, node::AppInfo}; pub fn list_apps_impl() -> Result, AdapterError> { #[cfg(target_os = "macos")] { - let mut apps = apps_from_cg_windows(); - let windows = crate::system::window_list::list_windows_impl(&WindowFilter { - focused_only: false, - app: None, - }) - .unwrap_or_default(); - merge_apps(&mut apps, apps_from_windows(windows)); - - merge_apps(&mut apps, running_apps_from_workspace()); - - merge_apps(&mut apps, running_apps_from_process_table()); - - apps.sort_by(|a, b| { - a.name - .to_ascii_lowercase() - .cmp(&b.name.to_ascii_lowercase()) - .then_with(|| a.pid.cmp(&b.pid)) - }); - Ok(apps) + Ok(crate::system::app_inventory::list_apps()) } #[cfg(not(target_os = "macos"))] Err(AdapterError::not_supported("list_apps")) } -pub fn apps_from_windows(windows: Vec) -> Vec { - let mut seen_pids = std::collections::HashSet::new(); - let mut apps = Vec::new(); - - for window in windows { - if seen_pids.insert(window.pid) { - apps.push(AppInfo { - name: window.app, - pid: window.pid, - bundle_id: None, - }); - } - } - - apps -} - #[cfg(target_os = "macos")] pub(crate) fn pid_for_app_name(app_name: &str) -> Option { - let apps = app_sources(); - find_pid_in_apps(&apps, app_name) + crate::system::app_inventory::pid_for_app_name(app_name) } - -fn find_pid_in_apps(apps: &[AppInfo], app_name: &str) -> Option { - let wanted = app_name.to_ascii_lowercase(); - apps.iter() - .find(|app| app.name.eq_ignore_ascii_case(app_name)) - .or_else(|| { - apps.iter() - .find(|app| app.name.to_ascii_lowercase().contains(&wanted)) - }) - .map(|app| app.pid) -} - -#[cfg(target_os = "macos")] -fn app_sources() -> Vec { - let mut apps = apps_from_cg_windows(); - merge_apps(&mut apps, running_apps_from_workspace()); - merge_apps(&mut apps, running_apps_from_process_table()); - apps -} - -#[cfg(target_os = "macos")] -fn running_apps_from_workspace() -> Vec { - use core_foundation::{base::TCFType, string::CFString}; - use std::ffi::c_void; - - type Id = *mut c_void; - type Class = *mut c_void; - type Sel = *mut c_void; - - #[link(name = "AppKit", kind = "framework")] - unsafe extern "C" { - fn objc_getClass(name: *const core::ffi::c_char) -> Class; - fn sel_registerName(name: *const core::ffi::c_char) -> Sel; - fn objc_msgSend(receiver: Id, sel: Sel, ...) -> Id; - } - - unsafe fn ns_string(id: Id) -> Option { - unsafe { - if id.is_null() { - return None; - } - Some( - CFString::wrap_under_get_rule(id as core_foundation_sys::string::CFStringRef) - .to_string(), - ) - } - } - - unsafe { - if !appkit_loaded() { - return Vec::new(); - } - - let workspace_cls = objc_getClass(c"NSWorkspace".as_ptr()); - if workspace_cls.is_null() { - return Vec::new(); - } - - let shared_sel = sel_registerName(c"sharedWorkspace".as_ptr()); - let send_class: unsafe extern "C" fn(Class, Sel) -> Id = - std::mem::transmute(objc_msgSend as *const c_void); - let workspace = send_class(workspace_cls, shared_sel); - if workspace.is_null() { - return Vec::new(); - } - - let running_sel = sel_registerName(c"runningApplications".as_ptr()); - let send_id: unsafe extern "C" fn(Id, Sel) -> Id = - std::mem::transmute(objc_msgSend as *const c_void); - let running = send_id(workspace, running_sel); - if running.is_null() { - return Vec::new(); - } - - let count_sel = sel_registerName(c"count".as_ptr()); - let send_count: unsafe extern "C" fn(Id, Sel) -> usize = - std::mem::transmute(objc_msgSend as *const c_void); - let count = send_count(running, count_sel); - - let object_sel = sel_registerName(c"objectAtIndex:".as_ptr()); - let send_object: unsafe extern "C" fn(Id, Sel, usize) -> Id = - std::mem::transmute(objc_msgSend as *const c_void); - let policy_sel = sel_registerName(c"activationPolicy".as_ptr()); - let send_policy: unsafe extern "C" fn(Id, Sel) -> isize = - std::mem::transmute(objc_msgSend as *const c_void); - let pid_sel = sel_registerName(c"processIdentifier".as_ptr()); - let send_pid: unsafe extern "C" fn(Id, Sel) -> i32 = - std::mem::transmute(objc_msgSend as *const c_void); - let name_sel = sel_registerName(c"localizedName".as_ptr()); - let bundle_sel = sel_registerName(c"bundleIdentifier".as_ptr()); - - let mut seen_pids = std::collections::HashSet::new(); - let mut apps = Vec::new(); - for idx in 0..count { - let app = send_object(running, object_sel, idx); - if app.is_null() || send_policy(app, policy_sel) != 0 { - continue; - } - - let pid = send_pid(app, pid_sel); - if pid <= 0 || !seen_pids.insert(pid) { - continue; - } - - let name = ns_string(send_id(app, name_sel)); - if let Some(name) = name { - apps.push(AppInfo { - name, - pid, - bundle_id: ns_string(send_id(app, bundle_sel)), - }); - } - } - - apps - } -} - -#[cfg(target_os = "macos")] -fn appkit_loaded() -> bool { - use std::ffi::c_void; - - type Id = *mut c_void; - - unsafe extern "C" { - fn dlopen(filename: *const core::ffi::c_char, flag: i32) -> Id; - } - - static APPKIT_LOADED: OnceLock = OnceLock::new(); - *APPKIT_LOADED.get_or_init(|| unsafe { - !dlopen( - c"/System/Library/Frameworks/AppKit.framework/AppKit".as_ptr(), - 1, - ) - .is_null() - }) -} - -fn merge_apps(apps: &mut Vec, incoming: Vec) { - let mut seen_pids = apps - .iter() - .map(|app| app.pid) - .collect::>(); - - for app in incoming { - if seen_pids.insert(app.pid) { - apps.push(app); - } else if let Some(existing) = apps.iter_mut().find(|existing| existing.pid == app.pid) { - if existing.bundle_id.is_none() { - existing.bundle_id = app.bundle_id; - } - } - } -} - -#[cfg(target_os = "macos")] -fn apps_from_cg_windows() -> Vec { - use core_foundation::{ - array::CFArray, - base::{CFType, CFTypeRef, TCFType}, - dictionary::CFDictionary, - string::CFString, - }; - - unsafe extern "C" { - fn CGWindowListCopyWindowInfo(option: u32, window_id: u32) -> CFTypeRef; - } - - let info_ref = unsafe { CGWindowListCopyWindowInfo(17, 0) }; - if info_ref.is_null() { - return Vec::new(); - } - - let array = unsafe { CFArray::::wrap_under_create_rule(info_ref as _) }; - let mut seen_pids = std::collections::HashSet::new(); - let mut apps = Vec::new(); - - for item in array.iter() { - let dict = unsafe { - CFDictionary::::wrap_under_get_rule(item.as_concrete_TypeRef() as _) - }; - let Some(layer) = cg_int_field(&dict, "kCGWindowLayer") else { - continue; - }; - if layer != 0 { - continue; - } - let Some(pid) = cg_int_field(&dict, "kCGWindowOwnerPID").map(|p| p as i32) else { - continue; - }; - if !seen_pids.insert(pid) { - continue; - } - let Some(name) = cg_string_field(&dict, "kCGWindowOwnerName") else { - continue; - }; - apps.push(AppInfo { - name, - pid, - bundle_id: None, - }); - } - - apps -} - -#[cfg(target_os = "macos")] -fn cg_int_field( - dict: &core_foundation::dictionary::CFDictionary< - core_foundation::string::CFString, - core_foundation::base::CFType, - >, - key: &str, -) -> Option { - let key = core_foundation::string::CFString::new(key); - dict.find(&key).and_then(|value| { - let number = unsafe { - core_foundation::number::CFNumber::wrap_under_get_rule(value.as_concrete_TypeRef() as _) - }; - number.to_i64() - }) -} - -#[cfg(target_os = "macos")] -fn cg_string_field( - dict: &core_foundation::dictionary::CFDictionary< - core_foundation::string::CFString, - core_foundation::base::CFType, - >, - key: &str, -) -> Option { - let key = core_foundation::string::CFString::new(key); - dict.find(&key).map(|value| unsafe { - core_foundation::string::CFString::wrap_under_get_rule(value.as_concrete_TypeRef() as _) - .to_string() - }) -} - -#[cfg(target_os = "macos")] -fn running_apps_from_process_table() -> Vec { - let mut command = std::process::Command::new("/bin/ps"); - command.args(["-axo", "pid=,comm="]); - let output = match crate::system::process::run_with_timeout(&mut command, "ps", PS_TIMEOUT) { - Ok(output) if output.status.success() => output, - Ok(_) | Err(_) => return Vec::new(), - }; - let text = String::from_utf8_lossy(&output.stdout); - let mut seen_pids = std::collections::HashSet::new(); - let mut apps = Vec::new(); - - for line in text.lines() { - let line = line.trim_start(); - let mut fields = line.splitn(2, char::is_whitespace); - let Some(pid_text) = fields.next() else { - continue; - }; - let Some(command) = fields.next().map(str::trim) else { - continue; - }; - let Ok(pid) = pid_text.parse::() else { - continue; - }; - let Some(name) = app_name_from_command(command) else { - continue; - }; - if seen_pids.insert(pid) { - apps.push(AppInfo { - name, - pid, - bundle_id: None, - }); - } - } - - apps -} - -#[cfg(target_os = "macos")] -fn app_name_from_command(command: &str) -> Option { - if command.contains("/Contents/Frameworks/") - || command.contains("/Contents/PlugIns/") - || command.contains("/XPCServices/") - || command.contains(".appex/") - { - return None; - } - - let marker = ".app/Contents/MacOS"; - let marker_start = command.find(marker)?; - let app_path = &command[..marker_start + ".app".len()]; - let app_name = app_path.rsplit('/').next()?.strip_suffix(".app")?; - if app_name.is_empty() { - None - } else { - Some(app_name.to_string()) - } -} - -#[cfg(test)] -#[path = "app_list_tests.rs"] -mod tests; diff --git a/crates/macos/src/system/app_list_tests.rs b/crates/macos/src/system/app_list_tests.rs deleted file mode 100644 index b38b6f0..0000000 --- a/crates/macos/src/system/app_list_tests.rs +++ /dev/null @@ -1,64 +0,0 @@ -use super::*; - -fn app(name: &str, pid: i32) -> AppInfo { - AppInfo { - name: name.to_string(), - pid, - bundle_id: None, - } -} - -fn app_with_bundle(name: &str, pid: i32, bundle_id: &str) -> AppInfo { - AppInfo { - name: name.to_string(), - pid, - bundle_id: Some(bundle_id.to_string()), - } -} - -#[test] -fn merge_apps_does_not_duplicate_same_pid_with_different_name() { - let mut apps = vec![app("Preview", 42)]; - - merge_apps(&mut apps, vec![app("Preview Helper", 42)]); - - assert_eq!(apps.len(), 1); - assert_eq!(apps[0].name, "Preview"); -} - -#[test] -fn merge_apps_adds_bundle_id_for_existing_pid() { - let mut apps = vec![app("Preview", 42)]; - - merge_apps( - &mut apps, - vec![app_with_bundle("Preview Helper", 42, "com.apple.Preview")], - ); - - assert_eq!(apps.len(), 1); - assert_eq!(apps[0].bundle_id.as_deref(), Some("com.apple.Preview")); -} - -#[test] -fn merge_apps_keeps_distinct_pids_with_same_name() { - let mut apps = vec![app("Terminal", 10)]; - - merge_apps(&mut apps, vec![app("Terminal", 11)]); - - assert_eq!(apps.len(), 2); - assert_eq!(apps[1].pid, 11); -} - -#[test] -fn find_pid_in_apps_prefers_exact_case_insensitive_match() { - let apps = vec![app("Finder Helper", 10), app("Finder", 11)]; - - assert_eq!(find_pid_in_apps(&apps, "finder"), Some(11)); -} - -#[test] -fn find_pid_in_apps_falls_back_to_contains_match() { - let apps = vec![app("Preview", 10), app("Docker Desktop", 11)]; - - assert_eq!(find_pid_in_apps(&apps, "Docker"), Some(11)); -} diff --git a/crates/macos/src/system/app_ops.rs b/crates/macos/src/system/app_ops.rs index 76507e5..adc09af 100644 --- a/crates/macos/src/system/app_ops.rs +++ b/crates/macos/src/system/app_ops.rs @@ -117,7 +117,7 @@ pub fn launch_app_impl(id: &str, timeout_ms: u64) -> Result Result [&str; 3] { + ["-g", "-a", id] +} + #[cfg(not(target_os = "macos"))] pub fn launch_app_impl(_id: &str, _timeout_ms: u64) -> Result { Err(AdapterError::not_supported("launch_app")) } +#[cfg(test)] +#[path = "app_ops_tests.rs"] +mod tests; + #[cfg(target_os = "macos")] pub fn close_app_impl(id: &str, force: bool) -> Result<(), AdapterError> { tracing::debug!("system: close app={id:?} force={force}"); diff --git a/crates/macos/src/system/app_ops_tests.rs b/crates/macos/src/system/app_ops_tests.rs new file mode 100644 index 0000000..ed598d6 --- /dev/null +++ b/crates/macos/src/system/app_ops_tests.rs @@ -0,0 +1,6 @@ +use super::*; + +#[test] +fn open_app_args_preserve_current_focus() { + assert_eq!(open_app_args("Mail"), ["-g", "-a", "Mail"]); +} diff --git a/crates/macos/src/system/cg_window.rs b/crates/macos/src/system/cg_window.rs new file mode 100644 index 0000000..1827236 --- /dev/null +++ b/crates/macos/src/system/cg_window.rs @@ -0,0 +1,102 @@ +use core_foundation::{base::CFType, dictionary::CFDictionary, string::CFString}; + +type WindowDictionary = CFDictionary; + +#[derive(Clone, Debug, PartialEq)] +pub(crate) struct WindowRecord { + pub(crate) app_name: String, + pub(crate) pid: i32, + pub(crate) title: Option, + pub(crate) window_number: i64, + pub(crate) area: f64, +} + +pub(crate) fn visible_window_records() -> Vec { + window_dictionaries() + .into_iter() + .filter_map(|dict| { + if int_field(&dict, "kCGWindowLayer")? != 0 { + return None; + } + + let pid = int_field(&dict, "kCGWindowOwnerPID")? as i32; + if pid <= 0 { + return None; + } + + let app_name = string_field(&dict, "kCGWindowOwnerName")?; + if app_name.is_empty() { + return None; + } + + Some(WindowRecord { + app_name, + pid, + title: string_field(&dict, "kCGWindowName").filter(|title| !title.is_empty()), + window_number: int_field(&dict, "kCGWindowNumber").unwrap_or(0), + area: area_field(&dict, "kCGWindowBounds").unwrap_or(0.0), + }) + }) + .collect() +} + +fn window_dictionaries() -> Vec { + use crate::cf_type::borrowed_cf_dictionary; + use core_graphics::display::CGDisplay; + use core_graphics::window::{ + kCGWindowListExcludeDesktopElements, kCGWindowListOptionOnScreenOnly, + }; + + let options = kCGWindowListOptionOnScreenOnly | kCGWindowListExcludeDesktopElements; + let Some(array) = CGDisplay::window_list_info(options, None) else { + return Vec::new(); + }; + + array + .get_all_values() + .into_iter() + .filter_map(|raw| borrowed_cf_dictionary(raw as core_foundation::base::CFTypeRef)) + .collect() +} + +fn int_field(dict: &WindowDictionary, key: &str) -> Option { + use crate::cf_type::borrowed_cf_number; + use core_foundation::base::TCFType; + + let key = CFString::new(key); + dict.find(&key) + .and_then(|value| borrowed_cf_number(value.as_concrete_TypeRef())) + .and_then(|number| number.to_i64()) +} + +fn string_field(dict: &WindowDictionary, key: &str) -> Option { + use crate::cf_type::borrowed_cf_string; + use core_foundation::base::TCFType; + + let key = CFString::new(key); + dict.find(&key) + .and_then(|value| borrowed_cf_string(value.as_concrete_TypeRef())) + .map(|value| value.to_string()) +} + +fn area_field(dict: &WindowDictionary, key: &str) -> Option { + use crate::cf_type::borrowed_cf_dictionary; + use core_foundation::base::TCFType; + + let bounds = dict + .find(CFString::new(key)) + .and_then(|value| borrowed_cf_dictionary(value.as_concrete_TypeRef()))?; + let width = int_or_float_field(&bounds, "Width").unwrap_or(0.0); + let height = int_or_float_field(&bounds, "Height").unwrap_or(0.0); + Some(width * height) +} + +fn int_or_float_field(dict: &WindowDictionary, key: &str) -> Option { + use crate::cf_type::borrowed_cf_number; + use core_foundation::base::TCFType; + + let key = CFString::new(key); + dict.find(&key) + .and_then(|value| borrowed_cf_number(value.as_concrete_TypeRef())) + .and_then(|number| number.to_f64()) +} diff --git a/crates/macos/src/system/key_dispatch.rs b/crates/macos/src/system/key_dispatch.rs index 1b87681..ff28348 100644 --- a/crates/macos/src/system/key_dispatch.rs +++ b/crates/macos/src/system/key_dispatch.rs @@ -76,7 +76,7 @@ fn try_simple_key_action( fn get_focused_element( app_el: accessibility_sys::AXUIElementRef, ) -> Option { - use accessibility_sys::{AXUIElementCopyAttributeValue, AXUIElementRef, kAXErrorSuccess}; + use accessibility_sys::{AXUIElementCopyAttributeValue, kAXErrorSuccess}; use core_foundation::{base::TCFType, string::CFString}; let attr = CFString::new("AXFocusedUIElement"); @@ -86,7 +86,7 @@ fn get_focused_element( if err != kAXErrorSuccess || value.is_null() { return None; } - Some(crate::tree::AXElement(value as AXUIElementRef)) + crate::tree::ax_value::created_ax_element(value) } #[cfg(target_os = "macos")] @@ -138,7 +138,7 @@ fn try_menu_bar_shortcut( #[cfg(target_os = "macos")] fn read_menu_item_modifiers(el: &crate::tree::AXElement) -> u32 { use accessibility_sys::{AXUIElementCopyAttributeValue, kAXErrorSuccess}; - use core_foundation::{base::TCFType, number::CFNumber, string::CFString}; + use core_foundation::{base::TCFType, string::CFString}; let attr = CFString::new("AXMenuItemCmdModifiers"); let mut value: core_foundation_sys::base::CFTypeRef = std::ptr::null_mut(); @@ -148,8 +148,8 @@ fn read_menu_item_modifiers(el: &crate::tree::AXElement) -> u32 { return 0; } let cf = unsafe { core_foundation::base::CFType::wrap_under_create_rule(value) }; - cf.downcast::() - .and_then(|n| n.to_i64()) + crate::cf_type::borrowed_cf_number(cf.as_concrete_TypeRef()) + .and_then(|number| number.to_i64()) .map(|v| v as u32) .unwrap_or(0) } diff --git a/crates/macos/src/system/mod.rs b/crates/macos/src/system/mod.rs index 7eda061..f0750b5 100644 --- a/crates/macos/src/system/mod.rs +++ b/crates/macos/src/system/mod.rs @@ -1,9 +1,14 @@ +pub(crate) mod app_inventory; pub mod app_list; pub mod app_ops; +pub(crate) mod cg_window; pub mod key_dispatch; pub mod permissions; pub(crate) mod process; +pub(crate) mod process_apps; pub mod screenshot; pub mod wait; +pub(crate) mod window_inventory; pub mod window_list; pub mod window_ops; +pub(crate) mod workspace_apps; diff --git a/crates/macos/src/system/process_apps.rs b/crates/macos/src/system/process_apps.rs new file mode 100644 index 0000000..3c3cd94 --- /dev/null +++ b/crates/macos/src/system/process_apps.rs @@ -0,0 +1,73 @@ +use agent_desktop_core::node::AppInfo; +use std::time::Duration; + +const PS_TIMEOUT: Duration = Duration::from_secs(2); + +pub(crate) fn list_apps() -> Vec { + let mut command = std::process::Command::new("/bin/ps"); + command.args(["-axo", "pid=,comm="]); + let output = match crate::system::process::run_with_timeout(&mut command, "ps", PS_TIMEOUT) { + Ok(output) if output.status.success() => output, + Ok(output) => { + tracing::debug!(status = ?output.status, "system: ps app inventory failed"); + return Vec::new(); + } + Err(error) => { + tracing::debug!(message = %error.message, "system: ps app inventory failed"); + return Vec::new(); + } + }; + let text = String::from_utf8_lossy(&output.stdout); + let mut seen_pids = std::collections::HashSet::new(); + let mut apps = Vec::new(); + + for line in text.lines() { + let line = line.trim_start(); + let mut fields = line.splitn(2, char::is_whitespace); + let Some(pid_text) = fields.next() else { + continue; + }; + let Some(command) = fields.next().map(str::trim) else { + continue; + }; + let Ok(pid) = pid_text.parse::() else { + continue; + }; + let Some(name) = app_name_from_command(command) else { + continue; + }; + if seen_pids.insert(pid) { + apps.push(AppInfo { + name, + pid, + bundle_id: None, + }); + } + } + + apps +} + +fn app_name_from_command(command: &str) -> Option { + if command.contains("/Contents/Frameworks/") + || command.contains("/Contents/PlugIns/") + || command.contains("/XPCServices/") + || command.contains(".appex/") + { + return None; + } + + let marker = ".app/Contents/MacOS"; + let marker_start = command.find(marker)?; + let app_path = &command[..marker_start + ".app".len()]; + let app_name = app_path.rsplit('/').next()?.strip_suffix(".app")?; + if app_name.is_empty() { + None + } else { + Some(app_name.to_string()) + } +} + +#[cfg(test)] +#[path = "process_apps_tests.rs"] +mod tests; diff --git a/crates/macos/src/system/process_apps_tests.rs b/crates/macos/src/system/process_apps_tests.rs new file mode 100644 index 0000000..4ca7f38 --- /dev/null +++ b/crates/macos/src/system/process_apps_tests.rs @@ -0,0 +1,53 @@ +use super::*; + +#[test] +fn app_name_from_command_extracts_app_bundle_name() { + assert_eq!( + app_name_from_command("/Applications/Finder.app/Contents/MacOS/Finder").as_deref(), + Some("Finder") + ); +} + +#[test] +fn app_name_from_command_rejects_framework_helpers() { + assert_eq!( + app_name_from_command( + "/Applications/Foo.app/Contents/Frameworks/Foo Helper.app/Contents/MacOS/Foo Helper", + ), + None + ); +} + +#[test] +fn app_name_from_command_rejects_plugin_helpers() { + assert_eq!( + app_name_from_command( + "/Applications/Foo.app/Contents/PlugIns/Foo Plugin.app/Contents/MacOS/Foo Plugin", + ), + None + ); +} + +#[test] +fn app_name_from_command_rejects_xpc_services() { + assert_eq!( + app_name_from_command("/Applications/Foo.app/Contents/XPCServices/Worker.xpc/Worker"), + None + ); +} + +#[test] +fn app_name_from_command_rejects_app_extensions() { + assert_eq!( + app_name_from_command("/Applications/Foo.app/Contents/PlugIns/Share.appex/Share"), + None + ); +} + +#[test] +fn app_name_from_command_rejects_empty_app_name() { + assert_eq!( + app_name_from_command("/Applications/.app/Contents/MacOS/Foo"), + None + ); +} diff --git a/crates/macos/src/system/screenshot.rs b/crates/macos/src/system/screenshot.rs index 39b67e0..7f0ec6f 100644 --- a/crates/macos/src/system/screenshot.rs +++ b/crates/macos/src/system/screenshot.rs @@ -138,78 +138,17 @@ mod imp { } fn find_cg_window_id_for_pid(pid: i32) -> Option { - use core_foundation::{ - array::CFArray, - base::{CFType, CFTypeRef, TCFType}, - dictionary::CFDictionary, - number::CFNumber, - string::CFString, - }; - - unsafe extern "C" { - fn CGWindowListCopyWindowInfo(option: u32, window_id: u32) -> CFTypeRef; - } - - let info_ref = unsafe { CGWindowListCopyWindowInfo(17, 0) }; - if info_ref.is_null() { - return None; - } - - let array = unsafe { CFArray::::wrap_under_create_rule(info_ref as _) }; - let mut best_id: Option = None; let mut best_area: f64 = 0.0; - for item in array.iter() { - let dict = unsafe { - CFDictionary::::wrap_under_get_rule( - item.as_concrete_TypeRef() as _ - ) - }; - - let int_field = |key: &str| -> Option { - let k = CFString::new(key); - dict.find(&k).and_then(|v| { - let n = unsafe { CFNumber::wrap_under_get_rule(v.as_concrete_TypeRef() as _) }; - n.to_i32() - }) - }; - - if int_field("kCGWindowOwnerPID") != Some(pid) { - continue; - } - if int_field("kCGWindowLayer").unwrap_or(99) != 0 { + for record in crate::system::cg_window::visible_window_records() { + if record.pid != pid { continue; } - let wid = match int_field("kCGWindowNumber") { - Some(n) => n as u32, - None => continue, - }; - - let bounds_key = CFString::new("kCGWindowBounds"); - let area = if let Some(bounds_val) = dict.find(&bounds_key) { - let bounds_dict = unsafe { - CFDictionary::::wrap_under_get_rule( - bounds_val.as_concrete_TypeRef() as _, - ) - }; - let w = bounds_dict.find(CFString::new("Width")).and_then(|v| { - let n = unsafe { CFNumber::wrap_under_get_rule(v.as_concrete_TypeRef() as _) }; - n.to_f64() - }); - let h = bounds_dict.find(CFString::new("Height")).and_then(|v| { - let n = unsafe { CFNumber::wrap_under_get_rule(v.as_concrete_TypeRef() as _) }; - n.to_f64() - }); - w.unwrap_or(0.0) * h.unwrap_or(0.0) - } else { - 0.0 - }; - - if area > best_area { - best_area = area; - best_id = Some(wid); + if record.area > best_area { + best_area = record.area; + best_id = Some(record.window_number as u32); } } diff --git a/crates/macos/src/system/window_inventory.rs b/crates/macos/src/system/window_inventory.rs new file mode 100644 index 0000000..bdaf3c8 --- /dev/null +++ b/crates/macos/src/system/window_inventory.rs @@ -0,0 +1,256 @@ +use agent_desktop_core::{ + adapter::WindowFilter, + node::{AppInfo, WindowInfo}, +}; +use std::time::Duration; + +use crate::system::cg_window; + +pub(crate) fn visible_apps() -> Vec { + apps_from_window_records(&cg_window::visible_window_records()) +} + +fn apps_from_window_records(records: &[cg_window::WindowRecord]) -> Vec { + let mut seen_pids = std::collections::HashSet::new(); + let mut apps = Vec::new(); + + for record in records { + if !seen_pids.insert(record.pid) { + continue; + } + apps.push(AppInfo { + name: record.app_name.clone(), + pid: record.pid, + bundle_id: None, + }); + } + + apps +} + +pub(crate) fn list_windows( + filter: &WindowFilter, + app_for_name: impl FnMut(&str, &[AppInfo]) -> Option, +) -> Vec { + list_windows_with_sources( + filter, + app_for_name, + cg_window::visible_window_records, + ax_window_for_app, + std::thread::sleep, + ) +} + +fn list_windows_with_sources( + filter: &WindowFilter, + mut app_for_name: impl FnMut(&str, &[AppInfo]) -> Option, + mut visible_records: impl FnMut() -> Vec, + mut ax_window_for_app: impl FnMut(&AppInfo) -> Option, + mut sleep: impl FnMut(Duration), +) -> Vec { + let mut app = None; + let mut app_loaded = false; + + for attempt in 0..3 { + let records = visible_records(); + let windows = visible_windows_from_records(filter, &records); + if !windows.is_empty() { + return windows; + } + + if let Some(app_name) = filter.app.as_deref() { + if !app_loaded { + let visible_apps = apps_from_window_records(&records); + app = app_for_name(app_name, &visible_apps); + app_loaded = true; + } + if let Some(app) = app.as_ref() { + if let Some(window) = ax_window_for_app(app) { + if !filter.focused_only || window.is_focused { + return vec![window]; + } + } + } + } + + if attempt == 2 || !should_retry_empty(filter, app.as_ref()) { + break; + } + + sleep(Duration::from_millis(50)); + } + + Vec::new() +} + +fn visible_windows_from_records( + filter: &WindowFilter, + records: &[cg_window::WindowRecord], +) -> Vec { + let app_filter = filter.app.as_deref().unwrap_or("").to_ascii_lowercase(); + let candidates = records + .iter() + .filter(|record| matches_app_filter(&record.app_name, &app_filter)) + .cloned() + .collect(); + + windows_from_records(candidates, filter.focused_only) +} + +fn windows_from_records( + records: Vec, + focused_only: bool, +) -> Vec { + windows_from_records_with_focus(records, focused_only, focused_window_identity) +} + +fn windows_from_records_with_focus( + records: Vec, + focused_only: bool, + mut focused_identity: impl FnMut(i32) -> FocusedWindowIdentity, +) -> Vec { + let candidates: Vec<_> = records + .into_iter() + .map(|record| { + let title = record.title.unwrap_or_else(|| record.app_name.clone()); + (record.app_name, title, record.pid, record.window_number) + }) + .collect(); + let mut title_counts = std::collections::HashMap::new(); + for (_, title, pid, _) in &candidates { + *title_counts.entry((*pid, title.clone())).or_insert(0) += 1; + } + + let mut focus_cache = std::collections::HashMap::new(); + let mut windows = Vec::new(); + let mut focused_seen = false; + + for (app_name, title, pid, window_number) in candidates { + let title_count = title_counts + .get(&(pid, title.clone())) + .copied() + .unwrap_or(0); + let identity = focus_cache + .entry(pid) + .or_insert_with(|| focused_identity(pid)); + let is_focused = + !focused_seen && matches_focused_window(&title, window_number, identity, title_count); + if focused_only && !is_focused { + continue; + } + focused_seen |= is_focused; + + windows.push(WindowInfo { + id: format!("w-{window_number}"), + title, + app: app_name, + pid, + bounds: None, + is_focused, + }); + } + + windows +} + +fn matches_app_filter(app_name: &str, app_filter: &str) -> bool { + app_filter.is_empty() || app_name.eq_ignore_ascii_case(app_filter) +} + +fn should_retry_empty(filter: &WindowFilter, app: Option<&AppInfo>) -> bool { + filter.app.is_none() || app.is_some() +} + +fn ax_window_for_app(app_info: &AppInfo) -> Option { + let app = crate::tree::element_for_pid(app_info.pid); + let window = focused_window_element(&app) + .or_else(|| crate::tree::copy_element_attr(&app, "AXMainWindow")) + .or_else(|| { + crate::tree::copy_ax_array(&app, "AXWindows") + .and_then(|windows| windows.into_iter().next()) + })?; + if crate::tree::copy_string_attr(&window, "AXRole").as_deref() != Some("AXWindow") { + return None; + } + let title = + crate::tree::copy_string_attr(&window, "AXTitle").unwrap_or_else(|| app_info.name.clone()); + let window_number = crate::tree::copy_i64_attr(&window, "AXWindowNumber").unwrap_or(0); + let is_focused = crate::tree::copy_bool_attr(&app, "AXFrontmost") == Some(true); + Some(ax_window_info(app_info, title, window_number, is_focused)) +} + +fn ax_window_info( + app_info: &AppInfo, + title: String, + window_number: i64, + is_focused: bool, +) -> WindowInfo { + WindowInfo { + id: format!("w-{window_number}"), + title, + app: app_info.name.clone(), + pid: app_info.pid, + bounds: None, + is_focused, + } +} + +type FocusedWindowIdentity = Option<(Option, Option)>; + +fn focused_window_identity(pid: i32) -> FocusedWindowIdentity { + let app = crate::tree::element_for_pid(pid); + if crate::tree::copy_bool_attr(&app, "AXFrontmost") != Some(true) { + return None; + } + let window = focused_window_element(&app)?; + Some(( + crate::tree::copy_string_attr(&window, "AXTitle"), + crate::tree::copy_i64_attr(&window, "AXWindowNumber"), + )) +} + +fn matches_focused_window( + title: &str, + window_number: i64, + identity: &FocusedWindowIdentity, + same_title_count: usize, +) -> bool { + let Some((focused_title, focused_number)) = identity else { + return false; + }; + if let Some(number) = focused_number { + return *number == window_number; + } + focused_title.as_deref() == Some(title) && same_title_count == 1 +} + +fn focused_window_element(app: &crate::tree::AXElement) -> Option { + let focused = crate::tree::copy_element_attr(app, "AXFocusedWindow")?; + window_ancestor(focused, 4) +} + +fn window_ancestor( + mut element: crate::tree::AXElement, + max_depth: usize, +) -> Option { + for _ in 0..=max_depth { + if is_window_element(&element) { + return Some(element); + } + if let Some(window) = crate::tree::copy_element_attr(&element, "AXWindow") { + if is_window_element(&window) { + return Some(window); + } + } + element = crate::tree::copy_element_attr(&element, "AXParent")?; + } + None +} + +fn is_window_element(element: &crate::tree::AXElement) -> bool { + crate::tree::copy_string_attr(element, "AXRole").as_deref() == Some("AXWindow") +} + +#[cfg(test)] +#[path = "window_inventory_tests.rs"] +mod tests; diff --git a/crates/macos/src/system/window_inventory_tests.rs b/crates/macos/src/system/window_inventory_tests.rs new file mode 100644 index 0000000..d312763 --- /dev/null +++ b/crates/macos/src/system/window_inventory_tests.rs @@ -0,0 +1,249 @@ +use super::*; +use crate::system::cg_window::WindowRecord; + +#[test] +fn apps_from_window_records_deduplicates_by_pid() { + let apps = apps_from_window_records(&[ + record("Finder", 10, "Window 1", 1), + record("Finder", 10, "Window 2", 2), + ]); + + assert_eq!(apps.len(), 1); + assert_eq!(apps[0].name, "Finder"); +} + +#[test] +fn apps_from_window_records_keeps_same_name_with_distinct_pids() { + let apps = apps_from_window_records(&[ + record("Preview", 10, "Preview", 10), + record("Preview", 11, "Preview", 11), + ]); + + assert_eq!(apps.len(), 2); +} + +#[test] +fn matches_app_filter_accepts_exact_case_insensitive_name() { + assert!(matches_app_filter("Docker Desktop", "docker desktop")); + assert!(!matches_app_filter("Finder", "docker")); +} + +#[test] +fn matches_app_filter_rejects_substring_match() { + assert!(!matches_app_filter("Mail Helper", "mail")); +} + +#[test] +fn retry_empty_skips_known_missing_app_filter() { + let filter = WindowFilter { + app: Some("Missing".to_string()), + focused_only: false, + }; + + assert!(!should_retry_empty(&filter, None)); +} + +#[test] +fn retry_empty_allows_known_app_filter_for_ax_race() { + let filter = WindowFilter { + app: Some("Mail".to_string()), + focused_only: false, + }; + let app = app("Mail", 42); + + assert!(should_retry_empty(&filter, Some(&app))); +} + +#[test] +fn windows_from_records_marks_single_focused_window_once() { + let windows = windows_from_records_with_focus( + vec![ + record("Mail", 10, "Inbox", 1), + record("Mail", 10, "Inbox", 2), + ], + false, + |_| Some((Some("Inbox".to_string()), Some(2))), + ); + + assert!(!windows[0].is_focused); + assert!(windows[1].is_focused); +} + +#[test] +fn windows_from_records_focus_only_filters_unfocused_windows() { + let windows = windows_from_records_with_focus( + vec![ + record("Mail", 10, "Inbox", 1), + record("Mail", 10, "Sent", 2), + ], + true, + |_| Some((Some("Sent".to_string()), Some(2))), + ); + + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].title, "Sent"); +} + +#[test] +fn matches_focused_window_uses_window_number_when_available() { + let identity = Some((Some("Other".to_string()), Some(7))); + + assert!(matches_focused_window("Inbox", 7, &identity, 3)); + assert!(!matches_focused_window("Inbox", 8, &identity, 1)); +} + +#[test] +fn matches_focused_window_uses_unique_title_without_window_number() { + let identity = Some((Some("Inbox".to_string()), None)); + + assert!(matches_focused_window("Inbox", 0, &identity, 1)); + assert!(!matches_focused_window("Inbox", 0, &identity, 2)); + assert!(!matches_focused_window("Sent", 0, &identity, 1)); +} + +#[test] +fn list_windows_retries_after_unfocused_ax_fallback_for_focused_filter() { + let filter = WindowFilter { + app: Some("Mail".to_string()), + focused_only: true, + }; + let app = app("Mail", 42); + let mut visible_calls = 0; + let mut ax_calls = 0; + + let windows = list_windows_with_sources( + &filter, + |_, _| Some(app.clone()), + || { + visible_calls += 1; + Vec::new() + }, + |_| { + ax_calls += 1; + Some(window("Mail", 42, "Inbox", 7, ax_calls == 2)) + }, + |_| {}, + ); + + assert_eq!(windows.len(), 1); + assert_eq!(windows[0].title, "Inbox"); + assert_eq!(visible_calls, 2); + assert_eq!(ax_calls, 2); +} + +#[test] +fn list_windows_sleeps_between_unfocused_ax_fallback_retries() { + let filter = WindowFilter { + app: Some("Mail".to_string()), + focused_only: true, + }; + let app = app("Mail", 42); + let mut sleep_calls = 0; + + let windows = list_windows_with_sources( + &filter, + |_, _| Some(app.clone()), + Vec::new, + |_| Some(window("Mail", 42, "Inbox", 7, false)), + |_| sleep_calls += 1, + ); + + assert!(windows.is_empty()); + assert_eq!(sleep_calls, 2); +} + +#[test] +fn list_windows_without_app_filter_retries_empty_records() { + let filter = WindowFilter { + app: None, + focused_only: false, + }; + let mut visible_calls = 0; + let mut app_resolver_called = false; + + let windows = list_windows_with_sources( + &filter, + |_, _| { + app_resolver_called = true; + None + }, + || { + visible_calls += 1; + Vec::new() + }, + |_| None, + |_| {}, + ); + + assert!(windows.is_empty()); + assert_eq!(visible_calls, 3); + assert!(!app_resolver_called); +} + +#[test] +fn list_windows_stops_when_named_app_is_missing() { + let filter = WindowFilter { + app: Some("Missing".to_string()), + focused_only: true, + }; + let mut visible_calls = 0; + + let windows = list_windows_with_sources( + &filter, + |_, _| None, + || { + visible_calls += 1; + Vec::new() + }, + |_| None, + |_| {}, + ); + + assert!(windows.is_empty()); + assert_eq!(visible_calls, 1); +} + +#[test] +fn ax_window_info_uses_resolved_app_identity() { + let app = app("Mail", 42); + let window = ax_window_info(&app, "Inbox".to_string(), 7, true); + + assert_eq!(window.app, "Mail"); + assert_eq!(window.pid, 42); + assert_eq!(window.id, "w-7"); +} + +fn app(name: &str, pid: i32) -> AppInfo { + AppInfo { + name: name.to_string(), + pid, + bundle_id: None, + } +} + +fn record(app_name: &str, pid: i32, title: &str, window_number: i64) -> WindowRecord { + WindowRecord { + app_name: app_name.to_string(), + pid, + title: Some(title.to_string()), + window_number, + area: 100.0, + } +} + +fn window( + app_name: &str, + pid: i32, + title: &str, + window_number: i64, + is_focused: bool, +) -> WindowInfo { + WindowInfo { + id: format!("w-{window_number}"), + title: title.to_string(), + app: app_name.to_string(), + pid, + bounds: None, + is_focused, + } +} diff --git a/crates/macos/src/system/window_list.rs b/crates/macos/src/system/window_list.rs index 0e36f7e..5488ba6 100644 --- a/crates/macos/src/system/window_list.rs +++ b/crates/macos/src/system/window_list.rs @@ -3,14 +3,7 @@ use agent_desktop_core::{adapter::WindowFilter, error::AdapterError, node::Windo pub(crate) fn list_windows_impl(filter: &WindowFilter) -> Result, AdapterError> { #[cfg(target_os = "macos")] { - for attempt in 0..3 { - let windows = list_windows_once(filter); - if !windows.is_empty() || attempt == 2 { - return Ok(windows); - } - std::thread::sleep(std::time::Duration::from_millis(50)); - } - Ok(vec![]) + Ok(crate::system::app_inventory::list_windows(filter)) } #[cfg(not(target_os = "macos"))] { @@ -18,179 +11,3 @@ pub(crate) fn list_windows_impl(filter: &WindowFilter) -> Result Err(AdapterError::not_supported("list_windows")) } } - -#[cfg(target_os = "macos")] -fn list_windows_once(filter: &WindowFilter) -> Vec { - #[cfg(target_os = "macos")] - { - use core_foundation::base::{CFType, TCFType}; - use core_foundation::number::CFNumber; - use core_foundation::string::CFString; - use core_foundation_sys::dictionary::CFDictionaryGetValue; - use core_graphics::display::CGDisplay; - use core_graphics::window::{ - kCGWindowLayer, kCGWindowListOptionOnScreenOnly, kCGWindowName, kCGWindowNumber, - kCGWindowOwnerName, kCGWindowOwnerPID, - }; - use std::{collections::HashMap, ffi::c_void}; - - unsafe fn dict_string(dict: *const c_void, key: *const c_void) -> Option { - let val = unsafe { CFDictionaryGetValue(dict as _, key) }; - if val.is_null() { - return None; - } - unsafe { CFType::wrap_under_get_rule(val as _) } - .downcast::() - .map(|s| s.to_string()) - } - - unsafe fn dict_i64(dict: *const c_void, key: *const c_void) -> Option { - let val = unsafe { CFDictionaryGetValue(dict as _, key) }; - if val.is_null() { - return None; - } - unsafe { CFType::wrap_under_get_rule(val as _) } - .downcast::() - .and_then(|n| n.to_i64()) - } - - let arr = match CGDisplay::window_list_info(kCGWindowListOptionOnScreenOnly, None) { - Some(a) => a, - None => return vec![], - }; - - let app_filter = filter.app.as_deref().unwrap_or("").to_lowercase(); - let mut candidates = Vec::new(); - - for raw in arr.get_all_values() { - if raw.is_null() { - continue; - } - let layer = unsafe { dict_i64(raw, kCGWindowLayer as _) }.unwrap_or(99); - if layer != 0 { - continue; - } - - let app_name = match unsafe { dict_string(raw, kCGWindowOwnerName as _) } { - Some(n) if !n.is_empty() => n, - _ => continue, - }; - if !app_filter.is_empty() && !app_name.to_lowercase().contains(&app_filter) { - continue; - } - - let title = match unsafe { dict_string(raw, kCGWindowName as _) } { - Some(t) if !t.is_empty() => t, - _ => app_name.clone(), - }; - - let pid = unsafe { dict_i64(raw, kCGWindowOwnerPID as _) }.unwrap_or(0) as i32; - let window_number = unsafe { dict_i64(raw, kCGWindowNumber as _) }.unwrap_or(0); - - candidates.push((app_name, title, pid, window_number)); - } - - let mut title_counts: HashMap<(i32, String), usize> = HashMap::new(); - for (_, title, pid, _) in &candidates { - *title_counts.entry((*pid, title.clone())).or_insert(0) += 1; - } - - let mut focus_cache: HashMap = HashMap::new(); - let mut windows = Vec::new(); - let mut focused_seen = false; - - for (app_name, title, pid, window_number) in candidates { - let title_count = title_counts - .get(&(pid, title.clone())) - .copied() - .unwrap_or(0); - let identity = focus_cache - .entry(pid) - .or_insert_with(|| focused_window_identity(pid)); - let is_focused = !focused_seen - && matches_focused_window(&title, window_number, identity, title_count); - if filter.focused_only && !is_focused { - continue; - } - focused_seen |= is_focused; - - windows.push(WindowInfo { - id: format!("w-{window_number}"), - title, - app: app_name, - pid, - bounds: None, - is_focused, - }); - } - if windows.is_empty() { - if let Some(app_name) = filter.app.as_deref() { - if let Some(window) = ax_window_for_app(app_name) { - if !filter.focused_only || window.is_focused { - windows.push(window); - } - } - } - } - windows - } -} - -#[cfg(target_os = "macos")] -fn ax_window_for_app(app_name: &str) -> Option { - let pid = crate::system::app_list::pid_for_app_name(app_name)?; - let app = crate::tree::element_for_pid(pid); - let window = crate::tree::copy_element_attr(&app, "AXFocusedWindow") - .or_else(|| crate::tree::copy_element_attr(&app, "AXMainWindow")) - .or_else(|| { - crate::tree::copy_ax_array(&app, "AXWindows") - .and_then(|windows| windows.into_iter().next()) - })?; - if crate::tree::copy_string_attr(&window, "AXRole").as_deref() != Some("AXWindow") { - return None; - } - let title = - crate::tree::copy_string_attr(&window, "AXTitle").unwrap_or_else(|| app_name.into()); - let window_number = crate::tree::copy_i64_attr(&window, "AXWindowNumber").unwrap_or(0); - let is_focused = crate::tree::copy_bool_attr(&app, "AXFrontmost") == Some(true); - Some(WindowInfo { - id: format!("w-{window_number}"), - title, - app: app_name.to_string(), - pid, - bounds: None, - is_focused, - }) -} - -#[cfg(target_os = "macos")] -type FocusedWindowIdentity = Option<(Option, Option)>; - -#[cfg(target_os = "macos")] -fn focused_window_identity(pid: i32) -> FocusedWindowIdentity { - let app = crate::tree::element_for_pid(pid); - if crate::tree::copy_bool_attr(&app, "AXFrontmost") != Some(true) { - return None; - } - let window = crate::tree::copy_element_attr(&app, "AXFocusedWindow")?; - Some(( - crate::tree::copy_string_attr(&window, "AXTitle"), - crate::tree::copy_i64_attr(&window, "AXWindowNumber"), - )) -} - -#[cfg(target_os = "macos")] -fn matches_focused_window( - title: &str, - window_number: i64, - identity: &FocusedWindowIdentity, - same_title_count: usize, -) -> bool { - let Some((focused_title, focused_number)) = identity else { - return false; - }; - if let Some(number) = focused_number { - return *number == window_number; - } - focused_title.as_deref() == Some(title) && same_title_count == 1 -} diff --git a/crates/macos/src/system/workspace_apps.rs b/crates/macos/src/system/workspace_apps.rs new file mode 100644 index 0000000..30a5141 --- /dev/null +++ b/crates/macos/src/system/workspace_apps.rs @@ -0,0 +1,136 @@ +use agent_desktop_core::node::AppInfo; +use core_foundation::base::CFTypeRef; +use std::{ffi::c_void, sync::OnceLock}; + +type Id = *mut c_void; +type Class = *mut c_void; +type Sel = *mut c_void; +const RTLD_LAZY: i32 = 1; + +struct AutoreleasePool(Option); + +impl AutoreleasePool { + unsafe fn new() -> Self { + let pool = unsafe { objc_autoreleasePoolPush() }; + Self((!pool.is_null()).then_some(pool)) + } +} + +impl Drop for AutoreleasePool { + fn drop(&mut self) { + if let Some(pool) = self.0 { + unsafe { objc_autoreleasePoolPop(pool) }; + } + } +} + +pub(crate) fn list_apps() -> Vec { + if !appkit_loaded() { + return Vec::new(); + } + + unsafe { + let _pool = AutoreleasePool::new(); + let workspace_cls = objc_getClass(c"NSWorkspace".as_ptr()); + if workspace_cls.is_null() { + return Vec::new(); + } + + let shared_sel = sel_registerName(c"sharedWorkspace".as_ptr()); + let send_class: unsafe extern "C" fn(Class, Sel) -> Id = + std::mem::transmute(objc_msgSend as *const c_void); + let workspace = send_class(workspace_cls, shared_sel); + if workspace.is_null() { + return Vec::new(); + } + + let running_sel = sel_registerName(c"runningApplications".as_ptr()); + let send_id: unsafe extern "C" fn(Id, Sel) -> Id = + std::mem::transmute(objc_msgSend as *const c_void); + let running = send_id(workspace, running_sel); + if running.is_null() { + return Vec::new(); + } + + apps_from_running_array(running, send_id) + } +} + +fn appkit_loaded() -> bool { + static APPKIT_LOADED: OnceLock = OnceLock::new(); + *APPKIT_LOADED.get_or_init(|| unsafe { + !dlopen( + c"/System/Library/Frameworks/AppKit.framework/AppKit".as_ptr(), + RTLD_LAZY, + ) + .is_null() + }) +} + +fn apps_from_running_array( + running: Id, + send_id: unsafe extern "C" fn(Id, Sel) -> Id, +) -> Vec { + unsafe { + let count_sel = sel_registerName(c"count".as_ptr()); + let send_count: unsafe extern "C" fn(Id, Sel) -> usize = + std::mem::transmute(objc_msgSend as *const c_void); + let count = send_count(running, count_sel); + + let object_sel = sel_registerName(c"objectAtIndex:".as_ptr()); + let send_object: unsafe extern "C" fn(Id, Sel, usize) -> Id = + std::mem::transmute(objc_msgSend as *const c_void); + let policy_sel = sel_registerName(c"activationPolicy".as_ptr()); + let send_policy: unsafe extern "C" fn(Id, Sel) -> isize = + std::mem::transmute(objc_msgSend as *const c_void); + let pid_sel = sel_registerName(c"processIdentifier".as_ptr()); + let send_pid: unsafe extern "C" fn(Id, Sel) -> i32 = + std::mem::transmute(objc_msgSend as *const c_void); + let name_sel = sel_registerName(c"localizedName".as_ptr()); + let bundle_sel = sel_registerName(c"bundleIdentifier".as_ptr()); + + let mut seen_pids = std::collections::HashSet::new(); + let mut apps = Vec::new(); + for idx in 0..count { + let app = send_object(running, object_sel, idx); + if app.is_null() || send_policy(app, policy_sel) != 0 { + continue; + } + + let pid = send_pid(app, pid_sel); + if pid <= 0 || !seen_pids.insert(pid) { + continue; + } + + if let Some(name) = ns_string(send_id(app, name_sel)) { + apps.push(AppInfo { + name, + pid, + bundle_id: ns_string(send_id(app, bundle_sel)), + }); + } + } + + apps + } +} + +unsafe fn ns_string(id: Id) -> Option { + if id.is_null() { + return None; + } + crate::cf_type::borrowed_cf_string(id as CFTypeRef).map(|value| value.to_string()) +} + +unsafe extern "C" { + fn objc_autoreleasePoolPush() -> Id; + fn objc_autoreleasePoolPop(pool: Id); + fn dlopen(filename: *const core::ffi::c_char, flag: i32) -> Id; + fn objc_getClass(name: *const core::ffi::c_char) -> Class; + fn sel_registerName(name: *const core::ffi::c_char) -> Sel; + fn objc_msgSend(receiver: Id, sel: Sel, ...) -> Id; +} + +#[cfg(test)] +#[path = "workspace_apps_tests.rs"] +mod tests; diff --git a/crates/macos/src/system/workspace_apps_tests.rs b/crates/macos/src/system/workspace_apps_tests.rs new file mode 100644 index 0000000..4424c7c --- /dev/null +++ b/crates/macos/src/system/workspace_apps_tests.rs @@ -0,0 +1,28 @@ +use super::*; +use core_foundation::{base::TCFType, number::CFNumber, string::CFString}; + +#[test] +fn ns_string_rejects_null() { + assert_eq!(unsafe { ns_string(std::ptr::null_mut()) }, None); +} + +#[test] +fn ns_string_rejects_non_string_object() { + let number = CFNumber::from(42); + let value = number.as_CFTypeRef() as Id; + + assert_eq!(unsafe { ns_string(value) }, None); +} + +#[test] +fn ns_string_accepts_cf_string_object() { + let string = CFString::new("Mail"); + let value = string.as_CFTypeRef() as Id; + + assert_eq!(unsafe { ns_string(value) }.as_deref(), Some("Mail")); +} + +#[test] +fn autorelease_pool_ignores_null_push_result() { + drop(AutoreleasePool(None)); +} diff --git a/crates/macos/src/tree/ax_value.rs b/crates/macos/src/tree/ax_value.rs new file mode 100644 index 0000000..80d4cd8 --- /dev/null +++ b/crates/macos/src/tree/ax_value.rs @@ -0,0 +1,79 @@ +#[cfg(target_os = "macos")] +mod imp { + use crate::tree::AXElement; + use accessibility_sys::{AXUIElementGetTypeID, AXUIElementRef}; + use core_foundation::base::{CFRetain, CFType, CFTypeRef, TCFType}; + use core_foundation_sys::base::{CFGetTypeID, CFRelease}; + + /// Takes ownership of a non-null +1 create-rule reference and releases mismatched values. + pub(crate) fn created_ax_element(value: CFTypeRef) -> Option { + if value.is_null() { + return None; + } + if !is_ax_element(value) { + unsafe { CFRelease(value) }; + return None; + } + Some(AXElement(value as AXUIElementRef)) + } + + pub(crate) fn retained_ax_element(value: &CFType) -> Option { + let value_ref = value.as_concrete_TypeRef(); + if !is_ax_element(value_ref) { + return None; + } + unsafe { CFRetain(value_ref) }; + Some(AXElement(value_ref as AXUIElementRef)) + } + + fn is_ax_element(value: CFTypeRef) -> bool { + !value.is_null() && unsafe { CFGetTypeID(value) } == unsafe { AXUIElementGetTypeID() } + } + + #[cfg(test)] + mod tests { + use super::*; + use core_foundation::{base::CFRetain, string::CFString}; + + #[test] + fn created_ax_element_rejects_null_without_releasing() { + assert!(created_ax_element(std::ptr::null()).is_none()); + } + + #[test] + fn created_ax_element_rejects_created_non_ax_ref() { + let value = CFString::new("not-ax"); + let retained = unsafe { CFRetain(value.as_CFTypeRef()) }; + + assert!(created_ax_element(retained).is_none()); + assert_eq!(value.to_string(), "not-ax"); + } + + #[test] + fn retained_ax_element_rejects_non_ax_ref() { + let value = CFString::new("not-ax"); + let cf = unsafe { CFType::wrap_under_get_rule(value.as_CFTypeRef()) }; + + assert!(retained_ax_element(&cf).is_none()); + } + } +} + +#[cfg(not(target_os = "macos"))] +mod imp { + use crate::tree::AXElement; + + pub(crate) fn created_ax_element(_value: *const std::ffi::c_void) -> Option { + None + } + + pub(crate) fn retained_ax_element(_value: &core_foundation::base::CFType) -> Option { + None + } +} + +#[cfg(target_os = "macos")] +pub(crate) use imp::{created_ax_element, retained_ax_element}; + +#[cfg(not(target_os = "macos"))] +pub(crate) use imp::{created_ax_element, retained_ax_element}; diff --git a/crates/macos/src/tree/builder.rs b/crates/macos/src/tree/builder.rs index 99b4fea..4cec93e 100644 --- a/crates/macos/src/tree/builder.rs +++ b/crates/macos/src/tree/builder.rs @@ -58,16 +58,17 @@ pub fn build_subtree( return None; } if raw_depth >= ABSOLUTE_MAX_DEPTH { - let (ax_role, title, ax_desc, value, _) = fetch_node_attrs(el); - let role = ax_role + let attrs = fetch_node_attrs(el); + let role = attrs + .role .as_deref() .map(crate::tree::roles::ax_role_to_str) .unwrap_or("unknown") .to_string(); - let is_secure_text = is_secure_text_role(ax_role.as_deref()); - let value = redact_secure_value(ax_role.as_deref(), value); - let name = title.or(ax_desc); - let child_count = count_children(el, ax_role.as_deref()); + let is_secure_text = is_secure_text_role(attrs.role.as_deref()); + let value = redact_secure_value(attrs.role.as_deref(), attrs.value); + let name = attrs.title.or(attrs.description); + let child_count = count_children(el, attrs.role.as_deref()); let bounds = context.read_bounds(el); let mut states = Vec::new(); if is_secure_text { @@ -96,12 +97,12 @@ pub fn build_subtree( return None; } - let (ax_role, title, ax_desc, value, enabled) = fetch_node_attrs(el); + let attrs = fetch_node_attrs(el); let (role, promoted_label) = - crate::tree::roles::normalized_role_and_label(el, ax_role.as_deref()); - let is_secure_text = is_secure_text_role(ax_role.as_deref()); - let value = redact_secure_value(ax_role.as_deref(), value); + crate::tree::roles::normalized_role_and_label(el, attrs.role.as_deref()); + let is_secure_text = is_secure_text_role(attrs.role.as_deref()); + let value = redact_secure_value(attrs.role.as_deref(), attrs.value); let is_promoted_item = promoted_label.is_some(); let available_actions = if is_promoted_item { vec!["Click".into(), "RightClick".into()] @@ -109,10 +110,14 @@ pub fn build_subtree( platform_available_actions(el, &role) }; - let name = promoted_label.or_else(|| title.clone().or_else(|| ax_desc.clone())); - let description = if title.is_some() { ax_desc } else { None }; + let name = promoted_label.or_else(|| attrs.title.clone().or_else(|| attrs.description.clone())); + let description = if attrs.title.is_some() { + attrs.description + } else { + None + }; - let name = if name.is_none() && ax_role.as_deref() == Some("AXStaticText") { + let name = if name.is_none() && attrs.role.as_deref() == Some("AXStaticText") { value.clone().or(name) } else { name @@ -126,7 +131,7 @@ pub fn build_subtree( { states.push("focused".into()); } - if !enabled { + if !attrs.enabled { states.push("disabled".into()); } if is_secure_text { @@ -142,9 +147,9 @@ pub fn build_subtree( let bounds = context.read_bounds(el); let is_web_wrapper = matches!( - ax_role.as_deref(), + attrs.role.as_deref(), Some("AXGroup") | Some("AXGenericElement") - ) && title.as_deref().is_none_or(str::is_empty) + ) && attrs.title.as_deref().is_none_or(str::is_empty) && value.as_deref().is_none_or(str::is_empty); let child_depth = if is_web_wrapper { depth } else { depth + 1 }; @@ -154,13 +159,13 @@ pub fn build_subtree( skeleton && (child_depth > max_depth || child_raw_depth >= ABSOLUTE_MAX_DEPTH); if at_skeleton_boundary { - let child_count = count_children(el, ax_role.as_deref()); + let child_count = count_children(el, attrs.role.as_deref()); let children_count = if child_count > 0 { Some(child_count) } else { None }; - let name = name.or_else(|| label_from_child_attrs(el, ax_role.as_deref())); + let name = name.or_else(|| label_from_child_attrs(el, attrs.role.as_deref())); ancestors.remove(&ptr_key); return Some(AccessibilityNode { ref_id: None, @@ -177,7 +182,7 @@ pub fn build_subtree( }); } - let children_raw = copy_children(el, ax_role.as_deref()).unwrap_or_default(); + let children_raw = copy_children(el, attrs.role.as_deref()).unwrap_or_default(); let name = name.or_else(|| label_from_children(&children_raw)); let children = if is_promoted_item { diff --git a/crates/macos/src/tree/capabilities.rs b/crates/macos/src/tree/capabilities.rs index 49eae2a..dcf08f0 100644 --- a/crates/macos/src/tree/capabilities.rs +++ b/crates/macos/src/tree/capabilities.rs @@ -1,12 +1,11 @@ #[cfg(target_os = "macos")] mod imp { - use crate::tree::AXElement; + use crate::{cf_type::created_cf_array, tree::AXElement}; use accessibility_sys::{ AXUIElementCopyActionNames, AXUIElementIsAttributeSettable, kAXErrorSuccess, }; use core_foundation::{ - array::CFArray, - base::{CFEqual, CFType, CFTypeRef, TCFType}, + base::{CFEqual, CFTypeRef, TCFType}, string::CFString, }; use std::os::raw::c_uchar; @@ -27,7 +26,9 @@ mod imp { return Vec::new(); } - let actions: CFArray = unsafe { TCFType::wrap_under_create_rule(actions_ref) }; + let Some(actions) = created_cf_array(actions_ref as _) else { + return Vec::new(); + }; let mut result = Vec::with_capacity(actions.len() as usize); for i in 0..actions.len() { if let Some(name) = actions.get(i).and_then(|v| v.downcast::()) { diff --git a/crates/macos/src/tree/element.rs b/crates/macos/src/tree/element.rs index 4858793..1a09ab2 100644 --- a/crates/macos/src/tree/element.rs +++ b/crates/macos/src/tree/element.rs @@ -13,17 +13,20 @@ pub(crate) fn child_attributes(ax_role: Option<&str>) -> &'static [&'static str] #[cfg(target_os = "macos")] mod imp { use super::*; - use crate::tree::ax_element::AXElement; + use crate::{ + cf_type::created_cf_array, + tree::{NodeAttrs, ax_element::AXElement, ax_value, node_attrs::parse_enabled}, + }; use accessibility_sys::{ AXUIElementCopyAttributeValue, AXUIElementCopyAttributeValues, AXUIElementCopyMultipleAttributeValues, AXUIElementCreateApplication, - AXUIElementGetAttributeValueCount, AXUIElementRef, AXUIElementSetMessagingTimeout, - kAXDescriptionAttribute, kAXEnabledAttribute, kAXErrorSuccess, kAXRoleAttribute, - kAXTitleAttribute, kAXValueAttribute, + AXUIElementGetAttributeValueCount, AXUIElementSetMessagingTimeout, kAXDescriptionAttribute, + kAXEnabledAttribute, kAXErrorSuccess, kAXRoleAttribute, kAXTitleAttribute, + kAXValueAttribute, }; use core_foundation::{ array::CFArray, - base::{CFRetain, CFType, CFTypeRef, TCFType}, + base::{CFType, CFTypeRef, TCFType}, boolean::CFBoolean, number::CFNumber, string::CFString, @@ -37,15 +40,7 @@ mod imp { el } - pub fn fetch_node_attrs( - el: &AXElement, - ) -> ( - Option, - Option, - Option, - Option, - bool, - ) { + pub fn fetch_node_attrs(el: &AXElement) -> NodeAttrs { let attr_names = [ kAXRoleAttribute, kAXTitleAttribute, @@ -68,15 +63,12 @@ mod imp { }; if err != kAXErrorSuccess || result_ref.is_null() { - let role = copy_string_attr(el, kAXRoleAttribute); - let title = copy_string_attr(el, kAXTitleAttribute); - let desc = copy_string_attr(el, kAXDescriptionAttribute); - let val = copy_value_typed(el); - let enabled = copy_bool_attr(el, kAXEnabledAttribute).unwrap_or(true); - return (role, title, desc, val, enabled); + return fetch_node_attrs_slow(el); } - let arr = unsafe { CFArray::::wrap_under_create_rule(result_ref as _) }; + let Some(arr) = created_cf_array(result_ref) else { + return fetch_node_attrs_slow(el); + }; let items: Vec> = arr .into_iter() .enumerate() @@ -108,13 +100,28 @@ mod imp { .collect(); let get = |i: usize| items.get(i).and_then(|v| v.clone()); - let role = get(0); - let title = get(1); - let desc = get(2); - let val = get(3); - let enabled = get(4).map(|s| s == "true").unwrap_or(true); + NodeAttrs { + role: get(0), + title: get(1), + description: get(2), + value: get(3), + enabled: parse_enabled(get(4)), + } + } - (role, title, desc, val, enabled) + fn fetch_node_attrs_slow(el: &AXElement) -> NodeAttrs { + let role = copy_string_attr(el, kAXRoleAttribute); + let title = copy_string_attr(el, kAXTitleAttribute); + let desc = copy_string_attr(el, kAXDescriptionAttribute); + let val = copy_value_typed(el); + let enabled = copy_bool_attr(el, kAXEnabledAttribute).unwrap_or(true); + NodeAttrs { + role, + title, + description: desc, + value: val, + enabled, + } } pub fn resolve_element_name(el: &AXElement) -> Option { @@ -213,19 +220,8 @@ mod imp { if err != kAXErrorSuccess || value.is_null() { return None; } - let arr = unsafe { CFArray::::wrap_under_create_rule(value as _) }; - let children: Vec = arr - .into_iter() - .filter_map(|item| { - let ptr = item.as_concrete_TypeRef() as AXUIElementRef; - if ptr.is_null() { - return None; - } - unsafe { CFRetain(ptr as CFTypeRef) }; - Some(AXElement(ptr)) - }) - .collect(); - Some(children) + let arr = created_cf_array(value)?; + Some(ax_array_items(arr)) } pub fn copy_ax_array_prefix( @@ -250,7 +246,7 @@ mod imp { if err != kAXErrorSuccess || value.is_null() { return None; } - let arr = unsafe { CFArray::::wrap_under_create_rule(value as _) }; + let arr = created_cf_array(value as CFTypeRef)?; Some(ax_array_items(arr)) } @@ -263,8 +259,7 @@ mod imp { if err != kAXErrorSuccess || value.is_null() { return None; } - let ptr = value as AXUIElementRef; - Some(AXElement(ptr)) + ax_value::created_ax_element(value) } pub fn count_children(element: &AXElement, ax_role: Option<&str>) -> u32 { @@ -290,21 +285,14 @@ mod imp { fn ax_array_items(arr: CFArray) -> Vec { arr.into_iter() - .filter_map(|item| { - let ptr = item.as_concrete_TypeRef() as AXUIElementRef; - if ptr.is_null() { - return None; - } - unsafe { CFRetain(ptr as CFTypeRef) }; - Some(AXElement(ptr)) - }) + .filter_map(|item| ax_value::retained_ax_element(&item)) .collect() } } #[cfg(not(target_os = "macos"))] mod imp { - use crate::tree::ax_element::AXElement; + use crate::tree::{NodeAttrs, ax_element::AXElement}; pub fn element_for_pid(_pid: i32) -> AXElement { AXElement(std::ptr::null()) @@ -350,16 +338,11 @@ mod imp { None } - pub fn fetch_node_attrs( - _el: &AXElement, - ) -> ( - Option, - Option, - Option, - Option, - bool, - ) { - (None, None, None, None, true) + pub fn fetch_node_attrs(_el: &AXElement) -> NodeAttrs { + NodeAttrs { + enabled: true, + ..NodeAttrs::default() + } } } diff --git a/crates/macos/src/tree/mod.rs b/crates/macos/src/tree/mod.rs index 9560060..536bd0a 100644 --- a/crates/macos/src/tree/mod.rs +++ b/crates/macos/src/tree/mod.rs @@ -1,10 +1,12 @@ pub mod action_list; pub mod ax_element; +pub(crate) mod ax_value; pub mod build_context; pub mod builder; pub mod capabilities; pub mod element; pub mod element_bounds; +pub(crate) mod node_attrs; pub mod resolve; mod resolve_bounds; mod resolve_identity; @@ -20,6 +22,7 @@ pub use element::{ copy_value_typed, element_for_pid, resolve_element_name, }; pub use element_bounds::read_bounds; +pub(crate) use node_attrs::NodeAttrs; pub use surfaces::{ alert_for_pid, focused_surface_for_pid, menu_element_for_pid, menubar_for_pid, popover_for_pid, sheet_for_pid, diff --git a/crates/macos/src/tree/node_attrs.rs b/crates/macos/src/tree/node_attrs.rs new file mode 100644 index 0000000..d946f3b --- /dev/null +++ b/crates/macos/src/tree/node_attrs.rs @@ -0,0 +1,37 @@ +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub(crate) struct NodeAttrs { + pub(crate) role: Option, + pub(crate) title: Option, + pub(crate) description: Option, + pub(crate) value: Option, + pub(crate) enabled: bool, +} + +pub(crate) fn parse_enabled(enabled: Option) -> bool { + enabled.map(|s| s == "true").unwrap_or(true) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn enabled_defaults_to_true_when_missing() { + assert!(parse_enabled(None)); + } + + #[test] + fn enabled_accepts_true_string() { + assert!(parse_enabled(Some("true".into()))); + } + + #[test] + fn enabled_rejects_false_string() { + assert!(!parse_enabled(Some("false".into()))); + } + + #[test] + fn enabled_rejects_unknown_string() { + assert!(!parse_enabled(Some("x".into()))); + } +} diff --git a/src/cli_args_system.rs b/src/cli_args_system.rs index 5e19582..8f8e19a 100644 --- a/src/cli_args_system.rs +++ b/src/cli_args_system.rs @@ -36,7 +36,7 @@ pub(crate) struct CloseAppArgs { #[derive(Parser, Debug, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct ListWindowsArgs { - #[arg(long, help = "Filter running apps by case-insensitive name substring")] + #[arg(long, help = "Filter to application by exact case-insensitive name")] pub app: Option, }