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, }