macOS accessibility / Rust
AXObserver and the eiz/accessibility crate: the safe wrapper does not exist
If you searched for AXObserver alongside eiz/accessibility, you almost certainly hit the same wall everyone does: you found the crate, you found AXUIElement and TreeWalker, and you could not find an observer anywhere. You are not missing it. It is not there.
Direct answer (verified 2026-06-16)
The high-level accessibility crate (eiz/accessibility) does not expose AXObserver. Its public surface is ElementFinder, TreeWalker, TreeVisitor, TreeWalkerFlow, Error, and the action, attribute, and ui_element modules. To receive AX notifications you bridge to its companion FFI crate accessibility-sys, which has the complete observer API: AXObserverCreate, AXObserverAddNotification, and AXObserverGetRunLoopSource. The crate's own README says it directly: "The high level safe bindings are pretty spotty, but accessibility-sys is complete."
Verified against docs.rs/accessibility and github.com/eiz/accessibility.
Two crates, one of them is the read path and one of them is everything else
eiz/accessibility ships as two crates that live in the same repository. accessibility is the safe, ergonomic layer. accessibility-sys is the raw extern "C" binding to Apple's ApplicationServices accessibility functions. The safe layer wraps the parts that were easy to make safe, which is the pull model: ask an element for an attribute, walk a tree. The push model, where the system calls you back when something changes, was left at the raw FFI level.
So the answer to "how do I use AXObserver with eiz/accessibility" is not a function call you missed. It is a layer change. You keep using the safe crate for elements and attributes, and you reach down to accessibility-sys for the observer itself.
The surface you have vs the surface you need
// What the high-level `accessibility` crate (eiz/accessibility) gives you.
// docs.rs/accessibility exports exactly this surface:
use accessibility::{
AXUIElement, // a wrapped element ref
AXAttribute, // typed attribute access
AXUIElementAttributes,
TreeWalker, // depth-first tree traversal
TreeVisitor, // enter_element / exit_element
ElementFinder, // find one element by predicate
Error,
};
// You can read the tree:
let app = AXUIElement::application(pid);
let title = app.attribute(&AXAttribute::title())?;
// You can walk it. But there is no AXObserver type here.
// No `Observer`, no `Notification`, no `add_notification`, nothing.
// The crate is a pull model: you ask, it answers. It never pushes.The bridge, four steps
An AXObserver is not a poll loop. It is a Core Foundation run-loop source that the system fires when a registered notification happens inside a target process. Wiring it up is four mechanical steps, and missing any one of them produces an observer that compiles, runs, and never calls you back.
Create the observer for a pid
AXObserverCreate(pid, callback, &mut observer). The observer is scoped to one process, identified by process id, not by element. The callback is a bare extern "C" function pointer, so it cannot capture anything from its environment.
AXObserverCreate(pid_t, AXObserverCallback, *mut AXObserverRef) -> AXErrorRegister each notification
AXObserverAddNotification(observer, element, notification, refcon). Register against the application element for app-wide events like focus changes, or against a specific element for value changes. The notification is a CFStringRef built from one of the kAX...Notification constants.
AXObserverAddNotification(observer, element, CFStringRef, *mut c_void) -> AXErrorSchedule the run-loop source
AXObserverGetRunLoopSource(observer) returns a CFRunLoopSourceRef. Add it to a running CFRunLoop in kCFRunLoopDefaultMode. This is the step that is easy to skip and impossible to debug from the symptom alone: until the source is on a live run loop, the observer delivers nothing.
AXObserverGetRunLoopSource(observer) -> CFRunLoopSourceRefKeep it alive and run the loop
Hold the observer for the full lifetime of the watch and run a run loop on that thread. Drop the observer, let the box behind your refcon free, or let the run loop exit, and every callback stops at once.
A working watch, end to end
Here is the whole thing for a focus-change watch on one process. It uses the safe accessibility crate to grab the application element and the raw accessibility-sys functions for the observer, with core-foundation handling the run loop and string conversions.
use accessibility_sys::{
AXObserverAddNotification, AXObserverCreate, AXObserverGetRunLoopSource,
AXObserverRef, AXUIElementRef, kAXFocusedUIElementChangedNotification,
};
use core_foundation::base::TCFType;
use core_foundation::runloop::{kCFRunLoopDefaultMode, CFRunLoop};
use core_foundation::string::CFString;
use std::os::raw::c_void;
use std::ptr;
// 1. The callback. It is extern "C" and cannot capture state, so any
// context you need must arrive through the refcon pointer.
unsafe extern "C" fn on_notification(
_observer: AXObserverRef,
_element: AXUIElementRef,
notification: core_foundation::string::CFStringRef,
_refcon: *mut c_void,
) {
let note = CFString::wrap_under_get_rule(notification);
println!("AX notification fired: {}", note.to_string());
// Do NOT release `notification` or `element`: they are
// "get rule" references owned by the observer, not by you.
}
fn watch_focus(pid: i32) -> Result<(), i32> {
unsafe {
// 2. Create the observer for one target process (by pid).
let mut observer: AXObserverRef = ptr::null_mut();
let err = AXObserverCreate(pid, on_notification, &mut observer);
if err != 0 {
return Err(err); // kAXErrorSuccess is 0
}
// 3. Register the notifications you care about against an element.
// Use the application element as the root for app-wide events.
let app = accessibility::AXUIElement::application(pid);
let note = CFString::from_static_string(
kAXFocusedUIElementChangedNotification,
);
AXObserverAddNotification(
observer,
app.as_concrete_TypeRef(),
note.as_concrete_TypeRef(),
ptr::null_mut(), // refcon: pass a *mut to your state here
);
// 4. The observer is inert until its run-loop source is scheduled.
// This is the step people miss: no source on a run loop, no callbacks.
let source = AXObserverGetRunLoopSource(observer);
CFRunLoop::get_current().add_source(
&core_foundation::runloop::CFRunLoopSource::wrap_under_get_rule(source),
kCFRunLoopDefaultMode,
);
// 5. Keep `observer` alive for the lifetime of the watch, and
// run a loop so the source can deliver. Dropping the observer
// or letting the run loop exit stops every notification.
CFRunLoop::run_current();
}
Ok(())
}The four things that silently break it
None of these throw a compile error. Each one produces an observer that looks correct and delivers nothing, or worse, crashes the app you are watching. If your callback is not firing, walk this list before touching anything else.
Pre-flight for a silent observer
- The run-loop source is added to a running CFRunLoop. AXObserverGetRunLoopSource returns it, but you have to schedule it and keep a loop running. No loop, no callbacks.
- The process is accessibility-trusted. AXIsProcessTrusted() returns true. Without the permission, AXObserverCreate can succeed and still never deliver.
- Your refcon points at heap state that outlives the registration. A pointer to a stack value is dangling by the time the notification fires.
- You wrap callback element and notification refs with the get-rule, not the create-rule. Over-releasing them double-frees inside the host process.
How a real framework sits on this crate
Terminator is an open source desktop automation framework that drives apps through native accessibility APIs rather than OCR or pixel matching. Its macOS tree walker is built directly on the eiz crate. The file crates/terminator/src/platforms/tree_search.rs opens with:
// TLDR: default TreeWalker does not traverse windows,
// so we need to traverse windows manually
use accessibility::{AXAttribute, AXUIElement, AXUIElementAttributes, Error};That first comment is the same lesson as the AXObserver gap, one layer up. The safe crate gives you a TreeWalker, but it does not descend into an application's windows, so Terminator wrote TreeWalkerWithWindows to walk the window list by hand. The pattern repeats across the whole eiz surface: lean on the safe layer for the common read path, and reach past it the moment you need something the safe wrapper never covered, window traversal, or observers, or anything event-driven.
That is the honest shape of building on macOS accessibility in Rust today. The high-level crate saves you real work on the part it covers, and you should expect to drop to accessibility-sys for the rest. An observer is simply the most common place people hit that edge, which is why this exact pairing turns up in searches at all.
Building an observer-driven automation layer on macOS?
We live in the eiz/accessibility and accessibility-sys layers daily for Terminator. Tell us what you are watching for and we will compare notes on the run-loop and trust-prompt edges.
Questions developers actually ask about this
Frequently asked questions
Does the eiz/accessibility crate expose AXObserver?
No. The high-level `accessibility` crate (github.com/eiz/accessibility, published as `accessibility` on crates.io) exports ElementFinder, TreeWalker, TreeVisitor, TreeWalkerFlow, Error, and the action, attribute, and ui_element modules. There is no Observer type, no Notification type, and no add_notification method anywhere in its public surface. The crate is a pull model: you call AXUIElement::attribute() and it answers. It never pushes events to you. AXObserver lives only in the companion FFI crate `accessibility-sys`.
Why is AXObserver in accessibility-sys but not in accessibility?
Because the safe crate was never finished for the observer path. Its own README states it plainly: 'The high level safe bindings are pretty spotty, but accessibility-sys is complete.' The author wrapped the read path (elements, attributes, tree walking) in safe Rust because that is what most assistive-tech tools needed first. The notification path, which requires a C function pointer, a refcon for context, and manual run-loop scheduling, is harder to wrap safely, so it was left at the raw FFI level in accessibility-sys.
What is the exact signature of AXObserverCreate in accessibility-sys?
`pub unsafe extern "C" fn AXObserverCreate(application: pid_t, callback: AXObserverCallback, outObserver: *mut AXObserverRef) -> AXError`. The first argument is a process id, not an element: an observer is scoped to one process. The callback is `unsafe extern "C" fn(observer: AXObserverRef, element: AXUIElementRef, notification: CFStringRef, refcon: *mut c_void)`, a bare C function pointer that cannot capture a closure environment. AXError is an i32 where 0 (kAXErrorSuccess) means success.
My callback never fires. What did I forget?
Almost always the run-loop source. AXObserverCreate and AXObserverAddNotification do not arm anything. The observer stays inert until you call AXObserverGetRunLoopSource(observer) and add that CFRunLoopSourceRef to a running CFRunLoop in kCFRunLoopDefaultMode. If you registered notifications and then returned from your function, or if you never started a run loop on that thread, the observer is alive but mute. The second most common cause is that the process is not accessibility-trusted; call AXIsProcessTrusted() and check it returns true before debugging anything else.
How do I pass state into the extern "C" callback?
Through the refcon. AXObserverAddNotification takes a final `*mut c_void` argument that is handed back to your callback verbatim on every event. Box your state, leak or otherwise keep the box alive, and pass `Box::into_raw(state) as *mut c_void`. Inside the callback, recover it with `&*(refcon as *const YourState)`. Do not pass a pointer to a stack value: the stack frame is gone by the time the notification fires. And do not drop the box while the observer is still registered, or the callback dereferences freed memory.
Do I need to release the element and notification passed to my callback?
No. Both arrive as Core Foundation 'get rule' references: the observer owns them, you are only borrowing for the duration of the callback. Wrap them with `wrap_under_get_rule` (not `wrap_under_create_rule`) if you use the core-foundation crate, which takes a +0 reference and will not over-release. Calling CFRelease on them yourself, or wrapping with the create-rule variant, double-frees and crashes the host app, not just your code, because you are running inside its accessibility bridge.
Which notification name constants does accessibility-sys provide?
The common ones are exported as static strings: kAXFocusedUIElementChangedNotification, kAXValueChangedNotification, kAXWindowCreatedNotification, kAXMainWindowChangedNotification, kAXFocusedWindowChangedNotification, kAXUIElementDestroyedNotification, kAXTitleChangedNotification, and the selection and row-count families. You pass them to AXObserverAddNotification as a CFStringRef. Build one with `CFString::from_static_string(kAXFocusedUIElementChangedNotification)` and hand over `as_concrete_TypeRef()`.
Does Terminator use the eiz/accessibility crate?
Yes, on the macOS side. The tree walker at crates/terminator/src/platforms/tree_search.rs opens with `use accessibility::{AXAttribute, AXUIElement, AXUIElementAttributes, Error};` and builds a custom TreeWalkerWithWindows on top of it. The file's first line is a warning that explains why the custom walker exists: the crate's default TreeWalker does not descend into application windows, so Terminator walks the window list manually. It is a concrete example of living inside the eiz crate's safe surface for the read path while reaching past it where the abstraction stops.
Should I poll the tree instead of using AXObserver?
Only if events do not matter to you. Polling AXUIElement attributes on a timer is simple and entirely within the safe crate, and for a one-shot script it is fine. But polling cannot tell you the instant focus moved, a window opened, or a text field's value changed, and it burns CPU re-walking a tree that did not change. AXObserver is the push model: the system calls you exactly when the thing happens. For anything long-running, an agent watching for state changes, a recorder capturing a workflow, the observer is the correct primitive even though it costs you a trip through accessibility-sys.
Related reading
macOS accessibility UI tree automation
Walking the AX tree on macOS, role and attribute lookups, and where the abstraction leaks.
Accessibility API desktop automation
Fire control patterns instead of moving the mouse, on the write path side of the accessibility tree.
Accessibility API for AI agents
Why an agent should read the accessibility tree and listen for changes rather than screenshot and poll.
Comments (••)
Leave a comment to see what others are saying.Public and anonymous. No signup.