macOS accessibility / Rust

The accessibility crate on docs.rs: AXUIElement::system_wide(), and what the page leaves out

M
Matthew Diakonov
8 min read

Direct answer (verified 2026-06-16)

AXUIElement::system_wide() -> Self lives in the accessibility crate (v0.2.0, re-exported from the ui_element module). It is an infallible constructor that returns the root system-wide accessibility element of macOS, a safe wrapper over the C call AXUIElementCreateSystemWide(). You walk down from it to reach the focused app, the menu bar, and every window on screen.

Source of record: docs.rs/accessibility → AXUIElement. Raw FFI binding: accessibility-sys::AXUIElementCreateSystemWide.

If you searched docs.rs for this, you have probably already found the signature. The signature is the easy part. What the reference page cannot tell you is what happens when you actually call system_wide() and try to walk the tree it roots: the walker bundled with the crate quietly skips every application window, and a depth-first traversal of the system-wide root never terminates. Both are fixable in a few lines. Neither is documented.

The code below is the macOS traversal layer from Terminator's tree_search.rs. Terminator is a Playwright-style desktop automation framework; it ships on Windows (UI Automation) today, with macOS accessibility support on the roadmap, and this file is the record of how it walked real macOS apps from the system-wide root using this exact crate.

The three ways into the tree

AXUIElement gives you three entry points. system_wide() is the broad one. The other two scope you to a single process and are the path you want once you know which app you are driving.

AXUIElement constructors (accessibility v0.2.0)

FeatureReturnsWhen to use it
system_wide()-> Self (infallible). Root of the whole desktop AX tree.AXUIElementCreateSystemWide(). Start here when you want focus, the menu bar, or to cross app boundaries.
application(pid: pid_t)-> Self (infallible). Root element for one running process.AXUIElementCreateApplication(pid). Scopes the walk to a single app you already have a PID for.
application_with_bundle(bundle_id: &str)-> Result<Self, Error>. Resolves a bundle id to a running app.Fails if the bundle is not running. There is also application_with_bundle_timeout(id, Duration) to wait for launch.

application(pid) and system_wide() are infallible. application_with_bundle() returns Result because the bundle may not be running.

src/main.rs
use accessibility::AXUIElement;

// whole desktop, focus-aware, crosses app boundaries
let root = AXUIElement::system_wide();

// one process you already have a PID for (fast path)
let app = AXUIElement::application(pid);

// resolve a bundle id to a running app
let safari = AXUIElement::application_with_bundle("com.apple.Safari")?;

Trap 1: the default TreeWalker never enters a window

The crate ships a TreeWalker that recurses through the AXChildren attribute. That is correct for most nodes and wrong for application nodes. An app does not list its windows as children: they live behind the AXWindows and AXMainWindow attributes. Walk only children from an app root and you step into the app and immediately hit nothing, so every button, field, and menu item below the window goes unseen.

The fix is to read windows explicitly, then walk children inside each one. The very first line of Terminator's walker file is a comment to this effect: "default TreeWalker does not traverse windows, so we need to traverse windows manually."

Children-only (default) — sees nothing

use accessibility::{AXUIElement, TreeWalker};

let root = AXUIElement::system_wide();

// The walker that ships with the crate descends AXChildren only.
let walker = TreeWalker::default();
walker.walk(&root, &my_visitor);

// You enter each app... and stop.
// Its buttons and text fields live inside windows,
// and windows are NOT children. You see nothing.

Windows-aware (Terminator) — sees the whole app

// Terminator: crates/terminator/src/platforms/tree_search.rs
// "default TreeWalker does not traverse windows,
//  so we need to traverse windows manually"

let mut flow = visitor.enter_element(root);
if flow == TreeWalkerFlow::Continue {
    // 1. windows hang off AXWindows, not AXChildren
    if let Ok(windows) = root.windows() {
        for w in windows.iter() { self.walk_one(&w, visitor); }
    }
    // 2. the focused/main window via AXMainWindow
    if let Ok(main) = root.main_window() {
        self.walk_one(&main, visitor);
    }
    // 3. THEN the ordinary children inside each window
    if let Ok(children) = root.attribute(&self.attr_children) {
        for c in children.into_iter() { self.walk_one(&c, visitor); }
    }
}

Trap 2: the system-wide tree has cycles, so the walk never ends

"Tree" is generous. The macOS accessibility graph contains back-references: a child can point at its parent, some containers re-expose ancestors, and following both directions sends a naive depth-first walk around in circles until it overflows the stack or runs the machine out of memory. You need a visited set.

Here is the part that surprises people: AXUIElement implements neither Hash nor Eq, so you cannot drop it into a HashSet and the obvious approach does not even compile. The fix is a thin wrapper that derives identity from Core Foundation's CFEqual and CFHash on the underlying CFTypeRef, which is the only comparison that reflects true AX element identity.

cycle guard, tree_search.rs (paraphrased)
// AXUIElement implements neither Hash nor Eq,
// so you cannot put it in a HashSet directly.
struct AXUIElementWrapper { element: AXUIElement }

impl PartialEq for AXUIElementWrapper {
    fn eq(&self, other: &Self) -> bool {
        unsafe {
            // CFEqual on the raw CFTypeRef = correct AX identity
            core_foundation::base::CFEqual(
                self.element.as_concrete_TypeRef() as _,
                other.element.as_concrete_TypeRef() as _,
            ) != 0
        }
    }
}
impl Eq for AXUIElementWrapper {}

impl Hash for AXUIElementWrapper {
    fn hash<H: Hasher>(&self, state: &mut H) {
        unsafe {
            let h = core_foundation::base::CFHash(
                self.element.as_concrete_TypeRef() as _,
            );
            state.write_u64(h as u64);
        }
    }
}

With the wrapper in a HashSet, the walker checks membership before recursing, counts how many cycles it skipped for debugging, and caps absolute recursion at a MAX_DEPTH of 100 as a backstop. Terminator logs the cycle count after each traversal: on a busy desktop it is rarely zero.

Everything the reference page does not say

Pin these five up before you write your first walker. Each one cost real debugging time to discover from the type signature alone.

Field notes for AXUIElement::system_wide()

  • An application element exposes its windows through AXWindows / AXMainWindow, not AXChildren, so the default TreeWalker dead-ends one level into every app.
  • The system-wide AX graph contains cycles; a depth-first walk without dedup recurses until it stack-overflows or exhausts memory.
  • AXUIElement is neither Hash nor Eq, so the obvious HashSet-based visited set does not compile until you wrap it.
  • CFEqual / CFHash on the underlying CFTypeRef are the only correct way to compare two AXUIElement handles for identity.
  • Constructing system_wide() never fails, but the first attribute read fails with a permission error if the process is not trusted for Accessibility.

One more thing: the constructor lies about permissions

system_wide() and application(pid) are infallible. They return an element no matter what. That is convenient and slightly dangerous, because the failure you actually care about, missing Accessibility permission, does not surface until the first attribute read. Call .windows() or any attribute() getter on an untrusted process and you get an Err (a permission error from the underlying AXUIElementCopyAttributeValue), not a panic. So an empty tree from a perfectly valid root almost always means the host process is not listed under System Settings → Privacy & Security → Accessibility, not that your traversal is wrong.

Building desktop automation on accessibility APIs?

Talk through the AX traversal traps, the Windows UIA story, and where Terminator fits before you sink a week into your own walker.

Questions developers ask about this crate

Frequently asked questions

What is AXUIElement::system_wide() in the accessibility crate?

It is an infallible constructor on the AXUIElement struct (accessibility crate v0.2.0, re-exported from the ui_element module) that returns the system-wide accessibility element: the root of the macOS accessibility tree. Under the hood it is a safe Rust wrapper over the C function AXUIElementCreateSystemWide() from accessibility-sys. From the element it returns you can read AXFocusedApplication, the menu bar, and reach into any running app, which is why automation tools use it as the entry point.

Where is system_wide() in the docs.rs module tree?

The accessibility crate's lib.rs does `pub use ui_element::*;`, so the canonical path is accessibility::ui_element::AXUIElement, but in your own code you just write `use accessibility::AXUIElement;`. On docs.rs the method is listed under the AXUIElement struct page at /accessibility/latest/accessibility/ui_element/struct.AXUIElement.html. The lower-level raw binding AXUIElementCreateSystemWide lives in the accessibility-sys crate.

Why does my traversal from system_wide() miss every button and field?

Because the TreeWalker shipped in the accessibility crate descends through the AXChildren attribute, and an application element does not expose its windows as ordinary children. Windows hang off the AXWindows and AXMainWindow attributes instead. If you walk only children from the application root you enter the app and immediately hit a dead end. You have to read windows() and main_window() explicitly, then walk children inside each window. Terminator's tree_search.rs opens with exactly this note.

Why does walking the system-wide tree hang or run out of memory?

The macOS accessibility graph is not a strict tree. Parent and child references form cycles (an element points to its parent which points back to it, and some containers re-expose ancestors), so a naive depth-first walk revisits nodes forever. You need to deduplicate visited elements. AXUIElement does not implement Hash or Eq, so you cannot drop it into a HashSet directly. The fix is a wrapper that hashes and compares via Core Foundation's CFHash and CFEqual on the underlying CFTypeRef.

Is system_wide() or application(pid) the right starting point?

Use application(pid) or application_with_bundle(bundle_id) when you already know which app you are driving: it scopes the walk to one process and is dramatically faster because you never traverse other apps. Use system_wide() when you need cross-application context: the currently focused app (AXFocusedApplication), the system menu bar, or global hotkey targets. system_wide() is the broad entry point; the per-app constructors are the fast path.

Does the accessibility crate need accessibility permissions to use system_wide()?

Constructing the element does not, but reading any attribute off it does. The process calling into the AX APIs must be trusted in System Settings > Privacy & Security > Accessibility, or AXUIElementCopyAttributeValue returns kAXErrorAPIDisabled / a permission error and you get empty trees. The accessibility crate surfaces that as an Err on the attribute call, not on the constructor.

How does Terminator use the accessibility crate?

Terminator is a Playwright-style desktop automation framework. Its shipping platform today is Windows via UI Automation, with macOS accessibility support on the roadmap. The macOS traversal layer was built on the accessibility crate's AXUIElement, and the file crates/terminator/src/platforms/tree_search.rs is the worked example this guide draws from: a windows-aware TreeWalker and a CFEqual/CFHash cycle guard around the system-wide root.

terminatorDesktop automation SDK
© 2026 terminator. All rights reserved.

How did this page land for you?

React to reveal totals

Comments ()

Leave a comment to see what others are saying.

Public and anonymous. No signup.