x11-protocol-visualizer
v0.1.0
Published
X11 MITM proxy with live protocol visualization — inspect, link and throttle X11 traffic
Maintainers
Readme
x11vis — X11 protocol visualizer
A man-in-the-middle X11 proxy with a live protocol inspector. It sits between an X11 client and the X server, forwards the byte stream faithfully both ways, and renders every message (request, reply, event, error) as a color-coded, navigable table with per-field byte mapping.
Fittingly, its own UI is a native X11 application built with
react-x11 +
react-x11-components — the
X11 visualizer is itself an X11 client.

The window follows the desktop's colour scheme; the same session on a light
desktop is docs/img/x11vis-light.png.
Above: a GetProperty is selected, so the right pane shows it as a call with
its arguments, a link to the reply that answered it, and every decoded field.
Picking the property field marks the four bytes that carry it — 17000000,
atom 23, RESOURCE_MANAGER — in the hex. That picture is rendered by
npm run screenshot, headlessly, by the real UI: see
Screenshots.
See docs/PRD.md for the product spec and
docs/decoder-and-state.md for the decoder/state
design.
Status — M0–M4 built
Working and tested against live XQuartz:
Transparent TCP proxy on a configurable port → upstream
$DISPLAY(Unix-socket or TCP target, incl. the XQuartz launchd socket). Byte-exact relay.Per-direction framing: connection setup handshake, then requests / replies+events+errors. Handles byte order,
BIG-REQUESTSextended length,GenericEvent(XGE), andKeymapNotify.Sequence linking: replies/errors matched to their request (16→32-bit sequence widening), with round-trip time.
Request decoding for common requests (CreateWindow, InternAtom, QueryExtension, GetProperty, CreateGC, …) with per-field byte spans.
Reply decoding — a reply carries no opcode, so it is decoded using the opcode of the request it answers: GetGeometry, QueryTree, GetProperty (typed by its atom, incl. ATOM lists and text), GetWindowAttributes, GetInputFocus, QueryPointer, TranslateCoordinates, ListExtensions, ListProperties, ListFonts, AllocColor, QueryBestSize, GetImage, GetKeyboardMapping, and more.
Extension registry: learns extension major opcodes from
QueryExtensionreplies and names subsequent extension traffic.Network emulation (PRD FR-39…42): per-direction added latency + throughput cap with Chrome-style presets, changeable live from the UI or
--network. Verified: the same 22-messagexdpyinfosession takes ~5 ms unthrottled and ~2.9 s on Slow 3G, while remaining byte-exact.Resource tracking (M2): resource-creating requests (CreateWindow, CreatePixmap, CreateGC, RENDER CreatePicture/GlyphSet, …) are recorded, and any later field that references that XID links back to its creator — jump-to-creator in the UI. Server-owned resources (each screen's root window and default colormap) are pre-seeded from the setup reply, so references to the root link to the handshake instead of dangling.
Image & glyph previews (PRD FR-37) —
PutImageandGetImagepayloads are decoded to real pixels and drawn in the detail pane, honouring the connection'simage-byte-order,bitmap-bit-order, scanline padding, the depth'sbits-per-pixel, and the visual's colour masks. RENDERAddGlyphsrenders each glyph from its A1/A4/A8/ARGB32 coverage. Decoding is lazy (only when a message is selected) and size-capped, so a 4 MB PutImage costs nothing until you look at it. Previews also appear in creator tooltips.Parameter decoding — semi-encoded values are made human-readable: value-masks expand to named bits (
value-mask=0x800 (event-mask)), enums to names (class=InputOnly,line-style=OnOffDash), event-masks to their event set, pixel and RENDER color values preview as color swatches, and RENDERPICTFORMATids resolve toDirect depth24 R8G8B8(harvested from QueryPictFormats). Applies to CreateWindow/ChangeWindowAttributes, CreateGC/ChangeGC, and RENDER CreatePicture/ChangePicture.Core events — all of codes 2–34 decode to fields with spans and a summary built around what identifies the event:
ButtonPress · button=1 @(312,208) event=0x04800001 state=Shift,ConfigureNotify · window=0x0480000a 300×200+10-20,PropertyNotify · window=0x04800001 _NET_WM_NAME NewValue.ClientMessage's data union is read performat, with the words its type says are atoms resolved by name (data=[WM_DELETE_WINDOW, …]);KeymapNotify's bitmap is listed as the keycodes held down; the request that caused aGraphicsExposureis named from its major opcode; andConfigureRequestreports only the parameters its value-mask asks for.Extensions (a data-driven registry — add one per file, see below):
- RENDER: ~30 requests with resource links, color swatches, and a QueryVersion reply; RENDER errors.
- XInputExtension (XInput 2): the full XI2 event set (Motion, Button, Key, Enter/Leave, Focus, Raw*, Touch*, Gesture*, Hierarchy, Property).
- XFIXES, SHAPE, DAMAGE, RANDR, Composite, SYNC, Present, XTEST, XC-MISC,
DPMS, MIT-SCREEN-SAVER, BIG-REQUESTS: request/event/error names, with
field decoders for the resource-creating requests (CreateRegion,
DAMAGE Create, NameWindowPixmap, …). Full per-field decode of the long tail
is the
xcbproto-codegen path noted in the PRD.
Filtering: mute whole categories (click the toolbar chips), mute an individual message type (right-click a row, or Filter → Hide type), solo one type, or narrow by a name/summary substring. Active filters show as removable chips.
react-x11 UI: menu bar (Capture / View / Filter), draggable split panes, network preset dropdown, filter box, virtualized
Tablepacket list,Treefields +Codecall view, a synchronized hex view (select a field to highlight its bytes; double-click a→link to jump to the creator), and a console pane for proxy diagnostics. Falls back to a headless console renderer with no display.Capture save/load (
.x11cap): record a session and reopen it offline. The file stores raw bytes and replays them through the current decoder, so an old capture gains every decoding improvement made since — and doubles as a regression corpus.--record out.x11cap/--open out.x11cap, or Capture → Save capture… (Ctrl+S) in the UI.Statistics & hotspots (Statistics tab): traffic and byte totals, round trips, blocking round trips (the client waited), RTT mean/p50/max, and ranked request/event/extension breakdowns — plus hotspot analysis aimed at protocol efficiency: blocking stalls priced at the observed RTT, repeated identical queries (cacheable), and per-frame resource churn (create/destroy cycles that could reuse a buffer).
Resource lints (Statistics → Resources, and badges on the message): use-after-free, double-free, freeing an id never created, and resources still live at the end of the capture. Severity is deliberate: a running app legitimately holds windows and buffers open, so a live resource is info — only an accumulation of one type is raised to a suspected leak. XID recycling (free then re-create the same id) is understood and not mis-reported.
Find usages (FR-34): every message that creates, references or frees a resource id. Double-click a resource field, or Filter → Find usages, and the table filters to that resource's whole lifetime.
Cursor previews:
CreateCursorcomposes its source and mask bitmaps — which live in pixmaps, not in the request — by resolving thePutImagemessages that filled them, then colouring by the fore/back values.Colormap-aware rendering:
AllocColor/AllocNamedColorreplies are paired with their request's colormap to build a palette, so indexed (PseudoColor) images render in real colour instead of as intensity.Unix-socket listener (
--unix N): also listen on/tmp/.X11-unix/XNso a client attaches with a plainDISPLAY=:N. Stale sockets are cleaned up.Breakpoints & fault injection (
--intercept): rules match on name, direction or category andbreak(hold the message — the client really blocks),drop, ordelayit. Opt-in, because it switches forwarding from raw chunks to whole framed messages. Holding a message holds everything behind it in that direction — a breakpoint stops the client, it does not just delay one packet. Drive it from the Intercept menu (break/drop/delay the selected type, StepCtrl+N, ContinueCtrl+G, toggle or remove rules), or seed rules from the CLI with--break <name>/--drop <name>, or build one in Break on… (Ctrl+B): a tree of the whole protocol catalog — every request, response, event and error — with per-parameter conditions (numbers, strings, resource ids, and an atom picker), or a Script tab for a JavaScript expression. Both forms evaluate against the same match context:kind,msg,requestandatom(name).requestis what lets a rule on a response reach the request that asked for it, e.g.kind === 'reply' && msg.name.includes('AddGlyphs') && request.f['num-glyphs'] > 100. A debugger toolbar shows Paused/Running with Step / Continue / Skip, the held message, and how many are queued behind it.Headless statistics (
--stats a.x11cap,--json): the Statistics tab's numbers without the UI — bytes each way, round trips, blocking round trips, RTT mean/p50/max, the request/event/extension tables and the hotspot list.--jsonemits the wholeCaptureStatsobject (and works with--difftoo), so a CI script can gate on a number the tool computed rather than reimplement it.Diff two captures (
--diff a.x11cap b.x11cap): what changed between two runs, compared over aggregates that survive differing XIDs and timing.Generated protocol tables (
npm run gen:protocol): names, enums, value-mask bits and field layouts generated from the xcbproto XML corpus (32 files → 652 requests, 102 events, 67 errors, 230 enums). Used as a fallback wherever a hand-written spec doesn't cover a message, which removes theext139:req4long tail entirely.Variable-length tails: a request's trailing
<list>s are decoded too — lengths that are a field (InternAtom's name), a literal, an arithmetic expression over earlier fields (ChangeProperty'sdata_len * format / 8), or unstated, which xcbproto means as "the rest of the message" (every drawing request). Text comes out as text, binary as[N bytes], struct arrays as[2 × RECTANGLE]— each with a byte span, so clicking the field highlights exactly its bytes in the hex.
Still partial: <switch> bitcases, <union>s, and lengths needing popcount
or sumof — 62 of 652 requests, down from 156. Such a message decodes up to
that point and says so rather than guessing past it.
UI layout
- Menu bar — Capture (pause/resume
Ctrl+P, clear logCtrl+K, saveCtrl+S, quit), View (console, columns, jump to newest), Filter, and a Network preset picker in the toolbar. - Right pane tabs — Detail (per-message decode) and Statistics (traffic, round trips, hotspots).
- Intercept bar — appears only when rules exist or a message is held; the held state is prominent because the client is blocked while it shows.
- Packet table — one row per message, color-coded by kind.
- Detail pane (right of the drag handle) — pretty-printed call, field list, and hex; selecting a field highlights exactly the bytes it came from.
- Console pane (below the table) — proxy diagnostics only: connections opening and closing, decode failures, network-profile changes. It is deliberately not a second copy of the packet table; hide it from View (hidden, the table fills the whole left column).
- Filtering — toolbar chips mute categories; the filter box narrows by
substring; right-click a row to hide that type; Filter menu has Hide
(
Ctrl+H), Solo, per-category toggles, Find usages, and Clear all. Active filters appear as removable chips under the toolbar.
Requirements
- Node.js ≥ 22 (developed on 26).
- For the UI: a running X server (Linux, or XQuartz on macOS). Without one, use
--no-ui/--record.
Install
npm installThe proxy/decoder core has no runtime dependencies and always installs. The
UI stack (react-x11@^2, @react-x11/components@^0.2) is in
optionalDependencies, resolved from npm as ordinary semver ranges.
Both packages used to be tracked from their GitHub default branches, which cost
this repo two workarounds — a legacy-peer-deps=true .npmrc to paper over a
pre-release peer-version mismatch, and a manual tsc build because the
components shipped no dist/. The published releases fixed both: components
0.2.0 declares peer react-x11@^2.0.0 against react-x11 2.0.0, and ships
prebuilt. A plain npm install now resolves under strict peer checking, with a
single deduped react-x11 copy (npm ls react-x11) — the one thing worth
keeping true, since two reconciler instances would break rendering.
If the UI can't load, x11vis runs headless automatically.
Usage
Start the proxy (and UI, if a display is available):
npm start -- --port 6001Then point a client at it over TCP (localhost:1 → TCP port 6001):
DISPLAY=127.0.0.1:1 xdpyinfoThe proxy forwards to your real $DISPLAY; the UI renders to that same real
display, so its own drawing traffic is not self-captured.
On macOS the UI window is drawn by AppKit rather than by XQuartz — react-x11's
native Cocoa backend, which also puts x11vis's menus on the system menu bar
instead of in the window. That needs @windowkit/appkit, which npm installs for
you on a mac; without it, auto falls back to X11 so an XQuartz setup keeps
working. --ui-backend x11 forces the old behaviour, and --ui-backend cocoa
makes the bridge a requirement rather than a preference:
npm start -- --ui-backend cocoaThe proxy itself is unaffected either way — it is an X11 proxy on every platform, and only the visualizer's own window changes.
Options
-p, --port <n> TCP port to listen on (default 6001)
-d, --display <s> Upstream X server DISPLAY (default: $DISPLAY)
--record <file> Record the session to <file> (.x11cap)
--no-ui Headless: log to console, don't open the UI
--ui-display <s> DISPLAY for the UI window (default: $DISPLAY); naming
one selects the X11 backend
--ui-backend <b> How the UI window is drawn: auto (default), x11, cocoa
-o, --open <file> Open a saved .x11cap for offline inspection
-u, --unix <n> Also listen on /tmp/.X11-unix/X<n> (DISPLAY=:<n>)
--intercept Enable breakpoints / fault injection
--break <name> Hold matching messages (implies --intercept)
--drop <name> Never forward matching messages
--diff <a> <b> Compare two captures and print what changed
-n, --network <id> Network emulation preset (also changeable live in the UI):
none, local, lan, wifi, fast4g, slow4g, fast3g, slow3g,
ssh, transatlantic
-q, --quiet Suppress per-message console output
-h, --helpFeel how chatty a client is over a WAN link — same traffic, ~600× the wall clock:
npm start -- --network sshHeadless capture (no X needed), e.g. over SSH:
npm start -- --no-ui --record capture.jsonlDevelopment
npm install # core + dev deps; the react-x11 UI stack is optional
npm test # decoder, framing, rules, capture tests — no X server needed
npm run typecheck
npm run gen:protocol # regenerate protocol tables from an xcbproto XML corpus
npm run check:generated
npm run screenshot # regenerate docs/img/*.png headlessly (no X server)
npm run workbench # the component workshop (needs a display)
npm run workbench:ls # what it discovered, without a displayThe core (src/core) is pure Node + TypeScript with no runtime dependencies
and is tested independently of the UI (src/ui), which is excluded from the
core typecheck because it depends on the optional react-x11 stack. CI installs
with --omit=optional and never needs a display.
src/core/protocol/generated.ts is generated from the
xcbproto XML corpus and
committed, so nothing needs xcbproto installed in order to run. Regenerate with
npm run gen:protocol — it reads /opt/X11/share/xcb by default (XQuartz ships
the corpus there) or takes a directory argument.
See AGENTS.md for scope, architecture and the invariants worth not breaking.
Screenshots
npm run screenshot regenerates docs/img/*.png with no $DISPLAY, no
xvfb and no XQuartz. scripts/screenshot.tsx mounts the real UI through
react-x11/test against node-x11's
in-process pure-JS X server, clicks through it with the real event pipeline,
and reads the pixels back off the window's own 2d context. The approach is
react-x11's (its npm run screenshots), adopted wholesale; it replaces an
xwd recipe that needed a live server and broke on a multi-monitor desktop,
where GetImage cannot read a root window that is mostly off-screen.
The traffic in the picture is synthesized, not recorded: fixtures/demo-session.ts
is hand-built X11 bytes fed through the shipping ConnectionCapture, so every
field, span, link and resource shown was decoded by the real decoder — and the
workbench stories mount their panels over that same session. A recording would have been
both non-deterministic and unpublishable — captures carry window titles,
clipboard contents and keystrokes, which is why *.x11cap is gitignored.
Because the PNGs are committed, everything that would otherwise vary per run is
pinned: the wall clock, the animation clock (react-x11 transitions the
row-selection colour — read the pixels too early and you photograph a fade),
the fonts (family resolution otherwise shells out to fc-match), and the
desktop's colour scheme — which the app follows, so it is passed per shot
rather than read from the machine. That last one is why there are two PNGs:
x11vis.png is a dark desktop, x11vis-light.png a light one. Run it on a
machine with Arial (macOS) or Liberation/DejaVu (Linux) installed.
The component workshop
The UI's own components have stories, mounted in isolation by
@react-x11/workbench —
which is what Storybook is for a toolkit with no browser to put an iframe in:
PATH=/opt/X11/bin:$PATH npm run workbench # needs a display
npm run workbench:ls # what it found; --json for the modelstories/*.story.tsx cover both halves of the UI: the control set and the icons
(Controls, Icons), and the app's composed panels — the toolbar, the filter
bar, the intercept bar, the detail pane with its field↔hex highlighting, the
hex block, the statistics and console panes, and the "Break on…" dialog. They
mount the real components from src/ui, over the same synthesized session
the screenshot uses (fixtures/demo-session.ts), so a story shows what the app
shows rather than a re-implementation that drifts from it. Selecting a file
previews every state at once; a story with args gets a knobs panel.
Every story sits on the app's own ground and inside the app's own
<ThemeProvider> (<Ground> in stories/ground.tsx), so a control is
previewed in the colours it actually wears. They declare theme: 'both', which
puts the light and the dark rendering side by side — the app follows the
desktop, so a panel has two right answers and neither is the one to check.
Layout
src/core/ proxy, framing, state machines, protocol tables (tested core)
src/core/protocol/ core request/reply/event decoders + tables
src/core/protocol/extensions/ one file per extension (render.ts, xinput.ts, …)
src/ui/ react-x11 application (Table / Tree / Code / hex)
src/cli.ts entry point: proxy + UI-or-headless
test/ unit tests
fixtures/ the synthesized session the stories and screenshot share
stories/ @react-x11/workbench stories for the UI's components
workbench.config.ts where the workshop looks for them
docs/ PRD + decoder/state designAdding an extension
Extensions are data-driven, resolved at runtime from the QueryExtension
negotiation. To add one:
- Write
src/core/protocol/extensions/<name>.tsexporting anExtensionSpec—requestskeyed by minor opcode,xgeEventsby XI-style evtype,eventsby offset from the extension's first-event,errorsby offset from its first-error. Each decoder returns{ summary, fields, created? }, where every field carries a byte span andcreatedrecords a resource for jump-to-creator. - Register it in
extensions/index.tsunder the exact nameQueryExtensionreturns (e.g.RENDER,XInputExtension).
Nothing in the connection engine changes; RENDER and XInputExtension are
the worked examples.
License
ISC
