Aug 23, 20266 min read/2026/08/23/uno-linux-accessibility-backend/

I Gave Uno Platform a Linux Accessibility Backend in an Afternoon

This is a build log, and it starts from a chain of earlier posts. I argued that a computer-use agent should read the accessibility tree instead of guessing pixels. Then I checked which .NET UI frameworks actually publish that tree on Linux and found a split: Avalonia 12 exposes a full AT-SPI tree; Uno's Skia desktop head exposes nothing. A Uno app on Linux is invisible to Orca and to any AT-SPI-first agent.

So: could I bolt AT-SPI onto Uno myself? Short answer — yes, and it was far less work than it sounds, because the hard part already exists inside Uno.

The insight: the tree is already there

AT-SPI is a publishing problem, not a modeling problem. Uno already models the UI semantically — every control has an AutomationPeer (Uno implements a subset of WinUI's UI Automation) that knows its role, name, bounding box, states, and children. Uno 6.6 even routes those peers to real screen readers on Windows, macOS, and WebAssembly. Linux is just the backend nobody's written yet.

Before writing a single line of D-Bus, I proved the peers exist at runtime on the Linux Skia head — dumped from inside the app running headless in a container:

[Button]   name='Open File Manager'    box=(20,52,148,33)   focusable=True
[Edit]     name='Search box'           box=(20,142,217,33)
[CheckBox] name='Enable notifications' box=(20,187,157,32)
[Slider]   name='Volume'               box=(28,231,200,32)

Thirty peers, fully populated. The semantic tree is right there. All that's missing is the thing that hands it to the outside world.

What AT-SPI actually wants

AT-SPI2 is a D-Bus protocol. To make an app accessible on Linux you:

  1. Ask the session bus for the accessibility bus address (org.a11y.Bus.GetAddress) and connect to it.
  2. Do the Socket.Embed handshake: tell the AT-SPI registry "here is my app root," which attaches you as a child of the desktop.
  3. Export a D-Bus object per accessible, implementing org.a11y.atspi.Accessible (role, name, states, parent, children) and org.a11y.atspi.Component (bounding box, hit-testing). Screen readers walk that tree by calling your methods.

That's the whole contract for the read path. Map each Uno peer onto one of those D-Bus objects and you're done.

The mapping

It's almost mechanical:

Uno AutomationPeer AT-SPI
GetAutomationControlType() role (Button → push button, Edit → entry, CheckBox → check box, Slider → slider, ComboBox → combo box)
GetName() name
GetBoundingRectangle() Component extents
IsEnabled() / IsKeyboardFocusable() state bits (ENABLED, SENSITIVE, FOCUSABLE, SHOWING, VISIBLE)
GetChildren() child accessibles

I built it on Tmds.DBus.Protocol — the same D-Bus stack Avalonia's AT-SPI backend uses — as about 350 lines: walk the peer tree into a flat list of nodes, register a D-Bus method handler per node, answer GetRole/GetName/GetState/GetChildren/GetExtents, and do the embed handshake on startup.

The one gotcha

My first run worked — the app registered, the tree showed up — but half the roles were wrong. Buttons were right ("push button"), yet my text box came back as "footer", my checkbox as "radio button", my slider as "autocomplete."

The cause is a small but important detail: libatspi derives the role name from the numeric role id you return from GetRole, not from the GetRoleName string. I'd returned nice strings but arbitrary numbers. The fact that "push button" was correct told me the enum was real (push button is 43) and my other ids were just off. Fixing them to the real AtspiRole values — entry 79, check box 7, slider 51, combo box 11 — cost one rebuild.

A tip that saved a lot of those rebuilds: the D-Bus server API in Tmds.DBus.Protocol is low-level and I wasn't sure of the exact method names. Rather than guess through slow container cycles, I reflected the assembly on my machine first and dumped the real type surface — DBusConnection, IPathMethodHandler, MessageWriter, the writer method signatures, the delegate shapes. It compiled on the first real build.

Before and after

Same app, same headless harness, same AT-SPI client (a little Python script that walks the tree exactly like Orca would).

Before — stock Uno:

# AT-SPI desktop has 0 application(s) registered

After — with the bridge:

# AT-SPI desktop has 1 application(s) registered
=== application: 'UnoDemo' ===
    [push button] 'Open File Manager'    box=(20,52,148,33)   focusable,enabled,showing,sensitive,visible
    [push button] 'Save Document'        box=(20,97,127,33)
    [entry]       'Search box'           box=(20,142,217,33)
    [check box]   'Enable notifications' box=(20,187,157,32)
    [slider]      'Volume'               box=(28,231,200,32)
    [combo box]   'Theme selector'       box=(20,275,83,32)

Correct roles, the names I set, exact boxes, live states. A screen reader can read this. An AT-SPI-first agent can ground on it — pick "Save Document" by name, click the center of its box, done. Uno went from invisible to fully legible on Linux, in application code, no framework fork.

What I did not do

This is a proof of concept, and honesty is the whole ethos of this series, so here's the ledger. I implemented the read path only:

  • No live events. A screen reader wants signals when focus moves, a checkbox toggles, or children change (org.a11y.atspi.Event.*). I publish a static snapshot. Grounding an agent that re-reads the tree each step is fine; a smooth Orca experience needs the events.
  • No Text/Value/Action interfaces. Fine for role/name/box/state; not enough to read a text field's caret or invoke a button through AT-SPI.
  • Window-relative coordinates. The boxes are relative to the window, not the screen — a real backend adds the window origin.
  • No filtering. Scrollbar repeat-buttons and other internals show up; a real one prunes to what's actionable.

None of those are hard; they're just more of the same. And the right home for a complete version isn't a bolt-on bridge at all — it's Uno's own accessibility abstraction, the one that already feeds Windows, macOS, and WebAssembly. This PoC is really an argument that the Linux backend slotting into that abstraction is a bounded, contributable piece of work.

Why I find this worth writing down

Two things. First, an accessibility backend feels intimidating and turns out to be a mechanical projection of a tree the framework already maintains — the moment you confirm the peers exist, most of the risk is gone. Second, and the reason it fits this series at all: the work you do to make an app usable by a blind person is the same work that makes it drivable by an agent. Roles, names, exact geometry, states — a screen reader and an AT-SPI-first agent want the identical view. Give Uno that view on Linux and you've served both at once.

The full PoC — the app, the bridge, the headless test harness, and the before/after dumps — is at github.com/egarim/uno-atspi-bridge. It runs in a container in a couple of minutes.