This is the core guide to AccessibilityControl, the main module of BetterSwiftAX. It assumes you already know roughly what the macOS Accessibility (AX) API does — AXUIElement, attributes, actions, observers — and explains how this framework maps onto it, what boilerplate it removes, and where its edges are.
For copy-paste examples, see recipes.md. For the window/Space/display layer (a separate substrate built on CoreGraphics private APIs, not AX), see window-control.md.
Four ideas carry the whole framework:
-
An
Accessibility.Elementis a remote handle. It wraps anAXUIElement, which refers to a UI object living in another process. Every attribute read, action, or hit test is an IPC round-trip into that process, and any of them can fail at any time (the app quit, the element disappeared, the app is hung). That is why essentially everything in this APIthrows. -
Accessing a member builds an accessor; calling it performs the IPC.
element.titledoesn't fetch anything — via@dynamicMemberLookupit constructs a typedAttribute<String>bound to that element. Invoking it like a function (try element.title()) is what actually talks to the target process. The same split applies to actions (element.pressbuilds anAction,try element.press()performs it) and mutable attributes (try element.position(assign: point)). -
Accessibility.Namesis the vocabulary — and the main extension point. The dynamic member lookup onElementresolves throughAccessibility.Names, a namespace of typed name definitions (title→Attribute<String>.Name,position→MutableAttribute<CGPoint>.Name,press→Action.Name, …). To teach the framework a new attribute — including private, app-specific ones — you extendNames. See Extending the vocabulary. -
Conversion is automatic at the boundary. The
AccessibilityConvertibleprotocol transparently boxes/unboxesAXValuestructs (CGPoint,CGSize,CGRect,Range<Int>),Elements, and arrays/dictionaries thereof. You never callAXValueCreate/AXValueGetValueor castCFTypeRefs yourself.
If you already know HIServices, this table is most of the learning curve:
| Raw HIServices pattern | BetterSwiftAX |
|---|---|
AXIsProcessTrustedWithOptions(...) |
Accessibility.isTrusted(shouldPrompt:) |
AXUIElementCreateApplication(pid) |
Accessibility.Element(pid:) |
AXUIElementCreateSystemWide() |
Accessibility.Element.systemWide |
AXUIElementCopyAttributeValue + AXError check + CFTypeRef cast |
let title: String = try element.title() |
AXValueCreate(.cgPoint, &p) + AXUIElementSetAttributeValue |
try element.position(assign: point) |
AXUIElementIsAttributeSettable |
try element.position.isSettable() |
AXUIElementCopyAttributeNames |
try element.supportedAttributes() |
AXUIElementGetAttributeValueCount |
try element.children.count() |
AXUIElementCopyAttributeValues(el, attr, index, count, &out) |
try element.children(range: 0..<10) or try element.children[3] |
AXUIElementCopyParameterizedAttributeValue |
try element.parameterizedAttribute(name)(param) |
AXUIElementPerformAction(el, kAXPressAction) |
try element.press() |
AXUIElementCopyActionNames |
try element.supportedActions() |
AXUIElementCopyElementAtPosition |
try element.hitTest(at: point) |
AXUIElementGetPid |
try element.pid() |
AXUIElementSetMessagingTimeout |
try element.setMessagingTimeout(5) |
AXObserverCreateWithInfoCallback + run-loop source + context pointer |
let token = try element.observe(.focusedWindowChanged) { info in … } |
_AXUIElementGetWindow (private) |
try element.window() → WindowControl.Window |
AXUIElementCopyHierarchy (private, via dlsym) |
try element.copyHierarchy(requesting:options:) |
The wrapper around AXUIElement. Create one from a pid (Element(pid:)), from the system-wide element (Element.systemWide), from a raw AXUIElement (Element(raw:)), or from an untyped CFTypeRef you got out of some other API (Element(erased:), failable). NSRunningApplication also has a convenience: app._accessibilityElement.
Elements are Equatable via CFEqual. The underlying raw: AXUIElement is always public — see Escape hatches.
All three are bound accessors: an (element, name) pair where the name is a phantom-typed string (Attribute<String>.Name is just a String at runtime, but carries the value type at compile time). What the phantom types buy you:
try element.title()returnsString— no cast, noCFTypeRef.- Read/write capability is in the type system:
titleis anAttribute(read-only),positionis aMutableAttribute(readable and assignable viatry element.position(assign: p)). You cannot accidentally write to a read-only attribute. - Parameterized attributes type both sides: a
ParameterizedAttribute<Range<Int>, String>is called astry attr(0..<100)and returnsString.
Attributes whose value is a collection get three extras (backed by the more efficient ranged AX calls): count(), callAsFunction(range:), and a throwing subscript — e.g. try app.appWindows.count() without fetching the array.
Same shape: element.press builds an Action, try element.press() performs it. element.supportedActions() enumerates what the element offers; an Action's description is the human-readable description supplied by the target app. Action.Name also has static members for every standard action (.press, .confirm, .showMenu, …) usable with element.action(.press).
try element.observe(.windowMoved) { info in … } wraps the whole AXObserver ceremony: creating the observer for the element's pid, installing its run-loop source, and bridging the C callback. It returns a Token; there is also a Combine flavor, element.publisher(for:), returning AnyCancellable.
Two rules, both about lifetime:
- You must retain the token. Dropping it cancels the subscription (
deinitcallscancel()). - The chosen run loop must actually be running. The default is
RunLoop.current— on the main thread that's fine; on an arbitrary background thread/queue, nothing will ever fire.
The info dictionary passed to your callback contains whatever the notification supplies, plus the triggering element under the key "AXUIElement" (as an Accessibility.Element).
Notification names are the same phantom-name pattern: Accessibility.Notification has static members for all standard notifications (.focusedWindowChanged, .windowCreated, .valueChanged, …), and NSAccessibility.Notification is accepted interchangeably.
Every non-.success AXError becomes a thrown AccessibilityError carrying the code plus the #fileID/#line of the call site — so an error printed from deep inside a traversal still tells you which call produced it. Codes worth knowing when debugging:
.noValue— the attribute exists but has no value right now (e.g. no focused window)..attributeUnsupported/.actionUnsupported— the element doesn't implement it at all..cannotComplete— usually the target app is hung, dead, or not responding to AX; considersetMessagingTimeout..apiDisabled— your process isn't trusted (seeAccessibility.isTrusted)..failureon an attribute read that you'd expect to succeed often means a type mismatch: the value arrived but couldn't convert to theValuetype you declared. Re-declare asAttribute<Any>and inspect what actually comes back.
The framework ships two parallel sets of name definitions; knowing which to reach for avoids confusion:
Accessibility.Names(Names+Standard.swift) — the curated, typed vocabulary that powers dynamic member lookup. Hand-maintained, covers the commonly used attributes/actions.- Generated plain-string constants —
Accessibility.AttributeKey,.Role,.Subrole,.Value,.ParameterizedAttributeKey, plus statics onAccessibility.NotificationandAction.Name. These are exhaustive, extracted verbatim from the HIServices headers (including doc comments) by thegenerate-ax-constantsplugin. Use them where an API wants raw strings:copyHierarchy(requesting: [Accessibility.AttributeKey.role, …]), comparingtry element.role() == Accessibility.Role.button, etc.
The generated files (Accessibility+AttributeKey.swift, +Role.swift, +Subrole.swift, +Notification.swift, +Action.swift, +Value.swift, +ParameterizedAttributeKey.swift) should not be hand-edited — regenerate them with:
swift package generate-ax-constants --allow-writing-to-package-directoryThis is the intended way to work with app-specific or undocumented attributes. Extend Accessibility.Names and dynamic member lookup picks it up everywhere:
extension Accessibility.Names {
// a read-only attribute holding an element
var chatTranscript: AttributeName<Accessibility.Element> { "AXChatTranscript" }
// a writable attribute
var enhancedUserInterface: MutableAttributeName<Bool> { "AXEnhancedUserInterface" }
// a parameterized attribute: parameter and return types both declared
var stringForRange: ParameterizedAttributeName<Range<Int>, String> {
.init(kAXStringForRangeParameterizedAttribute)
}
// an action
var scrollToTop: ActionName { "AXScrollToTop" }
}
// then, on any element:
let transcript = try app.chatTranscript()
try textField.enhancedUserInterface(assign: true)
let text = try textArea.stringForRange(0..<100)Name types are ExpressibleByStringLiteral, so one-off use without touching Names also works:
let attr = element.attribute(Accessibility.Attribute<String>.Name("AXOneOffThing"))
let value = try attr()element.children()/element.childrenInNavigationOrder()/element.parent()— one level.element.recursiveChildren()— lazy breadth-first sequence over the whole subtree. It is guarded by a traversal complexity budget (default 3,600 elements-with-children) and terminates early (with an os_log warning) rather than hanging on pathological or cyclic trees. Cycle detection by identity is impossible — see Sharp edges.element.recursivelyFindChild(withID:)— first descendant matchingAXIdentifier.element.firstChild(withRole: \.button)— first direct child with a given role.element.hitTest(at:)/element.elementAtScreenPoint(_:)— element under a screen point. Hit testing only works on application elements or.systemWide, not arbitrary sub-elements;elementAtScreenPointfalls back to system-wide automatically.element.dumpXML(to:)— pretty XML dump of a subtree (roles as tags, attributes inline, actions and sections included). Great for debugging and for feeding UI state to an LLM; supportsmaxDepth, role/attribute exclusion, and anexcludingPII:mode that strips titles, values, selected text, file names, etc.element.copyHierarchy(requesting:options:)— bulk snapshot in a single IPC round-trip via the privateAXUIElementCopyHierarchy. Dramatically faster than per-element attribute fetches for large trees. Returns aHierarchyResultkeyed by element, with per-attributeAttributeEntryvalues (value, true count when truncated, per-attribute error). Prefer this when you need many attributes across many elements (e.g. building an inspector); prefer plain accessors for a handful of reads.
The wrappers are designed to compose with the raw API rather than hide it:
element.rawis theAXUIElement; any raw HIServices call works on it. Wrap results back withElement(raw:)/Element(erased:).- Untyped values: declare
Attribute<Any>when an attribute's type varies (e.g.valueon different roles) and pattern-match, or useAccessibility.Structto decode anAXValueby hand. - Genuinely outside the wrappers:
- Entitlement-gated attributes — e.g.
AXClassNamerequires the privatecom.apple.private.accessibility.inspectionentitlement; the wrapper can name it but macOS will refuse. - Text-marker APIs (
AXTextMarkerRef,AXTextMarkerRangeRef) — the parameterized-attribute names exist in the generated constants, but the marker types themselves have no typed wrapper; work withAttribute<Any>/rawCFTypeRefs. - Anything needing exotic setup (
AXUIElementCreateWithRemoteToken, per-process trust manipulation, …).
- Entitlement-gated attributes — e.g.
Non-obvious invariants that will bite you (or an LLM) if unstated:
- Retain observer tokens. The subscription lives exactly as long as the
Token/AnyCancellable. - Observers need a running run loop (default
RunLoop.current). AXUIElementinstances are not pooled. Two fetches of the "same" UI object may return different object instances, and an instance may be reused for a different object later. Never dedupe by identity (ObjectIdentifier,Set); use==(CFEqual) for point comparisons only. This is also whyrecursiveChildren()uses a complexity budget instead of a visited-set.- Everything can throw at any time — elements are invalidated whenever the target app changes its UI. Treat
try?on individual reads as normal, not lazy. - Hung apps block you. AX calls into an unresponsive app stall for the default timeout; call
setMessagingTimeout(_:)on the element (or on.systemWideto change the global default for your process) before touching apps you don't control. - Type mismatches surface as thrown
.failure, not asnil. If a read inexplicably fails, re-try withAttribute<Any>and look at the real type. - Trust is required: call
Accessibility.isTrusted(shouldPrompt: true)early; the user grants access in System Settings → Privacy & Security → Accessibility. Sandboxed apps cannot use the AX API on other processes. - A few
Namesmembers are#if DEBUG-gated (decrement,minValue,maxValueinNames+Standard.swift) — they vanish in release builds. Useelement.action(.decrement)/ generated constants there instead. - Private API is involved:
element.window(),copyHierarchy, and everything inWindowControluse private symbols. They can break across macOS releases and are not App Store-safe.copyHierarchyresolves its symbol viadlsymat runtime and throws if unavailable.
| Target | What it is |
|---|---|
AccessibilityControl |
The AX wrapper — everything in this guide. import AccessibilityControl. |
WindowControl |
Windows, Spaces, displays, Dock via CoreGraphics + private CGS/SkyLight APIs. See window-control.md. |
CAccessibilityControl / CWindowControl |
C shims declaring the private symbols. |
AXConstantsGenerator + GenerateAXConstants plugin |
Codegen extracting constants from the HIServices headers into Accessibility+*.swift. |
BetterAccessibilityInspector/ |
A SwiftUI accessibility-tree inspector app built on this package — useful as a reference consumer. |