The Accessibility API is how VoiceOver reads the screen and how apps like Keyboard Maestro
press buttons in other apps. It's also how XCommander reads Xcode's menus, finds its panes,
reads and replaces the selected text, and runs commands. Almost all of that code lives in
Sources/Companion/XcodeContext.swift, Sources/XcodeMenus/ and
Sources/Companion/PaneController.swift.
Every app exposes its UI as a tree of elements (AXUIElement). An element has:
- a role:
AXWindow,AXMenuBar,AXMenuItem,AXButton,AXTextArea,AXScrollArea… - attributes you can read, and sometimes write:
AXTitle,AXPosition,AXSize,AXChildren,AXFocused,AXSelectedText,AXIdentifier… - actions you can perform:
AXPress,AXShowMenu,AXCancel… - notifications you can subscribe to:
AXWindowMoved,AXFocusedUIElementChanged…
You start from the application element and walk down:
let app = AXUIElementCreateApplication(pid)
var value: CFTypeRef?
AXUIElementCopyAttributeValue(app, kAXFocusedWindowAttribute as CFString, &value)
let window = value as! AXUIElementIt's a C API from the Carbon era, so it's verbose: every value comes back as a CFTypeRef you
have to cast, and every call returns an AXError you should check. The code wraps the common
calls in small helpers so the rest reads normally.
The best tool for this is the Accessibility Inspector, which ships with Xcode (Xcode menu → Open Developer Tool → Accessibility Inspector). Point it at any part of Xcode and it shows that element's role, attributes and actions. When I need to know "how would I find X in Xcode?", I start there.
Reading an attribute isn't like reading a property. It sends a message to Xcode, waits for Xcode's main thread to answer, and comes back. That has consequences:
- It's slow compared to normal code. Reading a window frame takes about 0.5 ms typically, and tens of milliseconds at worst. Walking Xcode's whole menu bar takes about 750 ms. That's why none of it happens in the event tap callback, and why the menu walk runs off the main thread.
- It can hang. If Xcode's main thread is busy (a modal sheet, a long build step), calls
block until a timeout. The default is 1.5 s. XCommander lowers it to 0.25 s with
AXUIElementSetMessagingTimeout, because a late answer is useless for placing a panel. - Elements belong to a process. When Xcode quits and relaunches (every update does), every
AXUIElementwe held becomes meaningless.appElement()inXcodeContext.swiftnotices the process ID changed and starts over.
XCommander's main way of running commands is pressing Xcode's own menu items. To do that it
needs an index of every menu item, which MenuScanner builds by walking
AXMenuBar → AXMenuBarItem → AXMenu → AXMenuItem → …. For each item it records:
| Attribute | What it gives |
|---|---|
AXIdentifier |
The Objective-C action behind the item, like buildActiveRunContext:. This is how commands are named. |
AXTitle |
The visible title, like "Build". Used for display only. |
AXMenuItemCmdChar, AXMenuItemCmdModifiers, AXMenuItemCmdVirtualKey |
The item's keyboard shortcut. |
AXEnabled |
Whether it can be pressed right now. |
The result is a MenuSnapshot, which Core turns into a MenuCatalogue, the lookup table that
resolves a command's MenuRef to a real item.
Some things I learned the hard way, all handled in the code:
- Use the identifier, not the title. Titles change between Xcode versions (Xcode 27 renamed four items XCommander binds) and between languages. Some titles even contain the name of the open file. The action identifier stays the same.
- The menus depend on what's on screen. The Editor menu has a handful of items with no editor focused and over a hundred with a Swift file open. One scan only shows one context.
- The menus are inert when Xcode isn't in front. Many items are disabled or missing while
another app is active. That's why
xcmdrbrings Xcode forward before scanning, and why a test that forgets to will report working features as broken. - Shortcut characters come back in the current keyboard layout, and letters are upper-cased for display. The modifier mask is the only reliable statement about Shift.
Once an item is in the index, running it is one call:
AXUIElementPerformAction(item.element, kAXPressAction as CFString)It takes about 1 ms and is invisible. XCommander first tried the obvious alternative, posting the
item's keyboard shortcut as a synthetic key event, but macOS flashes the menu's title in the
menu bar when a shortcut matches a menu item, which looks like something is clicking through your
menus. Pressing the item over Accessibility doesn't. Posting the shortcut remains the fallback
when a press is refused (CommandExecutor.swift).
A few commands exist only in a toolbar popup, not in the menu bar (for example "new editor
tab"). PopupReader opens the popup with AXShowMenu, finds the item and presses it. It's
slower and visible, so it's used only where there's no other way.
Some features need more than pressing menu items:
| Feature | How | Where |
|---|---|---|
| The panels follow Xcode's window | Read the focused window's AXPosition and AXSize; subscribe to AXWindowMoved, AXWindowResized and AXFocusedWindowChanged with an AXObserver |
WindowTracker.swift |
| Current file | The window's AXDocument attribute (a file URL) |
XcodeContext.documentURL() |
| Text transforms | The editor is an AXTextArea. AXSelectedText and AXSelectedTextRange are writable, and a write joins Xcode's own undo stack, so ⌘Z undoes it |
XcodeContext.replaceSelection(with:) |
| Open editors (buffers) | Each editor pane may have an AXTabGroup whose tabs can be pressed |
XcodeContext.openEditors() |
| Moving between panes | Each editor pane is an AXScrollArea with a frame; setting AXFocused moves focus; an AXSplitter's AXValue moves the divider |
PaneController.swift, with the geometry in Core's PaneLayout.swift |
| Navigator, inspector, debug area | Mostly menu items; focusing a specific area needs Accessibility | AreaController.swift |
AXObserver is the push alternative to polling. You create one for a process, register for
notifications on specific elements, and add its run-loop source to your run loop. Two details:
- Notifications are registered on an element, so when Xcode's focused window changes, the window observers have to be moved to the new window.
- An observer belongs to one process. When Xcode relaunches, or you switch between a release
and a beta, the observer is dead. That's why
WindowTrackerhasfollow(), called whenever Xcode comes to the front, rather than astart()that runs once.
Some of what XCommander does depends on how Xcode's window is built: that editor panes are
AXScrollAreas, that the navigator is an AXOutline, and so on. Apple doesn't document any of
that and can change it in any release. Those assumptions are gathered in
Sources/XcodeMenus/XcodeAXSchema.swift, which can check them against the running Xcode. If a
pane command fails, the app runs that check to tell "Xcode changed its window" apart from an
ordinary failure. xcmdr doctor runs it too.
- Apple: Accessibility for macOS
(the
AXUIElement.hreference) and the header itself, which is better documented than the website. - Open-source apps that use this API heavily, worth reading: Rectangle (window management),
AltTab, and Hammerspoon's
hs.axuielementmodule.