#[cfg(target_os = "macos")]
pub(crate) use macos::AxBackend;
#[cfg(not(target_os = "macos"))]
pub(crate) use unsupported::AxBackend;
#[cfg(target_os = "macos")]
mod macos {
use std::cell::RefCell;
use std::collections::HashMap;
use std::ffi::c_void;
use std::ptr::{self, NonNull};
use objc2_core_foundation::{
CFArray, CFBoolean, CFEqual, CFRetained, CFString, CFType, CGPoint, CGSize, Type,
};
use super::super::{Frame, OsWindow, WindowBackend, WindowId};
fn app_pids_via_ps(pids: &[u32]) -> HashMap<u32, u32> {
if pids.is_empty() {
return HashMap::new();
}
let list = pids
.iter()
.map(ToString::to_string)
.collect::<Vec<_>>()
.join(",");
match std::process::Command::new("/bin/ps")
.args(["-o", "pid=,ppid=", "-p", &list])
.output()
{
Ok(out) => parse_ppids(&String::from_utf8_lossy(&out.stdout)),
Err(err) => {
tracing::warn!("cannot resolve owning applications via ps: {err}");
HashMap::new()
}
}
}
fn parse_ppids(stdout: &str) -> HashMap<u32, u32> {
stdout
.lines()
.filter_map(|line| {
let mut fields = line.split_whitespace();
let pid = fields.next()?.parse().ok()?;
let ppid = fields.next()?.parse().ok()?;
Some((pid, ppid))
})
.collect()
}
const MESSAGING_TIMEOUT_SECS: f32 = 2.0;
const AX_SUCCESS: i32 = 0;
const AX_VALUE_CGPOINT: u32 = 1;
const AX_VALUE_CGSIZE: u32 = 2;
const SUBROLE_STANDARD_WINDOW: &str = "AXStandardWindow";
#[allow(unsafe_code)]
#[link(name = "ApplicationServices", kind = "framework")]
extern "C" {
fn AXIsProcessTrusted() -> u8;
fn AXUIElementCreateApplication(pid: i32) -> *mut c_void;
fn AXUIElementCopyAttributeValue(
element: *mut c_void,
attribute: *const CFString,
value: *mut *const c_void,
) -> i32;
fn AXUIElementSetAttributeValue(
element: *mut c_void,
attribute: *const CFString,
value: *const c_void,
) -> i32;
fn AXUIElementSetMessagingTimeout(element: *mut c_void, timeout: f32) -> i32;
fn AXValueCreate(value_type: u32, value: *const c_void) -> *const c_void;
fn AXValueGetValue(value: *const c_void, value_type: u32, out: *mut c_void) -> u8;
}
type AxObject = CFRetained<CFType>;
#[allow(unsafe_code)]
fn own(ptr: *const c_void) -> Option<AxObject> {
let ptr = NonNull::new(ptr.cast_mut())?;
Some(unsafe { CFRetained::from_raw(ptr.cast::<CFType>()) })
}
fn raw(object: &AxObject) -> *mut c_void {
CFRetained::as_ptr(object).as_ptr().cast::<c_void>()
}
#[allow(unsafe_code)]
fn attribute(element: *mut c_void, name: &str) -> Option<AxObject> {
let name = CFString::from_str(name);
let mut value: *const c_void = ptr::null();
let status = unsafe {
AXUIElementCopyAttributeValue(element, CFRetained::as_ptr(&name).as_ptr(), &mut value)
};
if status == AX_SUCCESS {
own(value)
} else {
None
}
}
fn string_attribute(element: *mut c_void, name: &str) -> Option<String> {
let value = attribute(element, name)?;
Some(value.downcast_ref::<CFString>()?.to_string())
}
fn bool_attribute(element: *mut c_void, name: &str) -> bool {
attribute(element, name)
.and_then(|value| value.downcast_ref::<CFBoolean>().map(CFBoolean::value))
.unwrap_or(false)
}
#[allow(unsafe_code)]
fn value_attribute<T: Default>(element: *mut c_void, name: &str, value_type: u32) -> Option<T> {
let value = attribute(element, name)?;
let mut out = T::default();
let ok = unsafe {
AXValueGetValue(
raw(&value).cast_const(),
value_type,
(&raw mut out).cast::<c_void>(),
)
};
(ok != 0).then_some(out)
}
#[allow(unsafe_code)]
fn set_value_attribute<T>(
element: *mut c_void,
name: &str,
value_type: u32,
value: &T,
) -> Result<(), i32> {
let wrapped = unsafe { AXValueCreate(value_type, (&raw const *value).cast::<c_void>()) };
let Some(wrapped) = own(wrapped) else {
return Err(i32::MIN);
};
let name = CFString::from_str(name);
let status = unsafe {
AXUIElementSetAttributeValue(
element,
CFRetained::as_ptr(&name).as_ptr(),
raw(&wrapped).cast_const(),
)
};
if status == AX_SUCCESS {
Ok(())
} else {
Err(status)
}
}
pub(crate) struct AxBackend {
elements: RefCell<HashMap<u32, Vec<AxObject>>>,
}
impl AxBackend {
pub(crate) fn new() -> Self {
Self {
elements: RefCell::new(HashMap::new()),
}
}
#[allow(unsafe_code)]
fn application(app_pid: u32) -> Option<AxObject> {
let app = own(unsafe { AXUIElementCreateApplication(app_pid as i32) })?;
unsafe { AXUIElementSetMessagingTimeout(raw(&app), MESSAGING_TIMEOUT_SECS) };
Some(app)
}
fn snapshot(window: &AxObject, focused: Option<&AxObject>) -> Option<OsWindow> {
let element = raw(window);
let position: CGPoint = value_attribute(element, "AXPosition", AX_VALUE_CGPOINT)?;
let size: CGSize = value_attribute(element, "AXSize", AX_VALUE_CGSIZE)?;
let subrole = string_attribute(element, "AXSubrole");
Some(OsWindow {
title: string_attribute(element, "AXTitle").unwrap_or_default(),
frame: Frame {
x: position.x,
y: position.y,
width: size.width,
height: size.height,
},
minimized: bool_attribute(element, "AXMinimized"),
fullscreen: bool_attribute(element, "AXFullScreen"),
standard: subrole.map_or(true, |role| role == SUBROLE_STANDARD_WINDOW),
focused: focused.is_some_and(|f| CFEqual(Some(window), Some(f))),
})
}
}
impl WindowBackend for AxBackend {
fn trusted(&self) -> bool {
#[allow(unsafe_code)]
let trusted = unsafe { AXIsProcessTrusted() };
trusted != 0
}
fn app_pids(&self, pids: &[u32]) -> HashMap<u32, u32> {
app_pids_via_ps(pids)
}
fn windows(&self, app_pid: u32) -> Result<Vec<OsWindow>, String> {
let app = Self::application(app_pid)
.ok_or_else(|| format!("no accessible application for process {app_pid}"))?;
let element = raw(&app);
let list = attribute(element, "AXWindows").ok_or_else(|| {
format!(
"process {app_pid} exposed no windows (is Accessibility granted to omni-dev?)"
)
})?;
let list = list
.downcast_ref::<CFArray>()
.ok_or_else(|| format!("process {app_pid} returned a non-array window list"))?;
let focused = attribute(element, "AXFocusedWindow");
let mut snapshots = Vec::new();
let mut elements = Vec::new();
for index in 0..list.count() {
#[allow(unsafe_code)]
let item = unsafe { list.value_at_index(index) };
let Some(item) = NonNull::new(item.cast_mut()) else {
continue;
};
#[allow(unsafe_code)]
let window = unsafe { item.cast::<CFType>().as_ref() }.retain();
if let Some(snapshot) = Self::snapshot(&window, focused.as_ref()) {
snapshots.push(snapshot);
elements.push(window);
}
}
self.elements.borrow_mut().insert(app_pid, elements);
Ok(snapshots)
}
fn set_frame(&self, id: WindowId, frame: Frame) -> Result<Frame, String> {
let elements = self.elements.borrow();
let window = elements
.get(&id.app_pid)
.and_then(|list| list.get(id.index))
.ok_or_else(|| "the window was not enumerated by this operation".to_string())?;
let element = raw(window);
let size = CGSize {
width: frame.width,
height: frame.height,
};
let position = CGPoint {
x: frame.x,
y: frame.y,
};
let mut last_error = None;
for step in [
set_value_attribute(element, "AXSize", AX_VALUE_CGSIZE, &size),
set_value_attribute(element, "AXPosition", AX_VALUE_CGPOINT, &position),
set_value_attribute(element, "AXSize", AX_VALUE_CGSIZE, &size),
] {
if let Err(status) = step {
last_error = Some(status);
}
}
let read = || -> Option<Frame> {
let position: CGPoint = value_attribute(element, "AXPosition", AX_VALUE_CGPOINT)?;
let size: CGSize = value_attribute(element, "AXSize", AX_VALUE_CGSIZE)?;
Some(Frame {
x: position.x,
y: position.y,
width: size.width,
height: size.height,
})
};
match (read(), last_error) {
(Some(actual), _) => Ok(actual),
(None, Some(status)) => {
Err(format!("the window rejected the move (AXError {status})"))
}
(None, None) => Err("the window's geometry could not be read back".to_string()),
}
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn parses_ps_output() {
let out = " 28112 65488\n 28113 65488\n 65488 1\n";
let map = parse_ppids(out);
assert_eq!(map.get(&28112).copied(), Some(65488));
assert_eq!(map.get(&65488).copied(), Some(1));
assert_eq!(map.len(), 3);
}
#[test]
fn ignores_unparseable_ps_lines() {
let out = "\n ps: 999: no such process\n 10 20\nnotanumber x\n";
assert_eq!(parse_ppids(out), HashMap::from([(10, 20)]));
}
#[test]
fn resolving_no_pids_never_shells_out() {
assert!(app_pids_via_ps(&[]).is_empty());
}
#[test]
fn ps_resolves_this_process_to_its_real_parent() {
let me = std::process::id();
let resolved = app_pids_via_ps(&[me]);
assert_eq!(
resolved.get(&me).copied(),
Some(std::os::unix::process::parent_id()),
"ps should report this test process's real parent"
);
}
#[test]
fn an_unknown_pid_simply_has_no_entry() {
assert!(!app_pids_via_ps(&[0]).contains_key(&0));
}
#[test]
fn the_accessibility_ffi_is_callable_without_a_grant() {
use super::super::super::WindowBackend;
let backend = AxBackend::new();
let _ = backend.trusted();
match backend.windows(std::process::id()) {
Ok(windows) => assert!(
windows.iter().all(|w| w.frame.width >= 0.0),
"a window reported a negative width, so the CGSize decode is wrong"
),
Err(err) => assert!(!err.is_empty(), "a failure should say why"),
}
let unknown = WindowId {
app_pid: std::process::id(),
index: 9_999,
};
assert!(backend
.set_frame(
unknown,
Frame {
x: 0.0,
y: 0.0,
width: 100.0,
height: 100.0,
},
)
.is_err());
}
}
}
#[cfg(not(target_os = "macos"))]
mod unsupported {
use std::collections::HashMap;
use super::super::{Frame, OsWindow, WindowBackend, WindowId};
pub(crate) struct AxBackend;
impl AxBackend {
pub(crate) fn new() -> Self {
Self
}
}
impl WindowBackend for AxBackend {
fn trusted(&self) -> bool {
false
}
fn app_pids(&self, _pids: &[u32]) -> HashMap<u32, u32> {
HashMap::new()
}
fn windows(&self, _app_pid: u32) -> Result<Vec<OsWindow>, String> {
Err("window repositioning is only implemented on macOS".to_string())
}
fn set_frame(&self, _id: WindowId, _frame: Frame) -> Result<Frame, String> {
Err("window repositioning is only implemented on macOS".to_string())
}
}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn reports_itself_untrusted_so_callers_short_circuit() {
assert!(!AxBackend::new().trusted());
}
#[test]
fn every_other_method_fails_rather_than_faking_success() {
let backend = AxBackend::new();
assert!(backend.app_pids(&[1, 2, 3]).is_empty());
for err in [
backend.windows(1).expect_err("enumeration must fail"),
backend
.set_frame(
WindowId {
app_pid: 1,
index: 0,
},
Frame {
x: 0.0,
y: 0.0,
width: 10.0,
height: 10.0,
},
)
.expect_err("a write must fail"),
] {
assert!(err.contains("only implemented on macOS"), "{err}");
}
}
}
}