Shell surfaces
A shell surface is one accessibility root owned by the operating system rather than by an application. Menu bars, status items, taskbars, panels, docks, the desktop, and open flyouts are all shell surfaces. The Kinds table below gives each one per platform.
ShellSurface is the entry point for those roots, as App is for application
roots. Each surface wraps an element the platform itself vends, tagged with a
kind. Selectors, locators, auto-waiting, actions, tree(), dump(), and
every error type work on a shell surface exactly as they do on an application.
The kind crosses every binding as its snake_case string, with the same
spelling in Rust, Python, JavaScript, the CLI, and MCP.
| Kind | Windows | macOS | Linux |
|---|---|---|---|
menu_bar |
(none) | The active application’s AXMenuBar, Apple menu included. Name and process id are the owning application’s. |
(none) |
status_items |
(none) | One surface per process with a live AXExtrasMenuBar. Name and process id are the owning application’s. |
(none) |
taskbar |
The Shell_TrayWnd pane, holding the task band and the visible tray row with its overflow chevron. |
(none) | (none) |
panel |
(none) | (none) | Each AT-SPI frame carrying window-type:dock. One surface per frame, so a panel and a dock row are two surfaces. |
dock |
(none) | The Dock process’s application element. | (none) |
desktop |
The SysListView32 desktop list view, reached as Progman → SHELLDLL_DefView → SysListView32. A shell without that chain contributes no desktop surface. |
Finder’s desktop scroll area. | (none) |
flyout |
The tray overflow, Quick Settings, and shell context-menu popups, while open. | (none), not in v1. | (none) |
unknown |
(none) | (none) | (none) |
A cell reading (none) means the platform vends no such surface. list() on
Linux returns no taskbar, and by_kind for a kind that platform lacks fails
with SelectorNotMatched naming what was found instead. Windows has no
per-process tray hosting, so tray icons live inside the taskbar and flyout
surfaces rather than under status_items.
Two of the empty cells are a scoping decision rather than a platform gap.
macOS emits no flyout in v1: an opened Control Center or Notification
Center panel is a shell process’s AXSystemDialog window, whose enumeration
contract differs from every other macOS surface. Windows leaves the
Notification Center out of its flyout set, because its window class is the
one ordinary UWP apps also use and separating them takes a process-image
lookup the backend does not perform. An absent surface is honest; a
misclassified application window is not.
The unknown kind is a reserved fallback, so a shell window some future
backend cannot classify degrades to reachable-but-untagged instead of
vanishing from the listing. No backend emits it in v1, so by_kind("unknown")
matches nothing today.
The kind set grows as more shell UI is classified, so handle an unknown kind string the way you handle an unknown role.
Surface members
Section titled “Surface members”| Member | Meaning |
|---|---|
kind |
The kind string from the table above |
name |
The owning application for per-application surfaces, the platform’s own name otherwise (Taskbar, Dock) |
pid |
Owning process, where the platform reports one. Optional. |
locator(selector) |
A Locator rooted at the surface, with the usual auto-wait and action set |
children() |
The surface root’s direct children |
tree(max_depth) |
A structured snapshot, same shape as App.tree() |
dump(max_depth) |
The same snapshot as indented text |
as_element() |
The surface root as an ordinary Element |
pid reports the process the platform attributes the surface to, which is not
always the process that drew the icon you are looking at. On macOS it is the
true owner. On Windows it is the host process (explorer.exe), because UIA
carries no per-icon owner. On Linux it is the panel process.
The surface root also carries its kind in its raw platform attributes, under
the key shell_kind, so it shows up in tree() and dump() output and reads
back as surface.as_element().raw["shell_kind"]. That is a read, not a
selector hook: a locator rooted at the surface matches the surface’s
descendants, never the root itself, so surface.locator("[shell_kind='taskbar']")
does not match the surface it is rooted at.
Discovery
Section titled “Discovery”ShellSurface::list() and ShellSurface::by_kind() come from the
ShellSurfaceExt trait, mirroring AppExt.
use std::time::Duration;use xa11y::{ShellSurface, ShellSurfaceExt, ShellSurfaceKind};
for surface in ShellSurface::list()? { println!("{:?}\t{:?}\t{}", surface.kind, surface.pid, surface.name);}
// `Duration::ZERO` makes one attempt; anything longer polls.let bar = ShellSurface::by_kind(ShellSurfaceKind::MenuBar, Duration::from_secs(2))?;bar.locator("menu_item[name='Save']").press()?;Timeouts are seconds, as everywhere else in the Python binding.
for surface in xa11y.ShellSurface.list(): print(surface.kind, surface.pid, surface.name, sep="\t")
bar = xa11y.ShellSurface.by_kind("menu_bar", timeout=2.0)bar.locator("menu_item[name='Save']").press()Timeouts are milliseconds, matching App.byName().
for (const surface of await ShellSurface.list()) { console.log(surface.kind, surface.pid, surface.name);}
const bar = await ShellSurface.byKind('menu_bar', { timeout: 2000 });await bar.locator("menu_item[name='Save']").press();by_kind requires exactly one surface of that kind. Several status_items
processes, several panel frames, or a second flyout produce
SelectorNotMatched with a diagnosis listing every candidate by kind, name,
and process id. Call list() and pick by process id to disambiguate.
Process id is the only lever, and it does not always separate the candidates.
One Linux panel process can draw two panel frames, which are then two
surfaces sharing one pid, and no pid picks between them. A refusal in that
state says so in its diagnosis rather than offering a filter that cannot work.
list() still returns both surfaces, so a caller that walks the list can pick
on whatever else tells them apart, such as the root element’s bounds.
Mutation model
Section titled “Mutation model”Listing is live, and it reads only. Enumerating surfaces and dumping their
trees never opens, closes, focuses, or presses anything. So a flyout surface
appears in list() while it is on screen and disappears from the next call
once it closes.
Content that exists only while a flyout is open is reached by pressing a real element and then asking for the surface again. Windows tray icons in the overflow are the case that comes up most: they are absent from the tree until the chevron opens the flyout that hosts them.
taskbar = xa11y.ShellSurface.by_kind("taskbar", timeout=2.0)taskbar.locator("button[name='Show Hidden Icons']").press()
flyout = xa11y.ShellSurface.by_kind("flyout", timeout=3.0)flyout.locator("button[name*='Tailscale']").press()The press is an ordinary accessibility action on an element the platform advertises. No shell API opens a surface on the caller’s behalf.
From the CLI
Section titled “From the CLI”xa11y shell lists surfaces, and --shell KIND targets one from tree,
find, and action. It is mutually exclusive with --app, and --pid
alongside it picks between same-kind surfaces. See
CLI.
xa11y shellxa11y tree --shell taskbarxa11y find "button[name*='Tailscale']" --shell flyoutxa11y action press "button[name='Show Hidden Icons']" --shell taskbarFrom MCP
Section titled “From MCP”The shell tool lists surfaces as {kind, name, pid} rows. The tree,
find, and action tools take an optional shell argument holding a kind
string, mutually exclusive with app and combinable with pid. A kind that
matches several surfaces returns an ambiguous_shell_surface failure carrying
the candidate list. See CLI for the argument tables
and Serve xa11y over MCP for client setup.