Skip to content

Multi-mode feature parity

Ryll is one binary with three delivery modes: a GUI desktop window (egui/eframe), a headless mode (no window; stdout, metrics, and an optional control socket), and a web mode (browser client over WebRTC via --web). Features usually land in one mode first and reach the others later — or never, when a mode physically can't support them. This page is the honest ledger of that drift: a per-feature matrix of what is reachable in each mode today.

Use it two ways:

  • Choosing a mode. "Can I do X in headless?" is answered by one cell lookup rather than by reading three documents or trying it.
  • Reviewing changes. A new feature that only works in the mode its author happened to test in is a parity regression waiting to be discovered by a user. The matrix makes that visible at review time.

The three modes

Mode Invocation Description
GUI (default) Desktop window with menus, status bar, panels. See Features.
Headless --headless No window. Draws are applied to an in-memory surface, a stats line is logged every 10 s, and an optional control socket (--control-socket) lets an external driver send input, take screenshots, read the display counters, and subscribe to events.
Web --web Serves a browser client over WebRTC. Shipped; the MVP scope was declared in PLAN-web-frontend.md and items outside it are marked below. See the web frontend operator guide.

How to read the matrix

Cell value Meaning
available Feature is fully reachable in this mode
partial Only some of the feature is reachable
missing Feature is not reachable today
n/a — intrinsic Feature physically cannot exist in this mode (justification in parentheses)

Notes on the annotations:

  • Source references (file.rs:line) point at the evidence for a "partial" or "missing" verdict. They are point-in-time: correct as of the audit that added them, and removed when the gap closes.
  • "Out of MVP scope" in the Web column means the feature was deliberately excluded from the shipped web MVP; the list lives in PLAN-web-frontend.md §Out of MVP scope. These are future work, not oversights.
  • Several headless cells are reachable only through the control socket, which requires launching with --control-socket <path>. Those cells say so explicitly.

Maintenance protocol

Reviewers are expected to keep this matrix honest:

  • When a feature is added, add a row.
  • When a mode gains or loses a feature, update the cell.
  • When a "missing"/"partial" gap is closed, update the cell and remove the source link.

This document records the baseline; it does not propose fixes. There is no standing gap-closure plan — gaps get closed by mode-specific work (the web frontend plan closed the Web column's MVP set; the control socket work gave headless an interactive driving surface). Last full audit: the web frontend sweep; control-socket cells refreshed 2026-08-23 (web mode gained the socket, its status and event rows, and the shared digest poller), media socket binding added 2026-08-21, clipboard and resize rows refreshed 2026-10-14 (control protocol 1.3), display, streaming and statistics rows refreshed 2026-10-16 (control protocol 1.4's stats verb).

Parity matrix

Headless cells name two functions. The renderer's run_headless(), in shakenfist-spice-renderer/src/session.rs, runs the session and its event loop. Ryll's run_headless(), in ryll/src/main.rs, builds the notification store and registers the --pedantic and gap observers around it.

Feature GUI Headless Web
Connection
Connect via .vv file (--file) available available available
Connect via URL (--url) available available available
Connect via direct host:port (--direct) available available available
TLS / secure channel (inline CA from .vv) available available available
HTTP CONNECT proxy (proxy= in .vv) available available available (all three build the same ConnectionConfig)
Password authentication available available available
Control socket
Control socket (--control-socket) n/a — intrinsic (the window owns input and the surface; combining with the GUI is a CLI error) available available
Session state query (status verb) n/a — intrinsic available available (run_web builds a SessionStatus from the same spice_connected flag, SessionState agent watch and surface mirror headless uses)
Display and video-stream counters (stats verb) n/a — intrinsic available available (run_web hands the socket the same display snapshot it gives the session)
Event subscription (latency, agent_connected, paste_*, surface_drawn, clipboard_changed, digest_updated) n/a — intrinsic available (digest_updated needs the digest-decode build feature) available (same event_broadcast_tx; digest_updated needs the digest-decode build feature, and the poller is spawned only when --control-socket is given so an ordinary browser session does not pay to decode QR)
Display
Display framebuffer (render SPICE draw ops) available available (the renderer's run_headless() applies every draw op to a SurfaceMirror as it arrives, whether or not a control socket is open; nothing displays it, but the screenshot verb reads it) available (single monitor, MVP)
Multi-monitor (--monitors N) available partial (--monitors reaches the session as in the GUI, and the renderer's run_headless() applies every display channel's draws to the SurfaceMirror, which creates a surface for each SurfaceCreated; status lists them all with their channel, but screenshot takes only a surface_id and so reaches only the primary's display channel. Multi-monitor headless is unverified end to end: ryll#549) missing (out of MVP scope; see PLAN-web-frontend.md §Out of MVP scope)
Full draw-op coverage (FILL, COPY, BLEND, etc.) available available (the same SurfaceMirror::apply_event dispatch as the GUI and web mode) available (inherited from shared framebuffer)
SPICE video streams (MJPEG, H.264) available available (decoded frames are applied to the SurfaceMirror like any draw; the stats verb reports each stream's codec and decode counts) available (inherited)
Display capabilities advertisement (COMPOSITE, MONITORS_CONFIG, etc.) available available available
Prefer H.264 from the server (--experimental-h264) available available available (affects only the SPICE side; the WebRTC encoder is unchanged)
Do not offer H.264 to the server (--no-h264) available available available (affects only the SPICE side; the WebRTC encoder is unchanged)
Window auto-resize to surface size available n/a — intrinsic (no window) n/a — intrinsic (browser viewport is user-controlled)
Obey guest size hints toggle (--no-obey-guest-size / hamburger toggle) available n/a — intrinsic (no window; flag accepted in headless for CLI symmetry but ignored, _obey_guest_size in ryll's run_headless()) missing (out of MVP scope; the web frontend's "guest fits browser viewport" inverse is planned)
Guest resolution follows viewport (VDAgentMonitorsConfig) available available (via the control socket resize verb, rounded to the GUI's 8-pixel grid; there is no viewport to follow) available (also via the control socket resize verb, which shares the browser's resize queue)
Cursor
Server cursor rendering (SET/INIT) available missing (the renderer's run_headless() — CursorChannel runs, events arrive, but its event loop has no handler for CursorImage/CursorPos events) available (datachannel CSS overlay; the host cursor is only hidden once a real SPICE shape has arrived, so a guest that never sends one leaves the browser's own pointer visible rather than none at all)
Cursor position tracking available missing (same as above) available (MVP)
Cursor hide/show available missing (same as above) available (MVP)
Default arrow fallback cursor available n/a — intrinsic (no display) available
Audio
Audio playback (PCM and Opus decoding) available partial (shakenfist-spice-renderer/src/channels/playback.rs — PlaybackChannel connects and decodes, but always spawns a cpal thread; with no audio device present it raises one notification and retries with a backoff of up to 30 s; no headless-safe "discard" path exists) available (MVP; Opus passthrough preferred, PCM→Opus fallback; the host-side cpal thread still runs, so on a host with no audio device the snapshot's output_* fields record retries every 30 s, with notifications suppressed)
Volume control (slider + mute) available missing (the renderer's run_headless() — a VolumeControl is passed to run_connection() but no CLI flag, stdout interface, or control-socket verb exposes it in headless) missing (out of MVP scope; not listed in PLAN-web-frontend.md MVP)
Audio volume/mute persisted across reconnect available n/a — intrinsic (no GUI; VolumeControl is re-created on reconnect anyway) missing (out of MVP scope)
Keyboard / Mouse Input
Keyboard input (key down/up, scancodes) available available (via the control socket send_key verb — down/up/press with AT-set-1 scancodes; without --control-socket only cadence and --paste-text generate input) available (MVP; browser-side scancode table)
Mouse input (position and buttons) available missing (the control socket declares mouse verbs a non-goal until a test needs them; no other pointer path exists in headless) available (MVP)
Mouse mode negotiation (CLIENT/SERVER) available available (negotiation runs in MainChannel regardless of mode) available (both modes drive the guest pointer: absolute MouseMove in client mode, relative MouseMotion derived from consecutive browser positions in server mode. Pointer Lock is still absent, so the derived deltas drift under guest pointer acceleration and dead-zone at the window edge — see docs/web-frontend.md)
Extended key prefix (E0 for nav cluster) available available (the control socket send_key verb accepts 0xE0-prefixed 16-bit scancodes in logical form and converts them; protocol 1.1 and earlier sent them prefix-second and unusable — see docs/control-socket-protocol.md. Cadence alone only sends space) available (browser table is in logical form; ryll converts via make_scancode)
macOS Cmd chords (Cmd+Q, Cmd+W, ...) available (treated as host shortcuts: the chord key is withheld from the guest, press and release; Cmd is not mapped to the guest's Super key) n/a — intrinsic (no keyboard) available, by a different policy (Cmd is forwarded as the guest's Super key — MetaLeft/MetaRight are in the browser scancode table — so the guest sees a Super chord rather than a bare letter)
Modifier state tracking (Ctrl/Shift/Alt) available partial (InputsChannel in shakenfist-spice-renderer/src/channels/inputs.rs — tracked internally but only used by the paste state machine; cadence does not restore modifiers) available (MVP)
Cadence mode
Cadence mode (--cadence) available available missing (out of MVP scope; no test-automation use case in browser)
Clipboard
Clipboard sync with the guest (vdagent, CLIPBOARD selection, UTF-8 text) available (host clipboard via arboard) available (via the control socket: clipboard_set offers text to the guest, clipboard_get reads it back, clipboard_changed reports guest copies; the in-memory clipboard exists only with --control-socket) partial (the browser has no clipboard sync — out of MVP scope; the control socket's clipboard verbs work as in headless)
Paste-as-keystrokes
Paste-as-keystrokes (enable flag) available available (--enable-paste-as-keystrokes) missing (out of MVP scope; clipboard sync is in Future work list in PLAN-web-frontend.md)
Paste-as-keystrokes (runtime trigger via Ctrl+Alt+V) available available (via the control socket paste verb; Ctrl+Alt+V itself is GUI-only) missing (out of MVP scope)
Paste-as-keystrokes (headless --paste-text) n/a — intrinsic (GUI reads clipboard; --paste-text is headless-only per README) available missing (out of MVP scope)
Paste-as-keystrokes (inter-character delay --paste-char-delay-ms) available available (also settable per-request via the control socket paste verb's char_delay_ms) missing (out of MVP scope)
Paste-as-keystrokes (vdagent auto-disable) available (RyllApp::trigger_paste() in ryll/src/app/) available (vdagent state logged from the session's SessionState watch; enable_paste flag passed through) missing (out of MVP scope)
Paste character validation / error dialog available partial (the renderer's run_headless() — PasteFailed logged and causes non-zero exit; control-socket clients get a paste_failed event; no interactive dialog) missing (out of MVP scope)
USB Redirection
USB redirection channel (protocol layer) available available (--usb-disk, --usb-disk-ro CLI flags work in headless) missing (out of MVP scope; see PLAN-web-frontend.md §Out of MVP scope)
USB interactive panel (Menu → USB) available n/a — intrinsic (no GUI) missing (out of MVP scope)
Connect physical USB device (Linux only) available missing (the renderer's run_headless() — _usb_tx is created but usb_rx is never sent a ConnectPhysical command in headless; no CLI flag for physical devices) missing (out of MVP scope)
Connect virtual USB disk (--usb-disk) available available missing (out of MVP scope)
Connect read-only virtual USB disk (--usb-disk-ro) available available missing (out of MVP scope)
Runtime USB disk addition (file picker) available n/a — intrinsic (no GUI file picker) missing (out of MVP scope)
USB error reporting (bug report integration) available partial (the renderer's run_headless() — errors logged; no zip written unless --pedantic) missing (out of MVP scope)
Folder Sharing (WebDAV)
WebDAV folder sharing channel (protocol layer) available available (--share-dir, --share-dir-ro CLI flags work in headless) missing (out of MVP scope; see PLAN-web-frontend.md §Out of MVP scope)
WebDAV interactive panel (Menu → Folders) available n/a — intrinsic (no GUI) missing (out of MVP scope)
Share directory (--share-dir) available available missing (out of MVP scope)
Read-only share (--share-dir-ro) available available missing (out of MVP scope)
Runtime directory picker (native file dialog) available n/a — intrinsic (no GUI) missing (out of MVP scope)
Statistics and Instrumentation
FPS tracking (sliding window from DisplayMark) available partial (no FPS is computed; the control socket's stats verb returns marks_received, whose rate between polls is the GUI's measure) partial (no stats panel in the browser MVP; the control socket's stats verb works as in headless)
Bandwidth sparkline (rolling bytes/sec graph) available partial (no graph; the control socket's stats verb returns the display channel's bytes_in, whose rate between polls is its bandwidth) partial (no graph; the control socket's stats verb works as in headless)
PING interval sparkline (inter-PING interval graph) available partial (no graph; raw samples are reachable as control-socket latency events) missing (out of MVP scope)
Inputs round trip (RTT in the status bar) available missing (headless sends no pointer input, and a round trip is only measured from pointer messages) partial (no stats panel; the browser's pointer drives control-socket input_rtt events)
Periodic stats to stdout (headless only) n/a — intrinsic (GUI shows live stats in status bar) available (every 10 s: DISPLAY_MARKs, image draws, and bytes in and out across every channel) n/a — intrinsic (browser context)
--latency-file latency measurements export missing (Args::latency_file in ryll/src/config.rs — flag declared but never read or acted upon anywhere in the codebase) missing (same — flag is declared but unused; control-socket latency events are the working alternative) missing (out of MVP scope)
Channel state snapshots (for bug reports) available available missing (out of MVP scope)
Traffic ring buffer (always active) available available missing (out of MVP scope)
Protocol Gap Tracking
Protocol gap counter (Gaps: N status bar) available missing (ryll's run_headless() — gap observer registered; warn_once fires; but the renderer's event loop has no handler to surface the count) missing (out of MVP scope)
Protocol gap floating window (click Gaps button) available n/a — intrinsic (no GUI) missing (out of MVP scope)
--pedantic mode (auto bug-report per gap) available available (partial: ryll's run_headless() — app_snapshot is an empty default, so pedantic zips omit surface/bandwidth/latency data) missing (out of MVP scope)
--pedantic-dir directory selection available available missing (out of MVP scope)
Bug Reports
Bug report dialog (F12 / Menu → Report) available n/a — intrinsic (no GUI) missing (out of MVP scope)
Bug report zip assembly (metadata, pcap, screenshot, metrics) available partial (ryll's run_headless() — --pedantic zips have an empty app_snapshot; no interactive zip generation in headless) missing (out of MVP scope)
Bug report display region selection available n/a — intrinsic (no GUI) missing (out of MVP scope)
Bug report trigger-time screenshot capture available n/a — intrinsic (no continuously-painted display) missing (out of MVP scope)
Runtime metrics in bug reports (Linux /proc) available available (sampled in metrics.rs; used by pedantic observer) missing (out of MVP scope)
Screenshot
Screenshot (F8 / Menu → Screenshot) available available (via the control socket screenshot verb — PNG or raw RGBA of any live surface; the backing SurfaceMirror is created on first use) missing (out of MVP scope; see PLAN-web-frontend.md §Out of MVP scope "Recording / capture")
Multi-surface screenshot (suffix -1, -2, …) available available (the screenshot verb takes a surface_id parameter; one call per surface) missing (out of MVP scope)
Live Traffic Viewer
Traffic viewer panel (F11 / Menu → Traffic) available n/a — intrinsic (no GUI) missing (out of MVP scope)
Traffic viewer channel filters available n/a — intrinsic (no GUI) missing (out of MVP scope)
Traffic viewer pause/resume available n/a — intrinsic (no GUI) missing (out of MVP scope)
Notifications
In-app notifications panel (bell / Menu bell) available missing (ryll's run_headless() — store exists and is populated, but no bell or side panel in headless; no stdout drain) missing (out of MVP scope)
SPICE_MSG_NOTIFY surfacing available partial (stored in notification store; printed nowhere in headless event loop unless captured by --pedantic) missing (out of MVP scope)
Gap observer notification feed available partial (registered by register_gap_notification_observer() in ryll's run_headless(); notifications accumulate in store but are never printed to stdout) missing (out of MVP scope)
Reconnect / Lifecycle
Reconnect on disconnect (Reconnect button) available n/a — intrinsic (no GUI; headless exits when main channel disconnects) available (SPICE session held while PeerConnection is dead; browser auto-reconnects with 1 s/2 s/4 s/8 s/16 s backoff, max 5 attempts then a manual button)
State preserved across reconnect (disks, share dir, paste toggle) available n/a — intrinsic (headless does not reconnect) partial (SPICE session continuity is preserved — the bridge is reaped and rebuilt, leaving the SPICE channels untouched; per-tab UI state is browser-managed)
Notification history preserved across reconnect available n/a — intrinsic n/a — intrinsic (browser tab holds its own page state)
Disconnect dialog available n/a — intrinsic (no GUI) missing (not in MVP scope; browser tab closure is the UX)
Graceful Ctrl+C shutdown available available available (SIGTERM/Ctrl-C drains axum then explicitly closes the active bridge so DTLS/SRTP tears down cleanly before the process exits)
Cancel superseded connection attempt available n/a — intrinsic (headless does not reconnect) n/a — intrinsic (single-session MVP)
Window / Session Persistence
Window size/position persistence (eframe persistence) available n/a — intrinsic (no window) n/a — intrinsic (browser manages its own viewport)
Open side panels survive reconnect available n/a — intrinsic (no GUI) n/a — intrinsic (no side panels in web MVP)
Capture Mode
Protocol pcap capture (--capture) available available missing (out of MVP scope; see PLAN-web-frontend.md §Out of MVP scope "Recording / capture")
Display video capture (display.mp4 via openh264) available missing (only the GUI's frame loop calls CaptureSink::frame; headless keeps no continuously-painted surface to encode, so --capture writes the pcaps but no display.mp4) missing (out of MVP scope)
Session metadata (metadata.json) available available missing (out of MVP scope)
Diagnostics / Verbose Logging
Verbose logging to stderr (-v) available available available (stdout logging works regardless of mode)
File logging to /tmp/ryll.log (-v) available available available
Intimate logging (--intimate, keystrokes/mouse) available available missing (no interactive input to log in web MVP that wouldn't also appear in the browser console)
Connection Source
VDAgent connection status (agent_connected flag) available (status-bar Agent indicator; a server with no agent is notified as such, not as a disconnect) available (read from the session's SessionState watch and logged; also a control-socket event and status field) available (wired through same channel path)
TCP keepalive on all channel sockets available available available (inherited from SpiceClient)
Authentication / Session Token
Per-launch URL token (web mode only) n/a — intrinsic (no HTTP server) n/a — intrinsic (no HTTP server) available (MVP; random 32-byte token printed to stdout)
TLS for browser-facing endpoint n/a — intrinsic n/a — intrinsic available (--web-tls-cert / --web-tls-key flags; axum-server with rustls)
WebRTC media socket binding n/a — intrinsic (no WebRTC) n/a — intrinsic (no WebRTC) available (--web-media-addr takes an address or an interface name, --web-media-port pins the UDP port for a firewall rule, --web-ice-server supplies STUN/TURN URLs)
Web frontend (--web mode)
HTTP server with per-launch URL token n/a — intrinsic n/a — intrinsic available (ryll/src/web/server.rs)
Embedded browser shell (HTML/JS/CSS via include_bytes!) n/a — intrinsic n/a — intrinsic available
WebRTC SDP signalling endpoint (POST /offer) n/a — intrinsic n/a — intrinsic available
Synthetic video stream over WebRTC (test pattern) n/a — intrinsic n/a — intrinsic available (SyntheticFrameSource)
Synthetic audio stream over WebRTC (440 Hz tone) n/a — intrinsic n/a — intrinsic available
Real SPICE display frames over WebRTC available (egui paint loop) n/a — intrinsic (no encoder driven from real frames) available (RealFrameSource over SurfaceMirror)
Real SPICE audio over WebRTC (Opus passthrough) available (cpal sink) n/a — intrinsic (no audio sink) available (Opus path; PCM-only servers fall back to silent audio with a warn)
Browser-side keyboard/mouse input over datachannel available (egui inputs) n/a — intrinsic (no input device) available
Browser-side cursor overlay (datachannel) available (egui overlay) n/a — intrinsic available

📝 Report an issue with this page