use super::{A11yNode, A11yOptions, NodeId};
use crate::extensions::computer::{backend, state::Rect};
use std::{
ffi::{c_char, c_void},
ptr,
};
const CF_UTF8: u32 = 0x0800_0100;
const AX_VALUE_CG_POINT: u32 = 1;
const AX_VALUE_CG_SIZE: u32 = 2;
#[repr(C)]
struct CGPoint {
x: f64,
y: f64,
}
#[repr(C)]
struct CGSize {
width: f64,
height: f64,
}
type CFRef = *const c_void;
#[link(name = "CoreFoundation", kind = "framework")]
unsafe extern "C" {
static kCFBooleanTrue: CFRef;
static kCFBooleanFalse: CFRef;
fn CFRelease(cf: CFRef);
fn CFRetain(cf: CFRef) -> CFRef;
fn CFGetTypeID(cf: CFRef) -> usize;
fn CFStringGetTypeID() -> usize;
fn CFArrayGetTypeID() -> usize;
fn CFBooleanGetTypeID() -> usize;
fn CFArrayGetCount(array: CFRef) -> isize;
fn CFArrayGetValueAtIndex(array: CFRef, index: isize) -> CFRef;
fn CFStringGetLength(text: CFRef) -> isize;
fn CFStringGetMaximumSizeForEncoding(length: isize, encoding: u32) -> isize;
fn CFStringGetCString(text: CFRef, buffer: *mut c_char, size: isize, encoding: u32) -> bool;
fn CFStringCreateWithCString(alloc: CFRef, text: *const c_char, encoding: u32) -> CFRef;
fn CFBooleanGetValue(value: CFRef) -> bool;
}
#[link(name = "ApplicationServices", kind = "framework")]
unsafe extern "C" {
fn AXUIElementCreateApplication(pid: i32) -> CFRef;
fn AXUIElementCopyAttributeValue(element: CFRef, attribute: CFRef, value: *mut CFRef) -> i32;
fn AXUIElementSetAttributeValue(element: CFRef, attribute: CFRef, value: CFRef) -> i32;
fn AXUIElementPerformAction(element: CFRef, action: CFRef) -> i32;
fn AXUIElementCopyActionNames(element: CFRef, names: *mut CFRef) -> i32;
fn AXValueGetValue(value: CFRef, the_type: u32, out: *mut c_void) -> bool;
}
struct Owned(CFRef);
impl Owned {
fn new(raw: CFRef) -> Option<Self> {
(!raw.is_null()).then_some(Owned(raw))
}
fn string(text: &str) -> Option<Self> {
let c = std::ffi::CString::new(text).ok()?;
Self::new(unsafe { CFStringCreateWithCString(ptr::null(), c.as_ptr(), CF_UTF8) })
}
fn raw(&self) -> CFRef {
self.0
}
}
impl Drop for Owned {
fn drop(&mut self) {
if !self.0.is_null() {
unsafe { CFRelease(self.0) };
}
}
}
fn attribute(element: CFRef, name: &str) -> Option<Owned> {
let key = Owned::string(name)?;
let mut value: CFRef = ptr::null();
let status = unsafe { AXUIElementCopyAttributeValue(element, key.raw(), &mut value) };
if status != 0 {
return None;
}
Owned::new(value)
}
fn as_string(value: CFRef) -> Option<String> {
unsafe {
if CFGetTypeID(value) != CFStringGetTypeID() {
return None;
}
let length = CFStringGetLength(value);
let size = CFStringGetMaximumSizeForEncoding(length, CF_UTF8) + 1;
if size <= 1 {
return Some(String::new());
}
let mut buffer = vec![0i8; size as usize];
if !CFStringGetCString(value, buffer.as_mut_ptr(), size, CF_UTF8) {
return None;
}
std::ffi::CStr::from_ptr(buffer.as_ptr())
.to_str()
.ok()
.map(str::to_string)
}
}
fn string_attribute(element: CFRef, name: &str) -> String {
attribute(element, name)
.and_then(|value| as_string(value.raw()))
.unwrap_or_default()
}
fn children(element: CFRef) -> Vec<Owned> {
let Some(list) = attribute(element, "AXChildren") else {
return Vec::new();
};
unsafe {
if CFGetTypeID(list.raw()) != CFArrayGetTypeID() {
return Vec::new();
}
let count = CFArrayGetCount(list.raw());
(0..count)
.filter_map(|index| {
let child = CFArrayGetValueAtIndex(list.raw(), index);
if child.is_null() {
None
} else {
Owned::new(CFRetain(child))
}
})
.collect()
}
}
fn role_of(element: CFRef) -> String {
string_attribute(element, "AXRole")
}
fn name_of(element: CFRef) -> String {
for key in ["AXTitle", "AXDescription"] {
let text = string_attribute(element, key);
if !text.is_empty() {
return text;
}
}
attribute(element, "AXValue")
.and_then(|value| as_string(value.raw()))
.unwrap_or_default()
}
fn states_of(element: CFRef) -> Vec<String> {
let mut out = Vec::new();
if let Some(value) = attribute(element, "AXEnabled") {
unsafe {
if CFGetTypeID(value.raw()) == CFBooleanGetTypeID() && CFBooleanGetValue(value.raw()) {
out.push("enabled".to_string());
}
}
}
if let Some(value) = attribute(element, "AXFocused") {
unsafe {
if CFGetTypeID(value.raw()) == CFBooleanGetTypeID() && CFBooleanGetValue(value.raw()) {
out.push("focused".to_string());
}
}
}
if let Some(value) = attribute(element, "AXSelected") {
unsafe {
if CFGetTypeID(value.raw()) == CFBooleanGetTypeID() && CFBooleanGetValue(value.raw()) {
out.push("selected".to_string());
}
}
}
out
}
fn actions_of(element: CFRef) -> Vec<String> {
let mut out = Vec::new();
let mut raw: CFRef = ptr::null();
let status = unsafe { AXUIElementCopyActionNames(element, &mut raw) };
if status == 0
&& let Some(list) = Owned::new(raw)
{
unsafe {
if CFGetTypeID(list.raw()) == CFArrayGetTypeID() {
for index in 0..CFArrayGetCount(list.raw()) {
let item = CFArrayGetValueAtIndex(list.raw(), index);
if let Some(name) = as_string(item)
&& name.contains("Press")
{
out.push("invoke".to_string());
}
}
}
}
}
if attribute(element, "AXValue").is_some() && out.is_empty() {
out.push("set_value".to_string());
}
out
}
fn rect_of(element: CFRef) -> Option<Rect> {
let position = attribute(element, "AXPosition")?;
let size = attribute(element, "AXSize")?;
let mut point = CGPoint { x: 0.0, y: 0.0 };
let mut extent = CGSize {
width: 0.0,
height: 0.0,
};
unsafe {
if !AXValueGetValue(
position.raw(),
AX_VALUE_CG_POINT,
&mut point as *mut CGPoint as *mut c_void,
) {
return None;
}
if !AXValueGetValue(
size.raw(),
AX_VALUE_CG_SIZE,
&mut extent as *mut CGSize as *mut c_void,
) {
return None;
}
}
if extent.width <= 0.0 || extent.height <= 0.0 {
return None;
}
Some(Rect {
x: point.x.round() as i32,
y: point.y.round() as i32,
width: extent.width.round() as u32,
height: extent.height.round() as u32,
})
}
pub fn available() -> bool {
true
}
struct Walk<'a> {
options: &'a A11yOptions,
visited: usize,
nodes: Vec<NodeId>,
out: Vec<A11yNode>,
}
impl Walk<'_> {
fn element(&mut self, element: CFRef, path: String, depth: usize) {
if self.visited >= self.options.max_visited || self.nodes.len() >= self.options.max_nodes {
return;
}
self.visited += 1;
let name = name_of(element);
let role = role_of(element);
let rect = rect_of(element);
let states = states_of(element);
let actions = if self.options.patterns {
actions_of(element)
} else {
Vec::new()
};
if !(self.options.skip_empty && name.is_empty() && actions.is_empty() && rect.is_none()) {
let index = self.nodes.len();
self.nodes.push(NodeId {
bus: path.split('.').next().unwrap_or("0").to_string(),
path: path.clone(),
name: name.clone(),
role: role.clone(),
});
self.out.push(A11yNode {
index,
depth,
role,
name,
description: String::new(),
automation_id: String::new(),
states,
rect,
actions,
interfaces: Vec::new(),
has_children: false,
});
}
if depth >= self.options.max_depth {
return;
}
let list = children(element);
if !list.is_empty()
&& let Some(node) = self.out.last_mut()
{
node.has_children = true;
}
for (position, child) in list.iter().enumerate() {
if self.nodes.len() >= self.options.max_nodes {
break;
}
let child_path = format!("{path}.{position}");
self.element(child.raw(), child_path, depth + 1);
}
}
}
fn root_for(pid: u32) -> Result<Owned, String> {
let raw = unsafe { AXUIElementCreateApplication(pid as i32) };
Owned::new(raw).ok_or_else(|| format!("could not create an AX element for pid {pid}"))
}
pub async fn read_tree(
window: Option<u64>,
options: A11yOptions,
) -> Result<(Vec<A11yNode>, Vec<NodeId>), String> {
let target = match window {
Some(id) => backend::window(id)?,
None => backend::windows()?
.into_iter()
.find(|w| w.focused)
.ok_or_else(|| "nothing is focused; pass a window id".to_string())?,
};
if target.pid == 0 {
return Err(format!("window {} reports no process id", target.id));
}
let root = root_for(target.pid)?;
let mut walk = Walk {
options: &options,
visited: 0,
nodes: Vec::new(),
out: Vec::new(),
};
walk.element(root.raw(), target.pid.to_string(), 0);
if walk.out.is_empty() {
return Err(format!(
"window {} exposes no accessibility tree; grant Accessibility permission to prux and retry",
target.id
));
}
Ok((walk.out, walk.nodes))
}
fn element_for(target: &NodeId) -> Result<Owned, String> {
let pid: u32 = target
.bus
.parse()
.map_err(|_| format!("{} is not a process id", target.bus))?;
let root = root_for(pid)?;
let mut element = root;
let mut segments = target.path.split('.').skip(1).peekable();
while let Some(segment) = segments.next() {
let index: usize = segment
.parse()
.map_err(|_| format!("{} is not a node path", target.path))?;
let list = children(element.raw());
element = list.into_iter().nth(index).ok_or_else(|| {
format!(
"[STALE_REF] {} is no longer at that position; read the tree again",
target.path
)
})?;
}
let name = name_of(element.raw());
let role = role_of(element.raw());
if name != target.name || role != target.role {
return Err(format!(
"[STALE_REF] the element at {} is now {role} {name:?}, not {} {:?}; read the tree again",
target.path, target.role, target.name
));
}
Ok(element)
}
fn perform(element: CFRef, action: &str) -> Result<(), String> {
let name = Owned::string(action).ok_or_else(|| "invalid action name".to_string())?;
let status = unsafe { AXUIElementPerformAction(element, name.raw()) };
if status == 0 {
Ok(())
} else {
Err(format!("AX action {action} failed with {status}"))
}
}
fn set_bool(element: CFRef, name: &str, value: bool) -> Result<(), String> {
let key = Owned::string(name).ok_or_else(|| "invalid attribute name".to_string())?;
let raw = if value {
unsafe { kCFBooleanTrue }
} else {
unsafe { kCFBooleanFalse }
};
let status = unsafe { AXUIElementSetAttributeValue(element, key.raw(), raw) };
if status == 0 {
Ok(())
} else {
Err(format!(
"AX attribute {name} could not be set (status {status})"
))
}
}
pub async fn do_action(target: &NodeId, index: u32) -> Result<(), String> {
if index != 0 {
return Err("AX exposes named actions, not numbered ones; use action_by_name".to_string());
}
action_by_name(target, "invoke").await
}
pub async fn action_by_name(target: &NodeId, wanted: &str) -> Result<(), String> {
let element = element_for(target)?;
match wanted {
"invoke" | "select" | "toggle" => perform(element.raw(), "AXPress"),
"expand" => perform(element.raw(), "AXPress"),
"collapse" => perform(element.raw(), "AXPress"),
other => Err(format!("unknown action {other:?}")),
}
}
pub async fn read_text(target: &NodeId) -> Result<String, String> {
let element = element_for(target)?;
let text = attribute(element.raw(), "AXValue")
.and_then(|value| as_string(value.raw()))
.or_else(|| {
let description = string_attribute(element.raw(), "AXDescription");
(!description.is_empty()).then_some(description)
})
.unwrap_or_default();
Ok(text)
}
pub async fn set_text(target: &NodeId, text: &str) -> Result<(), String> {
let element = element_for(target)?;
let key = Owned::string("AXValue").ok_or_else(|| "invalid attribute name".to_string())?;
let value = Owned::string(text).ok_or_else(|| "text could not be encoded".to_string())?;
let status = unsafe { AXUIElementSetAttributeValue(element.raw(), key.raw(), value.raw()) };
if status == 0 {
Ok(())
} else {
Err(format!(
"{} does not accept text (AX status {status})",
target.name
))
}
}
pub async fn read_value(target: &NodeId) -> Result<String, String> {
read_text(target).await
}
pub async fn grab_focus(target: &NodeId) -> Result<(), String> {
let element = element_for(target)?;
set_bool(element.raw(), "AXFocused", true)
}
pub async fn select_text(_target: &NodeId, _start: i32, _end: i32) -> Result<(), String> {
Err(
"select_text is not wired up on macOS yet; use computer_key with cmd+a / shift+arrows"
.to_string(),
)
}
pub async fn select_child(target: &NodeId, index: i32) -> Result<(), String> {
if index != 0 {
return Err("select on macOS presses the node itself; pass index 0".to_string());
}
action_by_name(target, "select").await
}
pub async fn caret_offset(_target: &NodeId) -> Option<i32> {
None
}
pub async fn node_rect(target: &NodeId) -> Option<Rect> {
let element = element_for(target).ok()?;
rect_of(element.raw())
}