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<CFType> 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 <agent-mouses@users.noreply.github.com>
Co-authored-by: Lahfir <nmhlahfir2@gmail.com>
Co-authored-by: Lahfir <70215676+lahfir@users.noreply.github.com>
This commit is contained in:
Agent-Mouses 2026-06-02 12:51:49 +08:00 committed by GitHub
parent 5987b35859
commit c02cb5ecb7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
27 changed files with 1533 additions and 781 deletions

View file

@ -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<String> {
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<CFType> = 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::<CFString>()) {
result.push(name.to_string());
}
}
result
crate::tree::capabilities::copy_action_names(el)
}
pub(crate) fn has_ax_action(el: &AXElement, target: &str) -> bool {

136
crates/macos/src/cf_type.rs Normal file
View file

@ -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<CFArray<CFType>> {
if value.is_null() {
return None;
}
if !matches_cf_type(value, unsafe { CFArrayGetTypeID() }) {
unsafe { CFRelease(value) };
return None;
}
Some(unsafe { CFArray::<CFType>::wrap_under_create_rule(value as _) })
}
pub(crate) fn borrowed_cf_dictionary(
value: CFTypeRef,
) -> Option<CFDictionary<CFString, CFType>> {
if matches_cf_type(value, unsafe { CFDictionaryGetTypeID() }) {
Some(unsafe { CFDictionary::<CFString, CFType>::wrap_under_get_rule(value as _) })
} else {
None
}
}
pub(crate) fn borrowed_cf_number(value: CFTypeRef) -> Option<CFNumber> {
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<CFString> {
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,
};

View file

@ -1,5 +1,6 @@
mod actions;
mod adapter;
mod cf_type;
mod input;
mod notifications;
mod system;

View file

@ -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<AppInfo> {
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<WindowInfo> {
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<i32> {
app_for_name(app_name).map(|app| app.pid)
}
pub(crate) fn app_for_name(app_name: &str) -> Option<AppInfo> {
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<AppInfo>,
visible: &[AppInfo],
process: impl FnOnce() -> Vec<AppInfo>,
) -> Option<AppInfo> {
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<AppInfo>,
visible: Vec<AppInfo>,
process: Vec<AppInfo>,
) -> Vec<AppInfo> {
let mut apps = merge_primary_sources(workspace, visible);
merge_apps(&mut apps, process);
apps
}
fn merge_primary_sources(workspace: Vec<AppInfo>, visible: Vec<AppInfo>) -> Vec<AppInfo> {
let mut apps = workspace;
merge_apps(&mut apps, visible);
apps
}
fn find_app_with_process_fallback(
primary: &[AppInfo],
process: impl FnOnce() -> Vec<AppInfo>,
app_name: &str,
) -> Option<AppInfo> {
find_app_in_apps(primary, app_name).or_else(|| find_app_in_apps(&process(), app_name))
}
fn merge_apps(apps: &mut Vec<AppInfo>, incoming: Vec<AppInfo>) {
let mut seen_pids = apps
.iter()
.map(|app| app.pid)
.collect::<std::collections::HashSet<_>>();
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<AppInfo> {
apps.iter()
.find(|app| app.name.eq_ignore_ascii_case(app_name))
.cloned()
}
#[cfg(test)]
#[path = "app_inventory_tests.rs"]
mod tests;

View file

@ -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<_>>(),
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<_>>(),
vec![1, 2, 3]
);
}

View file

@ -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<Vec<AppInfo>, 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<WindowInfo>) -> Vec<AppInfo> {
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<i32> {
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<i32> {
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<AppInfo> {
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<AppInfo> {
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<String> {
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<bool> = 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<AppInfo>, incoming: Vec<AppInfo>) {
let mut seen_pids = apps
.iter()
.map(|app| app.pid)
.collect::<std::collections::HashSet<_>>();
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<AppInfo> {
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::<CFType>::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::<CFString, CFType>::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<i64> {
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<String> {
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<AppInfo> {
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::<i32>() 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<String> {
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;

View file

@ -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));
}

View file

@ -117,7 +117,7 @@ pub fn launch_app_impl(id: &str, timeout_ms: u64) -> Result<WindowInfo, AdapterE
}
let mut command = Command::new("/usr/bin/open");
command.arg("-a").arg(id);
command.args(open_app_args(id));
crate::system::process::run_with_timeout(&mut command, "open", OPEN_TIMEOUT)?;
let start = Instant::now();
@ -149,11 +149,20 @@ pub fn launch_app_impl(id: &str, timeout_ms: u64) -> Result<WindowInfo, AdapterE
.with_suggestion("The app may take longer to start, or it may not create a visible window"))
}
#[cfg(target_os = "macos")]
fn open_app_args(id: &str) -> [&str; 3] {
["-g", "-a", id]
}
#[cfg(not(target_os = "macos"))]
pub fn launch_app_impl(_id: &str, _timeout_ms: u64) -> Result<WindowInfo, AdapterError> {
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}");

View file

@ -0,0 +1,6 @@
use super::*;
#[test]
fn open_app_args_preserve_current_focus() {
assert_eq!(open_app_args("Mail"), ["-g", "-a", "Mail"]);
}

View file

@ -0,0 +1,102 @@
use core_foundation::{base::CFType, dictionary::CFDictionary, string::CFString};
type WindowDictionary = CFDictionary<CFString, CFType>;
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct WindowRecord {
pub(crate) app_name: String,
pub(crate) pid: i32,
pub(crate) title: Option<String>,
pub(crate) window_number: i64,
pub(crate) area: f64,
}
pub(crate) fn visible_window_records() -> Vec<WindowRecord> {
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<WindowDictionary> {
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<i64> {
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<String> {
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<f64> {
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<f64> {
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())
}

View file

@ -76,7 +76,7 @@ fn try_simple_key_action(
fn get_focused_element(
app_el: accessibility_sys::AXUIElementRef,
) -> Option<crate::tree::AXElement> {
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::<CFNumber>()
.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)
}

View file

@ -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;

View file

@ -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<AppInfo> {
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::<i32>() 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<String> {
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;

View file

@ -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
);
}

View file

@ -138,78 +138,17 @@ mod imp {
}
fn find_cg_window_id_for_pid(pid: i32) -> Option<u32> {
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::<CFType>::wrap_under_create_rule(info_ref as _) };
let mut best_id: Option<u32> = None;
let mut best_area: f64 = 0.0;
for item in array.iter() {
let dict = unsafe {
CFDictionary::<CFString, CFType>::wrap_under_get_rule(
item.as_concrete_TypeRef() as _
)
};
let int_field = |key: &str| -> Option<i32> {
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::<CFString, CFType>::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);
}
}

View file

@ -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<AppInfo> {
apps_from_window_records(&cg_window::visible_window_records())
}
fn apps_from_window_records(records: &[cg_window::WindowRecord]) -> Vec<AppInfo> {
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<AppInfo>,
) -> Vec<WindowInfo> {
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<AppInfo>,
mut visible_records: impl FnMut() -> Vec<cg_window::WindowRecord>,
mut ax_window_for_app: impl FnMut(&AppInfo) -> Option<WindowInfo>,
mut sleep: impl FnMut(Duration),
) -> Vec<WindowInfo> {
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<WindowInfo> {
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<cg_window::WindowRecord>,
focused_only: bool,
) -> Vec<WindowInfo> {
windows_from_records_with_focus(records, focused_only, focused_window_identity)
}
fn windows_from_records_with_focus(
records: Vec<cg_window::WindowRecord>,
focused_only: bool,
mut focused_identity: impl FnMut(i32) -> FocusedWindowIdentity,
) -> Vec<WindowInfo> {
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<WindowInfo> {
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<String>, Option<i64>)>;
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<crate::tree::AXElement> {
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<crate::tree::AXElement> {
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;

View file

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

View file

@ -3,14 +3,7 @@ use agent_desktop_core::{adapter::WindowFilter, error::AdapterError, node::Windo
pub(crate) fn list_windows_impl(filter: &WindowFilter) -> Result<Vec<WindowInfo>, 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<Vec<WindowInfo>
Err(AdapterError::not_supported("list_windows"))
}
}
#[cfg(target_os = "macos")]
fn list_windows_once(filter: &WindowFilter) -> Vec<WindowInfo> {
#[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<String> {
let val = unsafe { CFDictionaryGetValue(dict as _, key) };
if val.is_null() {
return None;
}
unsafe { CFType::wrap_under_get_rule(val as _) }
.downcast::<CFString>()
.map(|s| s.to_string())
}
unsafe fn dict_i64(dict: *const c_void, key: *const c_void) -> Option<i64> {
let val = unsafe { CFDictionaryGetValue(dict as _, key) };
if val.is_null() {
return None;
}
unsafe { CFType::wrap_under_get_rule(val as _) }
.downcast::<CFNumber>()
.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<i32, FocusedWindowIdentity> = 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<WindowInfo> {
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<String>, Option<i64>)>;
#[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
}

View file

@ -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<Id>);
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<AppInfo> {
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<bool> = 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<AppInfo> {
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<String> {
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;

View file

@ -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));
}

View file

@ -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<AXElement> {
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<AXElement> {
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<AXElement> {
None
}
pub(crate) fn retained_ax_element(_value: &core_foundation::base::CFType) -> Option<AXElement> {
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};

View file

@ -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 {

View file

@ -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<CFType> = 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::<CFString>()) {

View file

@ -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<String>,
Option<String>,
Option<String>,
Option<String>,
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::<CFType>::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<Option<String>> = 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<String> {
@ -213,19 +220,8 @@ mod imp {
if err != kAXErrorSuccess || value.is_null() {
return None;
}
let arr = unsafe { CFArray::<CFType>::wrap_under_create_rule(value as _) };
let children: Vec<AXElement> = 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::<CFType>::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<CFType>) -> Vec<AXElement> {
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<String>,
Option<String>,
Option<String>,
Option<String>,
bool,
) {
(None, None, None, None, true)
pub fn fetch_node_attrs(_el: &AXElement) -> NodeAttrs {
NodeAttrs {
enabled: true,
..NodeAttrs::default()
}
}
}

View file

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

View file

@ -0,0 +1,37 @@
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub(crate) struct NodeAttrs {
pub(crate) role: Option<String>,
pub(crate) title: Option<String>,
pub(crate) description: Option<String>,
pub(crate) value: Option<String>,
pub(crate) enabled: bool,
}
pub(crate) fn parse_enabled(enabled: Option<String>) -> 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())));
}
}

View file

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