AXUIElement::system_wide() in the Rust accessibility crate: the three patterns the docs don't ship
The function body is one line. The code you have to put around it, before any of it does anything useful in a real automation tool, is roughly two hundred. Here are those lines, with line numbers from a deleted-but-still-canonical Rust adapter that used this exact code path in production.
Direct answer (verified 2026-05-06)
AXUIElement::system_wide() in the eiz/accessibility crate is a one-line wrapper around AXUIElementCreateSystemWide() from the macOS ApplicationServices framework. It returns the root pseudo-element of the macOS Accessibility API, the same handle Swift code gets from AXUIElementCreateSystemWide().
The returned AXUIElement is not Send and not Sync, so for any threaded or async use you wrap it in Arc<AXUIElement> inside a newtype with unsafe impl Send and unsafe impl Sync. You also gate construction on AXIsProcessTrustedWithOptions, otherwise every attribute read on the element silently returns nothing.
Source for the function body: eiz/accessibility/blob/master/accessibility/src/ui_element.rs. Reference adapter (4,368 lines, deleted on 2025-12-16): terminator commit 0c11011c.
The function body is one line
Here is the entire constructor, copied from accessibility/src/ui_element.rs on eiz/accessibility. It calls into the C function AXUIElementCreateSystemWide() (declared in accessibility-sys) and wraps the returned AXUIElementRef using Core Foundation's create-rule (the caller owns one retain).
// From eiz/accessibility, accessibility/src/ui_element.rs
impl AXUIElement {
pub fn system_wide() -> Self {
unsafe { Self::wrap_under_create_rule(AXUIElementCreateSystemWide()) }
}
pub fn application(pid: pid_t) -> Self {
unsafe { Self::wrap_under_create_rule(AXUIElementCreateApplication(pid)) }
}
}That is it. No initialization, no permission check, no error path. The call cannot fail; you always get an element back. Everything that makes this useful, and everything that makes it tricky, lives in the code you write next.
The four-step lifecycle of one system_wide() call
1. Construct
AXUIElement::system_wide() calls AXUIElementCreateSystemWide; the body is one line.
2. Wrap
Place inside Arc and a newtype with unsafe impl Send + Sync, otherwise Tokio rejects it.
3. Gate
Call AXIsProcessTrustedWithOptions before any attribute read; bail on false.
4. Walk
AXFocusedApplication then AXFocusedUIElement; downcast each result to AXUIElement.
Pattern 1: the Send + Sync newtype
The accessibility crate does not implement Send or Sync on AXUIElement. The struct holds a Core Foundation pointer, and Rust auto-derives neither trait for raw-pointer-bearing types. The compiler is being correct: the crate maintainer has not certified, in code, that the type is thread-safe.
Apple says it is. The ApplicationServices Accessibility API documentation states that AXUIElement functions can be called from any thread; the framework serializes calls against the application's main run loop internally. So you have a type that is safe at the framework level but unsafe in the type system. The accepted fix is a newtype with two unsafe impls and a SAFETY comment that pins the assumption to the API documentation.
The deleted Terminator macOS adapter declared this at the top of crates/terminator/src/platforms/macos.rs. You can read the file in git at git show 0c11011c~1:crates/terminator/src/platforms/macos.rs.
// terminator/crates/terminator/src/platforms/macos.rs (deleted in 0c11011c)
// lines 76-103
#[derive(Clone)]
pub struct ThreadSafeAXUIElement(Arc<AXUIElement>);
// SAFETY: AXUIElement is safe to send and share between threads as Apple's
// accessibility API is designed to be called from any thread. The underlying
// Core Foundation objects manage their own thread safety.
unsafe impl Send for ThreadSafeAXUIElement {}
unsafe impl Sync for ThreadSafeAXUIElement {}
impl ThreadSafeAXUIElement {
pub fn new(element: AXUIElement) -> Self {
Self(Arc::new(element))
}
pub fn system_wide() -> Self {
Self(Arc::new(AXUIElement::system_wide()))
}
pub fn application(pid: i32) -> Self {
Self(Arc::new(AXUIElement::application(pid)))
}
}Three things to notice. The wrapper holds an Arc, not the raw element, so cloning is cheap and reference counting is correct. The new constructor takes an existing AXUIElement (you get those out of attribute reads, see Pattern 3). The two static constructors mirror the high-level crate's API: system_wide() for the global root, application(pid) for a specific process. Without this newtype, every Tokio task that touches an AX element fails to compile with a Send bound error.
Pattern 2: the permission gate
The call to system_wide() itself does not require any permission. You always get a valid element handle back. What needs permission is everything you would do with that handle: reading any attribute, listing any child, performing any action.
The failure mode without permission is silent, not loud. Reading AXFocusedApplication on an unauthorized process returns kAXErrorAPIDisabled (-25200) or kAXErrorNotImplemented (-25208) depending on the macOS version, and attribute_names() returns an empty list. Your code looks like it works, then nothing happens, and you spend two hours wondering whether you traversed the tree wrong.
The fix is to call AXIsProcessTrustedWithOptions before any attribute read, with the AXTrustedCheckOptionPrompt option set to true so the system shows the user the prompt the first time. The accessibility crate does not export this function as of writing, so you link it directly through #[link(name = "ApplicationServices", kind = "framework")]. The Terminator adapter did this in MacOSEngine::new, at lines 119 to 148:
// terminator/crates/terminator/src/platforms/macos.rs (deleted in 0c11011c)
// MacOSEngine::new, lines 119-148
let accessibility_enabled = unsafe {
use core_foundation::dictionary::CFDictionaryRef;
#[link(name = "ApplicationServices", kind = "framework")]
unsafe extern "C" {
fn AXIsProcessTrustedWithOptions(options: CFDictionaryRef) -> bool;
}
let check_attr = CFString::new("AXTrustedCheckOptionPrompt");
let options = CFDictionary::from_CFType_pairs(&[(
check_attr.as_CFType(),
CFBoolean::true_value().as_CFType(),
)])
.as_concrete_TypeRef();
AXIsProcessTrustedWithOptions(options)
};
if !accessibility_enabled {
return Err(AutomationError::PermissionDenied(
"Accessibility permissions not granted".to_string(),
));
}
Ok(Self {
system_wide: ThreadSafeAXUIElement::system_wide(),
use_background_apps,
activate_app,
})The first time the user runs your binary, macOS pops a prompt asking for accessibility permission. They click OK, then they have to manually toggle your binary on in System Settings, Privacy & Security, Accessibility. They then have to fully quit and relaunch your process, because the trust check is read once and cached for the life of the process.
Pattern 3: the focused-element walk
A thing the system-wide element is good for, that application(pid) can't do, is finding the element under the keyboard cursor right now without knowing which app it is in. You read AXFocusedApplication off the system-wide element to learn which app is foreground, then read AXFocusedUIElement off that app to learn which control inside it has focus.
From system_wide() to the focused control
Two things to know. Both attribute reads return CFType, the Core Foundation top-level dynamic type, not AXUIElement directly; you have to call .downcast::<AXUIElement>() and handle the None case where the attribute existed but did not contain an element. Either read can return kAXErrorNoValue if no app is foreground or the foreground app has no focusable control (think Finder with the desktop selected). That is a normal state, not an error.
// terminator/crates/terminator/src/platforms/macos.rs (deleted in 0c11011c)
// get_focused_element, lines 2386-2410
fn get_focused_element(&self) -> Result<UIElement, AutomationError> {
let system_wide = AXUIElement::system_wide();
let focused_app_attr = AXAttribute::new(&CFString::new("AXFocusedApplication"));
let focused_element_attr = AXAttribute::new(&CFString::new("AXFocusedUIElement"));
// Step 1: read AXFocusedApplication off system_wide and downcast to AXUIElement
let focused_app = system_wide.attribute(&focused_app_attr).map_err(|e| {
AutomationError::ElementNotFound(
format!("Failed to get focused application: {}", e),
)
})?;
if let Some(app_element) = focused_app.downcast::<AXUIElement>() {
// Step 2: read AXFocusedUIElement off the app and downcast again
let focused_element = app_element.attribute(&focused_element_attr).map_err(|e| {
AutomationError::ElementNotFound(format!(
"Failed to get focused element within application: {}",
e
))
})?;
if let Some(element) = focused_element.downcast::<AXUIElement>() {
Ok(self.wrap_element(ThreadSafeAXUIElement::new(element)))
} else {
Err(AutomationError::ElementNotFound(
"Focused element attribute did not contain a valid AXUIElement".to_string(),
))
}
} else {
Err(AutomationError::ElementNotFound(
"AXFocusedApplication did not contain a valid AXUIElement".to_string(),
))
}
}For everything other than the focused-element case, prefer AXUIElement::application(pid). Walking children of AXFocusedApplication is a slow path, only works for whichever app is foreground at the moment of the call, and is racy. The deleted adapter cached one wrapped element per app PID and refreshed by PID enumeration; it used the system-wide element only as a parent reference and as the entry point for focused-element queries.
The error code you will see most often: -25204
The single most common AX error code in real-world code is kAXErrorNoValue (-25204). It does not mean failure; it means the attribute exists for that role but is not currently set, or the element legitimately has no value for it. The system-wide element returns -25204 for almost everything you might call on it directly: .role(), .title(), .position(), .size(). That is by design: the system-wide element exists to hold global attributes (AXFocusedApplication), not to represent a window or control.
Filter -25204 specifically before logging or reporting. Every other -252xx code is a real condition you want to surface:
-25200 kAXErrorAPIDisabled: accessibility off in System Settings.-25201 kAXErrorInvalidUIElement: the element was destroyed or its app died.-25204 kAXErrorNoValue: expected on the system-wide root and on legitimate empty attributes.-25212 kAXErrorCannotComplete: transient; retry once after a short delay.-25214 kAXErrorAttributeUnsupported: this role does not have this attribute; expected during generic walks.
// terminator/crates/terminator/src/platforms/macos.rs (deleted in 0c11011c)
// wrap_element, line 169 — and again at line 1004
if let Err(e) = ax_element.0.role() {
if let accessibility::Error::Ax(code) = e {
if code != -25204 {
// kAXErrorNoValue is expected on the system-wide root and on
// elements that genuinely have no role; do not warn on those.
let err_str = error_string(code);
debug!(
"Warning: Potentially invalid AXUIElement: {:?} (error: {})",
e, err_str
);
}
}
}Things the docs don't warn you about
The shortlist of system_wide() gotchas
- AXUIElement is not Send and not Sync; the accessibility crate does not impl either.
- .role() on the system-wide element returns -25204 (kAXErrorNoValue); filter it.
- Without AXIsProcessTrustedWithOptions, attribute reads silently return empty lists.
- The Mac App Sandbox blocks system_wide() permanently; ship outside the sandbox.
- wrap_under_create_rule moves ownership; do not also call CFRelease on the ref.
- AXFocusedApplication returns CFType; downcast to AXUIElement before chaining.
The first three are non-negotiable on any project that uses the accessibility crate seriously. The fourth one (sandbox) is what makes Mac App Store distribution effectively impossible for any tool built on this API; you ship Developer ID notarized binaries directly to users instead. The fifth one (create-rule) is hidden by the high-level crate, but if you drop down to accessibility-sys and call AXUIElementCreateSystemWide yourself, you must wrap the returned ref under create rule, not get rule. The sixth one (downcast) trips up first-time users of the Core Foundation type system; everything that comes back from an attribute read is a CFType and has to be downcast to the concrete kind you expect.
Why the deleted file is still the reference
On 2025-12-16, Terminator removed crates/terminator/src/platforms/macos.rs (4,368 lines) and crates/terminator/src/platforms/linux.rs (2,962 lines) in commit 0c11011c. The framework is now Windows-only by compile_error! on non-Windows targets; the line lives in crates/terminator/src/platforms/mod.rs.
That doesn't make the deleted file useless to anyone working with the accessibility crate today. The patterns above (Send + Sync newtype, AXIsProcessTrustedWithOptions gate, focused-element walk, kAXErrorNoValue filter) are stable across macOS releases and don't depend on Terminator-specific types. Pull the file with git show 0c11011c~1:crates/terminator/src/platforms/macos.rs and read it as a reference: 130-some distinct AXAttribute strings, the full click strategy ladder, key event composition, monitor enumeration, all using exactly the constructors covered here.
On the active Terminator product side, our focus is the Windows UIA path; that is where the framework ships, where the MCP agent lives, and where the workflow recorder runs. If you are on macOS today and you need a working desktop automation surface that has shipped, you have two honest options: write the Rust against the accessibility crate yourself using the patterns above, or run the Windows MCP server in a VM and drive it from your Mac. We are happy to talk through either route if you want to compare notes; the booking link below is the fastest way to get on a call.
Stuck on the macOS AX path in Rust?
A 20-minute call with the team that wrote (and then deleted) 4,368 lines of this code. Bring your stack trace.
AXUIElement::system_wide() in Rust, FAQ
What does AXUIElement::system_wide() return in the Rust accessibility crate?
It returns an AXUIElement that wraps Apple's AXUIElementCreateSystemWide() C function. The body in eiz/accessibility's accessibility/src/ui_element.rs is one line: unsafe { Self::wrap_under_create_rule(AXUIElementCreateSystemWide()) }. The returned element is the root of the macOS accessibility hierarchy: it is not a single window or application, it is the system-wide pseudo-element that holds attributes like AXFocusedApplication and AXFocusedUIElement and lets you walk into any running app's tree. Calling .role() on it returns kAXErrorNoValue (-25204) because the system-wide element has no role of its own; that is expected, not a bug.
Why does AXUIElement need a manual unsafe Send + Sync wrapper to use across threads?
Because the underlying type holds a Core Foundation pointer and the accessibility crate does not implement Send or Sync. If you stash an AXUIElement in a tokio task, an Arc you share with another thread, or any Send-bound async context, the borrow checker rejects it. Apple's accessibility API is documented as thread-safe (you can call AXUIElementCopyAttributeValue from any thread, the framework serializes against the main run loop), but Rust does not know that. The fix is a newtype: pub struct ThreadSafeAXUIElement(Arc<AXUIElement>); unsafe impl Send for ThreadSafeAXUIElement {} unsafe impl Sync for ThreadSafeAXUIElement {}. The deleted Terminator macOS adapter declared exactly that at lines 77 to 84 of crates/terminator/src/platforms/macos.rs in commit 0c11011c~1, with a SAFETY comment explaining the reasoning.
Do I have to ask for accessibility permission before calling system_wide()?
system_wide() itself does not need permission, the function call cannot fail, you get an AXUIElement back unconditionally. Every attribute read on it does need permission. Without it, .attribute_names() returns an empty list and .attribute(AXFocusedApplication) returns kAXErrorNotImplemented or kAXErrorAPIDisabled. The accepted pattern is to gate engine construction on AXIsProcessTrustedWithOptions from ApplicationServices.framework, with AXTrustedCheckOptionPrompt set to true so the system shows the user the prompt the first time. The Terminator adapter did this at lines 119 to 148 of macos.rs: build the CFDictionary with the prompt option, call AXIsProcessTrustedWithOptions, and bail with PermissionDenied if the result is false. The user then has to add your binary to System Settings, Privacy & Security, Accessibility, and restart the process.
How do I get the currently focused element starting from system_wide()?
It is a two-step walk. The system-wide element does not directly hold the focused element; it holds AXFocusedApplication, which is the focused app's AXUIElement, and that app holds AXFocusedUIElement, which is the actual control under the keyboard cursor. In Rust with the accessibility crate, you read the AXFocusedApplication attribute, downcast the CFType result to AXUIElement, then read AXFocusedUIElement on that and downcast again. If either downcast returns None or either read returns kAXErrorNoValue, there is no focused element right now (no app is foregrounded, or the app has no focusable control). The Terminator adapter implemented this at get_focused_element, lines 2386 to 2410 of macos.rs.
Can system_wide() reach a window or control inside an app, or do I need application(pid)?
You can reach anything from system_wide() in principle, by walking children of AXFocusedApplication, but that is the slow path and it only works for the foreground app. For any other running app you call AXUIElement::application(pid) to get that app's tree directly. application(pid) is just AXUIElementCreateApplication wrapped under create rule. The PID comes from NSWorkspace.runningApplications or from sysinfo / proc enumeration in pure Rust. The deleted Terminator adapter cached one ThreadSafeAXUIElement per app and refreshed by PID at lines 2440 to 2460. system_wide() is the right root for global queries (focused app, key window) and for serving as a parent reference; per-app trees should always come from application(pid) for stability.
What error code should I expect when I call .role() on the system-wide element?
-25204, which is kAXErrorNoValue. The system-wide element is a pseudo-element: it exists to hold global attributes, it does not represent a window or control, so it has no AXRole, AXTitle, or AXSize. Treat -25204 as expected on this element and do not log it as a warning. Other -252xx codes are real failures: -25200 kAXErrorAPIDisabled (accessibility off in System Settings), -25201 kAXErrorInvalidUIElement (the AXUIElement was destroyed or the app died), -25202 kAXErrorInvalidUIElementObserver (used by AXObserver only), -25204 kAXErrorNoValue (the attribute exists for that role but is not currently set), -25212 kAXErrorCannotComplete (transient, retry), -25214 kAXErrorAttributeUnsupported (the role does not have this attribute). The Terminator adapter filtered -25204 specifically at line 169 of macos.rs and reused that filter at line 1004.
Does system_wide() work in a sandboxed macOS app?
No. The macOS App Sandbox blocks AXUIElementCopyAttributeValue against any process you do not own, and AXIsProcessTrustedWithOptions returns false even when the user has approved the app. Mac App Store distribution and TCC sandbox profiles both disable accessibility client API outside the sandbox boundary. The practical answer for any automation tool that uses the accessibility crate's system_wide() is that the binary has to ship outside the sandbox: a Developer ID signed binary distributed directly, a Homebrew formula, a cargo install, or a notarized app with the com.apple.security.app-sandbox entitlement explicitly disabled. There is no Apple-blessed escape hatch from inside the sandbox.
Is the accessibility crate the only Rust path to AXUIElement?
No, but it is the only one with a high-level wrapper. accessibility-sys is the unsafe FFI layer; it gives you AXUIElementRef and the raw C functions and nothing else. accessibility-sys-ng is a maintained fork. cidre is a broader Apple framework binding that includes some AX types. objc2 currently does not ship AXUIElement bindings (issue #624 in the objc2 repo is the open ask). For new code, the choice is between accessibility (high-level, no Send/Sync) and a thin custom wrapper over accessibility-sys (more code, full control). Both end up calling the same AXUIElementCreateSystemWide. The Send/Sync, permission, and focused-element patterns in this guide apply to either path.
Why did Terminator delete its macOS adapter, and what does that mean for someone using the accessibility crate today?
Terminator's main crate was Windows-first and the macOS path was diverging in features and reliability. On 2025-12-16, commit 0c11011c removed crates/terminator/src/platforms/macos.rs (4,368 lines) and crates/terminator/src/platforms/linux.rs (2,962 lines) so the team could focus on Windows UIA. The Rust source code is still available in git history at commit 0c11011c~1 and remains, as far as we can tell, the most complete public Rust example of using AXUIElement::system_wide() in production: it covers thread safety, permission gating, focused-element traversal, the kAXErrorNoValue filter, browser-specific bypass for AXPress, value setters, key event composition, and monitor enumeration. If you are starting fresh on macOS automation in Rust today, that file is the reference implementation. The fact that Terminator stopped shipping it does not invalidate the patterns; it means we made a product call about where the team's bandwidth goes, not a technical call about the API.