use super::ocr::{TextBounds, TextMatch};
use crate::tools::ax_snapshot::{map_ax_role, AXSnapshotNode};
use core_foundation::array::{kCFTypeArrayCallBacks, CFArray, CFArrayCreate};
use core_foundation::base::{kCFAllocatorDefault, CFType, TCFType};
use core_foundation::boolean::CFBoolean;
use core_foundation::string::CFString;
use core_graphics::geometry::{CGPoint, CGSize};
use objc::runtime::{Class, Object};
use objc::{msg_send, sel, sel_impl};
use std::ffi::c_void;
use std::ptr;
use std::sync::Arc;
type AXUIElementRef = *mut c_void;
type AXValueRef = *mut c_void;
const K_AX_VALUE_TYPE_CGPOINT: u32 = 1;
const K_AX_VALUE_TYPE_CGSIZE: u32 = 2;
const K_AX_ERROR_SUCCESS: i32 = 0;
const K_AX_ERROR_ILLEGAL_ARGUMENT: i32 = -25204;
const K_AX_ERROR_ATTRIBUTE_UNSUPPORTED: i32 = -25205;
const K_AX_ERROR_ACTION_UNSUPPORTED: i32 = -25206;
const MAX_DEPTH: u32 = 50;
const MAX_ELEMENTS: usize = 10_000;
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXUIElementCreateApplication(pid: i32) -> AXUIElementRef;
fn AXUIElementCopyAttributeValue(
element: AXUIElementRef,
attribute: core_foundation::string::CFStringRef,
value: *mut core_foundation::base::CFTypeRef,
) -> i32;
fn AXValueGetValue(value: AXValueRef, value_type: u32, value_ptr: *mut c_void) -> bool;
fn AXUIElementPerformAction(
element: AXUIElementRef,
action: core_foundation::string::CFStringRef,
) -> i32;
fn AXUIElementSetAttributeValue(
element: AXUIElementRef,
attribute: core_foundation::string::CFStringRef,
value: core_foundation::base::CFTypeRef,
) -> i32;
fn AXUIElementCreateSystemWide() -> AXUIElementRef;
fn AXUIElementCopyElementAtPosition(
application: AXUIElementRef,
x: f32,
y: f32,
element: *mut AXUIElementRef,
) -> i32;
fn AXUIElementGetPid(element: AXUIElementRef, pid: *mut i32) -> i32;
}
#[derive(Clone)]
pub struct AXRef(Arc<AXRefInner>);
struct AXRefInner(AXUIElementRef);
unsafe impl Send for AXRefInner {}
unsafe impl Sync for AXRefInner {}
impl Drop for AXRefInner {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe {
core_foundation::base::CFRelease(self.0 as core_foundation::base::CFTypeRef);
}
}
}
}
impl std::fmt::Debug for AXRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "AXRef({:p})", self.0 .0)
}
}
impl AXRef {
pub(crate) unsafe fn from_create(raw: AXUIElementRef) -> Self {
AXRef(Arc::new(AXRefInner(raw)))
}
pub(crate) unsafe fn from_get(raw: AXUIElementRef) -> Self {
if !raw.is_null() {
core_foundation::base::CFRetain(raw as core_foundation::base::CFTypeRef);
}
AXRef(Arc::new(AXRefInner(raw)))
}
pub(crate) fn as_raw(&self) -> AXUIElementRef {
self.0 .0
}
}
pub fn find_text(search: &str, window_id: Option<u32>) -> Result<Vec<TextMatch>, String> {
let debug = std::env::var("NATIVE_DEVTOOLS_DEBUG").is_ok();
let pid = match window_id {
Some(wid) => pid_for_window(wid)?,
None => frontmost_pid()?,
};
if debug {
eprintln!(
"[DEBUG ax::find_text] search='{}', window_id={:?}, pid={}",
search, window_id, pid
);
}
let app_element = unsafe { AXUIElementCreateApplication(pid) };
if app_element.is_null() {
return Err(format!("Failed to create AXUIElement for pid {}", pid));
}
let search_lower = search.to_lowercase();
let mut matches = Vec::new();
let mut element_count: usize = 0;
unsafe {
walk_ax_tree(app_element, &mut element_count, 0, &mut |element| {
let matched_text = ["AXTitle", "AXValue", "AXDescription"]
.iter()
.filter_map(|attr| get_string_attribute(element, attr))
.find(|s| !s.is_empty() && s.to_lowercase().contains(search_lower.as_str()));
if let Some(text) = matched_text {
if let Some((position, size)) = get_position_and_size(element) {
if size.width > 0.0 && size.height > 0.0 {
let role = get_string_attribute(element, "AXRole");
let bounds = TextBounds {
x: position.x,
y: position.y,
width: size.width,
height: size.height,
};
matches.push(TextMatch {
text,
x: bounds.x + bounds.width / 2.0,
y: bounds.y + bounds.height / 2.0,
confidence: 1.0,
bounds,
role,
});
}
}
}
});
core_foundation::base::CFRelease(app_element as core_foundation::base::CFTypeRef);
}
if debug {
eprintln!(
"[DEBUG ax::find_text] found {} matches out of {} elements",
matches.len(),
element_count
);
}
Ok(matches)
}
unsafe fn get_ax_children(element: AXUIElementRef) -> Option<CFArray<*const c_void>> {
let attr = CFString::new("AXChildren");
let mut children_ref: core_foundation::base::CFTypeRef = ptr::null();
let err = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef(), &mut children_ref);
if err != K_AX_ERROR_SUCCESS || children_ref.is_null() {
return None;
}
Some(CFArray::wrap_under_create_rule(children_ref as _))
}
unsafe fn walk_ax_tree(
element: AXUIElementRef,
element_count: &mut usize,
depth: u32,
visitor: &mut dyn FnMut(AXUIElementRef),
) {
if depth > MAX_DEPTH || *element_count >= MAX_ELEMENTS {
return;
}
*element_count += 1;
visitor(element);
if let Some(children) = get_ax_children(element) {
for i in 0..children.len() {
let child = *children.get_unchecked(i) as AXUIElementRef;
core_foundation::base::CFRetain(child as core_foundation::base::CFTypeRef);
walk_ax_tree(child, element_count, depth + 1, visitor);
core_foundation::base::CFRelease(child as core_foundation::base::CFTypeRef);
}
}
}
unsafe fn get_string_attribute(element: AXUIElementRef, attr_name: &str) -> Option<String> {
let attr = CFString::new(attr_name);
let mut value_ref: core_foundation::base::CFTypeRef = ptr::null();
let err = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef(), &mut value_ref);
if err != K_AX_ERROR_SUCCESS || value_ref.is_null() {
return None;
}
let cf_string = CFType::wrap_under_create_rule(value_ref).downcast_into::<CFString>()?;
Some(cf_string.to_string())
}
unsafe fn get_bool_attribute(element: AXUIElementRef, attr_name: &str) -> Option<bool> {
let attr = CFString::new(attr_name);
let mut value_ref: core_foundation::base::CFTypeRef = ptr::null();
let err = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef(), &mut value_ref);
if err != K_AX_ERROR_SUCCESS || value_ref.is_null() {
return None;
}
let cf_bool = CFType::wrap_under_create_rule(value_ref).downcast_into::<CFBoolean>()?;
Some(bool::from(cf_bool))
}
unsafe fn get_position_and_size(element: AXUIElementRef) -> Option<(CGPoint, CGSize)> {
let position: CGPoint = get_ax_value(element, "AXPosition", K_AX_VALUE_TYPE_CGPOINT)?;
let size: CGSize = get_ax_value(element, "AXSize", K_AX_VALUE_TYPE_CGSIZE)?;
Some((position, size))
}
pub(crate) unsafe fn element_bbox(
element: AXUIElementRef,
) -> Option<crate::tools::ax_snapshot::Rect> {
let (pos, size) = get_position_and_size(element)?;
Some(crate::tools::ax_snapshot::Rect {
x: pos.x,
y: pos.y,
w: size.width,
h: size.height,
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum AXDispatchError {
NotDispatchable,
AxError(i32),
}
impl AXDispatchError {
pub fn from_press_code(code: i32) -> Option<Self> {
match code {
K_AX_ERROR_SUCCESS => None,
K_AX_ERROR_ACTION_UNSUPPORTED => Some(AXDispatchError::NotDispatchable),
other => Some(AXDispatchError::AxError(other)),
}
}
pub fn from_set_value_code(code: i32) -> Option<Self> {
match code {
K_AX_ERROR_SUCCESS => None,
K_AX_ERROR_ATTRIBUTE_UNSUPPORTED | K_AX_ERROR_ILLEGAL_ARGUMENT => {
Some(AXDispatchError::NotDispatchable)
}
other => Some(AXDispatchError::AxError(other)),
}
}
}
pub(crate) fn press_element(element: &AXRef) -> Result<(), AXDispatchError> {
let action = CFString::new("AXPress");
let code = unsafe { AXUIElementPerformAction(element.as_raw(), action.as_concrete_TypeRef()) };
match AXDispatchError::from_press_code(code) {
None => Ok(()),
Some(e) => Err(e),
}
}
pub(crate) fn set_value_attribute(element: &AXRef, text: &str) -> Result<(), AXDispatchError> {
let attr = CFString::new("AXValue");
let value = CFString::new(text);
let code = unsafe {
AXUIElementSetAttributeValue(
element.as_raw(),
attr.as_concrete_TypeRef(),
value.as_concrete_TypeRef() as core_foundation::base::CFTypeRef,
)
};
match AXDispatchError::from_set_value_code(code) {
None => Ok(()),
Some(e) => Err(e),
}
}
fn ax_parent(element: &AXRef) -> Option<AXRef> {
let attr = CFString::new("AXParent");
let mut value_ref: core_foundation::base::CFTypeRef = ptr::null();
let err = unsafe {
AXUIElementCopyAttributeValue(element.as_raw(), attr.as_concrete_TypeRef(), &mut value_ref)
};
if err != K_AX_ERROR_SUCCESS || value_ref.is_null() {
return None;
}
Some(unsafe { AXRef::from_create(value_ref as AXUIElementRef) })
}
const AX_ANCESTOR_WALK_LIMIT: u32 = 32;
pub(crate) fn ancestor_role_chain(start: &AXRef) -> Vec<(AXRef, Option<String>)> {
let mut chain: Vec<(AXRef, Option<String>)> = Vec::new();
let mut current = start.clone();
for _ in 0..AX_ANCESTOR_WALK_LIMIT {
let role = unsafe { get_string_attribute(current.as_raw(), "AXRole") };
chain.push((current.clone(), role));
match ax_parent(¤t) {
Some(p) => current = p,
None => break,
}
}
chain
}
pub(crate) fn select_rows_attribute(
container: &AXRef,
rows: &[&AXRef],
) -> Result<(), AXDispatchError> {
let attr = CFString::new("AXSelectedRows");
let raw_ptrs: Vec<*const c_void> = rows.iter().map(|r| r.as_raw() as *const c_void).collect();
let cf_array_ref = unsafe {
CFArrayCreate(
kCFAllocatorDefault,
raw_ptrs.as_ptr(),
raw_ptrs.len() as core_foundation::base::CFIndex,
&kCFTypeArrayCallBacks,
)
};
let cf_array: CFArray<*const c_void> = unsafe { CFArray::wrap_under_create_rule(cf_array_ref) };
let code = unsafe {
AXUIElementSetAttributeValue(
container.as_raw(),
attr.as_concrete_TypeRef(),
cf_array.as_concrete_TypeRef() as core_foundation::base::CFTypeRef,
)
};
match AXDispatchError::from_set_value_code(code) {
None => Ok(()),
Some(e) => Err(e),
}
}
unsafe fn get_ax_value<T: Default>(
element: AXUIElementRef,
attr_name: &str,
ax_value_type: u32,
) -> Option<T> {
let attr = CFString::new(attr_name);
let mut value_ref: core_foundation::base::CFTypeRef = ptr::null();
let err = AXUIElementCopyAttributeValue(element, attr.as_concrete_TypeRef(), &mut value_ref);
if err != K_AX_ERROR_SUCCESS || value_ref.is_null() {
return None;
}
let mut result = T::default();
let ok = AXValueGetValue(
value_ref as AXValueRef,
ax_value_type,
&mut result as *mut T as *mut c_void,
);
core_foundation::base::CFRelease(value_ref);
if ok {
Some(result)
} else {
None
}
}
fn pid_for_window(window_id: u32) -> Result<i32, String> {
let window = super::window::find_window_by_id(window_id)?
.ok_or_else(|| format!("Window {} not found", window_id))?;
i32::try_from(window.owner_pid)
.map_err(|_| format!("PID {} exceeds i32 range", window.owner_pid))
}
fn app_name_for_pid(pid: i32) -> Option<String> {
unsafe {
let app: *mut Object = msg_send![
Class::get("NSRunningApplication")?,
runningApplicationWithProcessIdentifier: pid
];
if app.is_null() {
return None;
}
let name_ns: *mut Object = msg_send![app, localizedName];
if name_ns.is_null() {
return None;
}
let utf8_ptr: *const std::ffi::c_char = msg_send![name_ns, UTF8String];
if utf8_ptr.is_null() {
return None;
}
Some(
std::ffi::CStr::from_ptr(utf8_ptr)
.to_string_lossy()
.into_owned(),
)
}
}
pub(crate) fn frontmost_pid() -> Result<i32, String> {
unsafe {
let cls = Class::get("NSWorkspace").ok_or("NSWorkspace class not available")?;
let workspace: *mut Object = msg_send![cls, sharedWorkspace];
if workspace.is_null() {
return Err("NSWorkspace.sharedWorkspace returned nil".to_string());
}
let app: *mut Object = msg_send![workspace, frontmostApplication];
if app.is_null() {
return Err("No frontmost application found".to_string());
}
let pid: i32 = msg_send![app, processIdentifier];
Ok(pid)
}
}
fn pid_for_app_name(app_name: &str) -> Result<i32, String> {
let windows = super::window::find_windows_by_app(app_name)
.map_err(|e| format!("Failed to find windows: {}", e))?;
let win = windows.first().ok_or_else(|| {
format!(
"No app found matching '{}'. Use list_apps to find the correct app name.",
app_name
)
})?;
i32::try_from(win.owner_pid).map_err(|_| format!("PID {} exceeds i32 range", win.owner_pid))
}
unsafe fn get_pid_for_element(element: AXUIElementRef) -> Option<i32> {
let mut pid: i32 = 0;
if AXUIElementGetPid(element, &mut pid) == K_AX_ERROR_SUCCESS {
Some(pid)
} else {
None
}
}
fn is_container_role(role: &str) -> bool {
matches!(
role,
"AXScrollArea"
| "AXWebArea"
| "AXGroup"
| "AXSplitGroup"
| "AXLayoutArea"
| "AXList"
| "AXOutline"
| "AXTable"
| "AXBrowser"
)
}
unsafe fn hit_test_tree(root: AXUIElementRef, x: f64, y: f64) -> Option<HitResult> {
let mut best: Option<HitResult> = None;
let mut element_count: usize = 0;
walk_ax_tree(root, &mut element_count, 0, &mut |element| {
if let Some((pos, size)) = get_position_and_size(element) {
if size.width > 0.0
&& size.height > 0.0
&& x >= pos.x
&& x <= pos.x + size.width
&& y >= pos.y
&& y <= pos.y + size.height
{
let area = size.width * size.height;
let is_better = match &best {
Some(current) => area < current.area,
None => true,
};
if is_better {
best = Some(HitResult {
name: get_string_attribute(element, "AXTitle"),
role: get_string_attribute(element, "AXRole"),
subrole: get_string_attribute(element, "AXSubrole"),
label: get_string_attribute(element, "AXDescription"),
value: get_string_attribute(element, "AXValue"),
position: pos,
size,
area,
});
}
}
}
});
best
}
struct HitResult {
name: Option<String>,
role: Option<String>,
subrole: Option<String>,
label: Option<String>,
value: Option<String>,
position: CGPoint,
size: CGSize,
area: f64,
}
pub fn element_at_point(
x: f64,
y: f64,
app_name: Option<&str>,
) -> Result<serde_json::Value, String> {
let root = if let Some(name) = app_name {
let pid = pid_for_app_name(name)?;
let el = unsafe { AXUIElementCreateApplication(pid) };
if el.is_null() {
return Err(format!("Failed to create AXUIElement for app '{}'", name));
}
el
} else {
let el = unsafe { AXUIElementCreateSystemWide() };
if el.is_null() {
return Err("Failed to create system-wide AXUIElement".to_string());
}
el
};
let mut element: AXUIElementRef = ptr::null_mut();
let err = unsafe { AXUIElementCopyElementAtPosition(root, x as f32, y as f32, &mut element) };
unsafe {
core_foundation::base::CFRelease(root as core_foundation::base::CFTypeRef);
}
if err != K_AX_ERROR_SUCCESS || element.is_null() {
return Err(format!("No accessibility element found at ({}, {})", x, y));
}
let pid = unsafe { get_pid_for_element(element) };
let role_str = unsafe { get_string_attribute(element, "AXRole") };
let is_container = role_str.as_deref().is_some_and(is_container_role);
let (name, role, subrole, label, value, bounds) = if is_container {
unsafe {
core_foundation::base::CFRelease(element as core_foundation::base::CFTypeRef);
}
let hit = pid.and_then(|p| unsafe {
let app = AXUIElementCreateApplication(p);
if app.is_null() {
return None;
}
let result = hit_test_tree(app, x, y);
core_foundation::base::CFRelease(app as core_foundation::base::CFTypeRef);
result
});
match hit {
Some(h) => (
h.name,
h.role,
h.subrole,
h.label,
h.value,
Some((h.position, h.size)),
),
None => (None, role_str, None, None, None, None),
}
} else {
let name = unsafe { get_string_attribute(element, "AXTitle") };
let subrole = unsafe { get_string_attribute(element, "AXSubrole") };
let label = unsafe { get_string_attribute(element, "AXDescription") };
let value = unsafe { get_string_attribute(element, "AXValue") };
let bounds = unsafe { get_position_and_size(element) };
unsafe {
core_foundation::base::CFRelease(element as core_foundation::base::CFTypeRef);
}
(name, role_str, subrole, label, value, bounds)
};
let resolved_app_name = pid.and_then(app_name_for_pid);
let mut result = serde_json::Map::new();
if let Some(r) = role {
result.insert("role".to_string(), serde_json::Value::String(r));
}
if let Some(sr) = subrole {
result.insert("subrole".to_string(), serde_json::Value::String(sr));
}
if let Some(n) = name {
result.insert("name".to_string(), serde_json::Value::String(n));
}
if let Some(l) = label {
result.insert("label".to_string(), serde_json::Value::String(l));
}
if let Some(v) = value {
result.insert("value".to_string(), serde_json::Value::String(v));
}
if let Some((pos, size)) = bounds {
result.insert(
"bounds".to_string(),
serde_json::json!({
"x": pos.x,
"y": pos.y,
"width": size.width,
"height": size.height,
}),
);
}
if let Some(p) = pid {
result.insert("pid".to_string(), serde_json::Value::Number(p.into()));
}
if let Some(a) = resolved_app_name {
result.insert("app_name".to_string(), serde_json::Value::String(a));
}
Ok(serde_json::Value::Object(result))
}
pub fn list_element_names(window_id: Option<u32>) -> Result<Vec<String>, String> {
let pid = match window_id {
Some(wid) => pid_for_window(wid)?,
None => frontmost_pid()?,
};
let app_element = unsafe { AXUIElementCreateApplication(pid) };
if app_element.is_null() {
return Err(format!("Failed to create AXUIElement for pid {}", pid));
}
let mut names = Vec::new();
let mut seen = std::collections::HashSet::new();
let mut element_count: usize = 0;
unsafe {
walk_ax_tree(app_element, &mut element_count, 0, &mut |element| {
for attr in &["AXTitle", "AXValue", "AXDescription"] {
if let Some(text) = get_string_attribute(element, attr) {
let trimmed = text.trim();
if !trimmed.is_empty() && seen.insert(trimmed.to_string()) {
names.push(trimmed.to_string());
}
}
}
});
core_foundation::base::CFRelease(app_element as core_foundation::base::CFTypeRef);
}
Ok(names)
}
pub fn raise_windows(pid: i32) -> bool {
let debug = std::env::var("NATIVE_DEVTOOLS_DEBUG").is_ok();
unsafe {
let app_element = AXUIElementCreateApplication(pid);
if app_element.is_null() {
if debug {
eprintln!(
"[DEBUG ax::raise_windows] Failed to create AXUIElement for pid {}",
pid
);
}
return false;
}
let frontmost_attr = CFString::new("AXFrontmost");
let frontmost_err = AXUIElementSetAttributeValue(
app_element,
frontmost_attr.as_concrete_TypeRef(),
core_foundation::boolean::CFBoolean::true_value().as_CFTypeRef(),
);
if debug {
eprintln!(
"[DEBUG ax::raise_windows] AXFrontmost set for pid {} (err={})",
pid, frontmost_err
);
}
let windows_attr = CFString::new("AXWindows");
let mut windows_ref: core_foundation::base::CFTypeRef = ptr::null();
let err = AXUIElementCopyAttributeValue(
app_element,
windows_attr.as_concrete_TypeRef(),
&mut windows_ref,
);
let mut raised = frontmost_err == K_AX_ERROR_SUCCESS;
if err == K_AX_ERROR_SUCCESS && !windows_ref.is_null() {
let windows: CFArray<*const c_void> = CFArray::wrap_under_create_rule(windows_ref as _);
let raise_action = CFString::new("AXRaise");
for i in 0..windows.len() {
let window = *windows.get_unchecked(i) as AXUIElementRef;
let result = AXUIElementPerformAction(window, raise_action.as_concrete_TypeRef());
if result == K_AX_ERROR_SUCCESS {
raised = true;
} else if debug {
eprintln!(
"[DEBUG ax::raise_windows] AXRaise failed for window {} (err={})",
i, result
);
}
}
if debug {
eprintln!(
"[DEBUG ax::raise_windows] pid={}, windows={}, raised={}",
pid,
windows.len(),
raised
);
}
} else if debug {
eprintln!(
"[DEBUG ax::raise_windows] No AXWindows for pid {} (err={})",
pid, err
);
}
core_foundation::base::CFRelease(app_element as core_foundation::base::CFTypeRef);
raised
}
}
unsafe fn collect_ax_tree_recursive(
element: AXUIElementRef,
element_count: &mut usize,
depth: u32,
next_uid: &mut u32,
nodes: &mut Vec<AXSnapshotNode>,
refs: &mut std::collections::HashMap<u32, AXRef>,
) {
if depth > MAX_DEPTH || *element_count >= MAX_ELEMENTS {
return;
}
*element_count += 1;
let uid = *next_uid;
*next_uid += 1;
let role = get_string_attribute(element, "AXRole")
.as_deref()
.map(map_ax_role)
.unwrap_or_else(|| "unknown".to_string());
let name = get_string_attribute(element, "AXTitle");
let value = get_string_attribute(element, "AXValue");
let focused = get_bool_attribute(element, "AXFocused").unwrap_or(false);
let disabled = get_bool_attribute(element, "AXEnabled")
.map(|enabled| !enabled)
.unwrap_or(false);
let expanded = get_bool_attribute(element, "AXExpanded");
let selected = get_bool_attribute(element, "AXSelected");
let bbox = element_bbox(element);
nodes.push(AXSnapshotNode {
uid,
role,
name,
value,
focused,
disabled,
expanded,
selected,
depth,
bbox,
});
refs.insert(uid, AXRef::from_get(element));
if let Some(children) = get_ax_children(element) {
for i in 0..children.len() {
let child = *children.get_unchecked(i) as AXUIElementRef;
core_foundation::base::CFRetain(child as core_foundation::base::CFTypeRef);
collect_ax_tree_recursive(child, element_count, depth + 1, next_uid, nodes, refs);
core_foundation::base::CFRelease(child as core_foundation::base::CFTypeRef);
}
}
}
pub fn collect_ax_tree_indexed(
app_name: Option<&str>,
) -> Result<(Vec<AXSnapshotNode>, std::collections::HashMap<u32, AXRef>), String> {
let pid = match app_name {
Some(name) => pid_for_app_name(name)?,
None => frontmost_pid()?,
};
let app_element = unsafe { AXUIElementCreateApplication(pid) };
if app_element.is_null() {
return Err(format!("Failed to create AXUIElement for pid {}", pid));
}
let mut nodes = Vec::new();
let mut element_count: usize = 0;
let mut next_uid: u32 = 1;
let mut refs = std::collections::HashMap::new();
unsafe {
collect_ax_tree_recursive(
app_element,
&mut element_count,
0,
&mut next_uid,
&mut nodes,
&mut refs,
);
core_foundation::base::CFRelease(app_element as core_foundation::base::CFTypeRef);
}
Ok((nodes, refs))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[ignore]
fn test_ax_find_text_calculator() {
let results = find_text("9", None).expect("find_text should succeed");
println!("AX find_text results for '9' (no window_id):");
for m in &results {
println!(
" '{}' at ({:.1}, {:.1}) bounds=({:.1}, {:.1}, {:.1}x{:.1})",
m.text, m.x, m.y, m.bounds.x, m.bounds.y, m.bounds.width, m.bounds.height
);
}
assert!(
!results.is_empty(),
"Should find at least one match for '9'"
);
for m in &results {
assert!(m.x > 0.0, "x coordinate should be positive");
assert!(m.y > 0.0, "y coordinate should be positive");
}
}
#[test]
#[ignore]
fn test_ax_find_text_with_window_id() {
let windows = crate::macos::window::find_windows_by_app("Calculator")
.expect("find_windows_by_app should succeed");
let calc_window = windows
.first()
.expect("Calculator must be running for this test");
println!("Calculator window id: {}", calc_window.id);
let results =
find_text("9", Some(calc_window.id)).expect("find_text with window_id should succeed");
println!(
"AX find_text results for '9' (window_id={}):",
calc_window.id
);
for m in &results {
println!(
" '{}' at ({:.1}, {:.1}) bounds=({:.1}, {:.1}, {:.1}x{:.1})",
m.text, m.x, m.y, m.bounds.x, m.bounds.y, m.bounds.width, m.bounds.height
);
}
assert!(
!results.is_empty(),
"Should find at least one match for '9' with window_id"
);
}
#[test]
#[ignore]
fn test_ax_element_at_point_calculator() {
let matches = find_text("5", None).expect("find_text should succeed");
let button = matches
.iter()
.find(|m| m.text == "5")
.expect("Should find the '5' button");
let result = element_at_point(button.x, button.y, Some("Calculator"))
.expect("element_at_point should succeed");
println!(
"element_at_point result: {}",
serde_json::to_string_pretty(&result).unwrap()
);
assert!(result.get("role").is_some(), "Should have a role");
assert!(result.get("bounds").is_some(), "Should have bounds");
assert!(result.get("pid").is_some(), "Should have a pid");
assert!(result.get("app_name").is_some(), "Should have an app_name");
}
#[test]
#[ignore]
fn test_ax_element_at_point_system_wide() {
let matches = find_text("5", None).expect("find_text should succeed");
let button = matches
.iter()
.find(|m| m.text == "5")
.expect("Should find the '5' button");
let result =
element_at_point(button.x, button.y, None).expect("element_at_point should succeed");
println!(
"element_at_point (system-wide): {}",
serde_json::to_string_pretty(&result).unwrap()
);
assert!(result.get("role").is_some(), "Should have a role");
}
#[test]
fn ax_dispatch_error_from_press_code() {
assert!(AXDispatchError::from_press_code(0).is_none());
assert!(matches!(
AXDispatchError::from_press_code(-25206),
Some(AXDispatchError::NotDispatchable)
));
match AXDispatchError::from_press_code(-25204) {
Some(AXDispatchError::AxError(-25204)) => (),
other => panic!("expected AxError(-25204), got {:?}", other),
}
}
#[test]
fn ax_dispatch_error_from_set_value_code() {
assert!(AXDispatchError::from_set_value_code(0).is_none());
assert!(matches!(
AXDispatchError::from_set_value_code(-25205),
Some(AXDispatchError::NotDispatchable)
));
assert!(matches!(
AXDispatchError::from_set_value_code(-25204),
Some(AXDispatchError::NotDispatchable)
));
match AXDispatchError::from_set_value_code(-25212) {
Some(AXDispatchError::AxError(-25212)) => (),
other => panic!("expected AxError(-25212), got {:?}", other),
}
}
#[test]
#[ignore]
fn test_element_bbox_calculator_five_button() {
let (nodes, refs) = collect_ax_tree_indexed(Some("Calculator"))
.expect("collect_ax_tree_indexed should succeed");
let five = nodes
.iter()
.find(|n| n.name.as_deref() == Some("5"))
.expect("Calculator should expose a '5' button");
let r = refs
.get(&five.uid)
.expect("refs map must contain every uid");
let bbox =
unsafe { element_bbox(r.as_raw()) }.expect("5 button should expose position + size");
assert!(bbox.w > 0.0);
assert!(bbox.h > 0.0);
let node_bbox = five
.bbox
.expect("node bbox should be populated during walk");
assert!((node_bbox.x - bbox.x).abs() < 0.5);
assert!((node_bbox.y - bbox.y).abs() < 0.5);
}
#[test]
fn test_ax_ref_retain_release_balance_against_cfdata() {
use core_foundation::base::{CFGetRetainCount, CFRetain, CFTypeRef, TCFType};
use core_foundation::data::CFData;
let d = CFData::from_buffer(&[1u8, 2, 3, 4, 5, 6, 7, 8]);
let raw: CFTypeRef = d.as_concrete_TypeRef() as CFTypeRef;
unsafe {
CFRetain(raw);
}
let before = unsafe { CFGetRetainCount(raw) };
assert!(
(2..isize::MAX).contains(&before),
"expected a finite retain count >= 2, got {before} — CFData should be heap-allocated"
);
let aref = unsafe { super::AXRef::from_create(raw as *mut _) };
let during = unsafe { CFGetRetainCount(raw) };
assert_eq!(
during, before,
"from_create must not CFRetain — transfer ownership of existing +1"
);
let clone = aref.clone();
let during2 = unsafe { CFGetRetainCount(raw) };
assert_eq!(during2, before, "Arc clone must not touch CFRetain");
drop(clone);
let after_clone_drop = unsafe { CFGetRetainCount(raw) };
assert_eq!(
after_clone_drop, before,
"dropping a clone while other Arcs live must not CFRelease"
);
drop(aref);
let after_last_drop = unsafe { CFGetRetainCount(raw) };
assert_eq!(
after_last_drop,
before - 1,
"final Drop must CFRelease exactly once (count {before} -> {after_last_drop})"
);
let before_get = unsafe { CFGetRetainCount(raw) };
let aref2 = unsafe { super::AXRef::from_get(raw as *mut _) };
let during_get = unsafe { CFGetRetainCount(raw) };
assert_eq!(during_get, before_get + 1, "from_get must CFRetain once");
drop(aref2);
let after_get_drop = unsafe { CFGetRetainCount(raw) };
assert_eq!(
after_get_drop, before_get,
"from_get + drop must be net-zero"
);
}
}