Phase O5, the last phase of plans/20260719-02-one-config.md. The README gains "The one machine profiles" alongside two's — both builtins in English and JSON with their boot commands, a note that bare `ewm one` boots the Replica 1, and the one-family key rules in a sentence. The example check extends to `-- one` commands (readme_examples_parse dispatches on the subcommand; one::parse_options is pub(crate) for it) so one's examples are enforced like two's. JSON_CONFIG.md records the as-built decisions: one document type with a derived family, the cross-check table and its one-place relax point, the shared collect_document parameterization, minimal builtins, boo booting via builtin:, and one's flag retirement. The schema inventory names which keys are apple2-family-only, and the status table gains the one-family row. REMOTE.md's Phase 7 notes that the config surface it wanted now exists — relaxing the remote-block family check is a one-place change. Plan marked complete. Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
37 KiB
Remote Console (VNC / RDP) — Implementation Plan
A working document for making EWM machines bootable as headless "VMs" that you connect to over the network — with a native VNC/RDP client and from a web page, the way the Proxmox console works. The end state: start any number of machines, each listening on its own port, and reach every one of them remotely, including from a browser.
Like REWRITE.md and APPLE_IIE_ENHANCED.md, this is meant to be re-read at
the start of every session and updated as phases land. The tree must build
and pass all verification gates (cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test) after every phase. The
existing SDL frontends must stay byte-for-byte unchanged in behaviour —
the golden-BMP tests are the tripwire.
Branch: All remote-console work lands on the long-lived
remote-consoleintegration branch, kept separate frommainuntil the feature is complete. Work branches (claude/remote-console*) are cut from that branch and opened as PRs into it (never intomain) — the prototype PR #262 is the first. One final PR promotes the integration branch tomain. One sub-phase = one PR.
Scope note: this is a Linux-first feature and may lean on a Linux/X11
host for deployment tooling, exactly as the task allows. Nothing in the core
design is Linux-specific (it is pure Rust + std::net), but the orchestration
recipes (systemd, reverse proxy) target Linux.
1. Goals and non-goals
Goals
- Boot an EWM machine with no local window — no SDL, no GPU, no X server required on the host.
- Serve that machine's screen and keyboard/paddle input over a standard remote-desktop protocol so an off-the-shelf client can connect.
- Run N machines on N ports simultaneously, each independent.
- Connect from a web page — a self-contained console like Proxmox's, no plug-in, no installed client.
- Keep it a single self-contained binary where practical (the Proxmox feel), and keep the dependency budget small and blocking-I/O-based, in the spirit of the existing WozBug TCP server.
Non-goals (v1)
- Audio. The RFB/VNC protocol has no audio channel; the speaker beep is
dropped over VNC. Section 9 sketches a later path (WebAudio side-channel /
RDP
rdpsnd), but v1 is silent. - Multi-user collaboration semantics, session recording, file transfer.
- RDP as the primary protocol — it is a large, separable optional track (Section 12, Track B). VNC/RFB is the recommended v1.
2. Decision: VNC (RFB) is the primary protocol; RDP is optional Track B
The task says "either VNC or RDP, whatever is more feasible." RFB (the VNC wire protocol, RFC 6143) wins decisively for EWM:
| VNC / RFB | RDP | |
|---|---|---|
| Minimal server size | A few hundred lines (Raw encoding) | Large: T.128, capability negotiation, bitmap/RemoteFX codecs, virtual channels |
| Security stack required | Optional VNC auth; TLS via reverse proxy | TLS + CredSSP/NLA effectively expected by modern clients |
| Rust libraries | rfb (Oxide, trait-based), rustvncserver, or hand-roll |
ironrdp-server — an extendable skeleton, Tokio-based, much more to wire up |
| Runtime model | Blocking std::net threads (matches WozBug) |
Requires the Tokio async runtime |
| Framebuffer fit | We already produce exactly &[u32]; Raw is trivial at 280×192 / 560×192 |
Expects codecs and larger desktops; overkill for an Apple II frame |
| Browser client | noVNC — mature, canvas-based, HTML5 only | ironrdp-web (WASM) — real (Cloudflare/Devolutions ship it) but heavier to embed |
| Input fit | KeyEvent (X11 keysyms) + PointerEvent map straight onto two.key() / paddle |
PS/2 scancodes; more translation |
RFB's Raw encoding is genuinely tiny, and our frames are tiny (280×192×4 =
210 KB worst case, redrawn only when screen_dirty). Bandwidth is a
non-issue, so we do not even need compression to start. RFB input maps
one-to-one onto EWM's existing input surface. And the browser story — noVNC —
is the exact thing Proxmox uses.
RDP buys us Windows' built-in mstsc and a real audio channel, but at a cost
that is not justified for an 8-bit framebuffer. It stays as an optional later
track for people who specifically want native RDP.
Building the RFB server: hand-roll a minimal server in std::net
(blocking, thread-per-connection, like wozbug::Server), rather than pull in
a Tokio-based crate. This matches EWM's culture — the CPU, the BMP writer, the
PNG writer, the TTY, the palette, and the sound synthesis are all
implemented from scratch and unit-tested, with a deliberately small dependency
list. The Oxide rfb crate (consumer implements a Server trait that
supplies the framebuffer) and rustvncserver are excellent references,
and remain fallbacks if the hand-rolled path stalls, but the default is a
small rfb.rs we own and test the way snd.rs/scr.rs are tested.
3. Why EWM is unusually well-suited to this
Three facts about the current codebase make the embedded-server approach the natural one — and make the "run the real app under a virtual display" approach the unnatural one.
3.1 The renderer is already pure and headless. scr::Scr::update() is
SDL-free: it fills a Vec<u32> (pixels, 280-wide ][+ / wide, 560-wide
//e) in the renderer's pixel layout, and scr.frame(model) returns the
buffer. The Apple 1 / Replica 1 path is the same shape: tty::Tty renders
character cells into tty.pixels: Vec<u32>. This is already exercised
headlessly by the hidden --screenshot flag, the golden-BMP tests, and
encode_bmp(). A VNC server needs precisely this buffer and nothing more.
The overlay renderers (render_status_bar, render_led_strip,
palette::Palette, the pause Tty) are also pure Vec<u32> producers, so
the remote frontend can composite the same overlays into one framebuffer if we
want them later.
3.2 EWM already embeds a network server in the frame loop. wozbug::Server
(the --wozbug line server, notes/DEBUGGING_TOOLS.md) binds a
TcpListener on a background thread, talks to the machine over mpsc
channels, and the frame loop drains it between frames. The VNC server is the
same pattern: a listener thread per port, input events posted to the driver
over a channel, framebuffer snapshots handed back. No new concurrency model.
3.3 The "lean on existing X11 tools" path is actively blocked by our own
code. sdl::check_renderer() rejects the software renderer and requires
an accelerated one. Under Xvfb (a headless X server with no GPU) SDL3 falls
back to software → EWM refuses to start. So "run the normal SDL binary on
a virtual :99 display and point x11vnc at it" fights the existing renderer
gate, drags a full X server into every VM, and still needs all the input
plumbing. The embedded server needs no X server, no GPU, no display — it
reuses the pure renderer directly. (Xvfb+x11vnc is written up and rejected in
Section 11.)
The input surface we must drive is small and already factored:
two.key(u8)— a single ASCII entry point (the SDL loop's whole keyboard handler ultimately funnels here).two.set_button(n, state)/two.set_joystick(Option<(i16,i16)>)— paddles.two.cpu.reset(),two.load_disk(drive, path)— control.onehas the analogousone.key()/ PIA feed.
The SDL KeyDown handler in two::main (CR→0x0d, arrows→0x08/0x15/0x0b/ 0x0a, ESC→0x1b, Ctrl-A..Z → 1..26, Alt-1..4 → paddle buttons) is the
translation table we re-target from SDL keycodes to RFB/X11 keysyms.
4. Target architecture
┌──────────────────────── one host, one process per machine ────────────────────────┐
│ │
│ ewm two --serve vnc://0.0.0.0:5901 --config configs/2e.json │
│ ┌───────────────────────────────────────────────────────────────────────────┐ │
│ │ Machine driver (shared, frontend-agnostic — Phase 1) │ │
│ │ step CPU (speed/fps cycles) → Scr/Tty.update() → frame(): &[u32] │ │
│ │ ▲ input (key/button/joystick/reset) │ framebuffer │ │
│ └────────┼───────────────────────────────────────────┼───────────────────────┘ │
│ │ ▼ │
│ ┌────────┴────────────────────────────────────────────────────────────────┐ │
│ │ rfb.rs — embedded RFB server (background thread, std::net, like WozBug) │ │
│ │ RFB handshake · ServerInit · Raw FramebufferUpdate · KeyEvent/Pointer │ │
│ │ ├─ plain TCP :5901 ── native VNC clients (TigerVNC, RealVNC, …) │ │
│ │ └─ WebSocket :5701 ── noVNC in a browser (tungstenite upgrade) │ │
│ └──────────────────────────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────────────────────────┘
Web tier (Proxmox-console equivalent)
┌───────────────────────────────────────────────────────────────────────────────────┐
│ Browser ──HTTP──> static noVNC page (vendored, self-contained) │
│ ──WSS──> ws://host:5701 (RFB-over-WebSocket, straight into rfb.rs) │
│ │
│ "Hub" index page lists running machines and links to each console (like the │
│ Proxmox node view). Optional reverse proxy (nginx/Caddy) terminates TLS + auth. │
└───────────────────────────────────────────────────────────────────────────────────┘
Fleet: ewm-vnc@enhanced → :5901/:5701 ewm-vnc@plus → :5902/:5702 …
(systemd template unit or scripts/ewm-farm.sh; config name → port)
Process model: one machine = one process = one VNC port (+ one WebSocket port). This is the QEMU/Propolis/Proxmox model and it makes "N VMs on N ports" fall out for free — no shared state, crash isolation, trivial orchestration.
5. Web access — three ways, and which we pick
The web-console requirement (noVNC-style, like Proxmox) has three viable shapes. We can support more than one; the recommendation is (A) as the built-in default, with (C) documented for people who already run a gateway.
(A) Embedded WebSocket + vendored noVNC (recommended default). Put a
WebSocket upgrade (sync tungstenite) in front of the RFB state machine in
rfb.rs, so a browser's noVNC connects directly to EWM with no sidecar —
exactly how LibVNCServer/QEMU expose their built-in web consoles. Ship a tiny
static page bundling noVNC, served by a small built-in HTTP handler on the
same port. This is the true "single binary, Proxmox feel" experience:
http://host:5701/ → live Apple II in a tab.
(B) websockify in front of a plain-TCP VNC port (zero code). The stock
noVNC deployment: websockify bridges browser WebSocket ↔ our plain
:5901. Requires no EWM code at all, so it works from Phase 2 as an
interim browser path before (A) lands. Documented as the fallback.
(C) Apache Guacamole gateway (optional, best for fleets + auth). Guacamole
(guacd + web app) is a protocol-agnostic HTML5 gateway: point it at our VNC
ports and it gives a browser console plus authentication, a connection
list, and TLS for free — a ready-made multi-VM front door. It is a
deployment recommendation, not a substitute for EWM speaking a protocol:
EWM still serves VNC; Guacamole is one possible web tier over it. Recommended
for anyone running many machines who wants auth without building it.
We do not need a browser RDP client for v1. If Track B (RDP) is ever
built, its browser story is ironrdp-web (WASM), which Cloudflare and
Devolutions ship in production.
6. Config and CLI surface
A new top-level remote block in the JSON config (config.rs already has
Display, Machine, Input, Boot, Debug; this slots alongside), plus a
--serve CLI shorthand:
{
"machine": { "model": "2e", "slots": { "6": { "card": "diskii" } } },
"display": { "monitor": "rgb" },
"remote": {
"protocol": "vnc", // "vnc" (v1). "rdp" reserved for Track B.
"bind": "127.0.0.1", // default localhost; opt in to 0.0.0.0
"port": 5901, // plain-TCP RFB
"websocket": 5701, // optional; enables browser noVNC (Phase 4)
"web": true, // serve the vendored noVNC page (Phase 5)
"password": null, // null → VNC "None" auth (localhost/tunnel only)
"view_only": false
}
}
ewm two --serve vnc://0.0.0.0:5901 --config configs/2e.json
ewm two --serve vnc://0.0.0.0:5901?ws=5701&web=1 --config configs/2plus.json
--serve parses to the same remote struct and is mutually exclusive with
opening an SDL window. When remote is present (or --serve given), main
takes the headless driver + RFB frontend path instead of the SDL path.
7. Input mapping (RFB → machine)
RFB KeyEvent carries an X11 keysym + down flag; PointerEvent carries
button mask + x/y. The translation is a direct re-targeting of the existing
SDL KeyDown/KeyUp logic:
| RFB keysym | Machine action |
|---|---|
ASCII 0x20–0x7E (down) |
two.key(byte) |
XK_Return 0xFF0D |
two.key(0x0D) |
XK_BackSpace 0xFF08 / XK_Left 0xFF51 |
two.key(0x08) |
XK_Right 0xFF53 |
two.key(0x15) |
XK_Up 0xFF52 / XK_Down 0xFF54 |
two.key(0x0B) / 0x0A |
XK_Escape 0xFF1B |
two.key(0x1B) |
Ctrl held + A–Z |
two.key(letter - 'A' + 1) (1–26) |
XK_Tab 0xFF09 |
two.key(0x09) then 0x7F (mirrors the SDL quirk) |
XK_Delete 0xFFFF |
two.key(0x7F) |
| Reset | a chosen combo (e.g. Ctrl+F12) → two.cpu.reset() |
Modifier state (Ctrl/Alt) is tracked from keysym up/down, exactly as
keymod is today. Paddle buttons reuse the Alt-1..4 mapping, or move to
PointerEvent buttons once the AppleMouse card lands (see IDEAS.md). The
//e vs ][+ keyboard differences (lower case, Open/Solid-Apple) are the
same branch the SDL path already takes on two.model().
RFB clipboard (ClientCutText / the ExtendedClipboard pseudo-encoding) is an
optional nicety for paste-in; deferred past v1.
8. Rendering / RFB details
- Pixel format: advertise a
PIXEL_FORMATinServerInitthat matches ourPixelLayout(32-bit, 8-8-8), so the framebuffer ships with no per-pixel conversion. If a client requests a differentSetPixelFormat, convert in the server (rare; most clients accept the server's). - Frame dims: read straight from the driver —
frame_width(model)× 192 fortwo(280 or 560),TTY_PIXEL_WIDTH × TTY_PIXEL_HEIGHTforone. - Update cadence: the RFB client drives with
FramebufferUpdateRequest(usually incremental). Serve a frame when the machine isscreen_dirtyor the text-flash phase flips — reusing the existingscreen_dirty()+phasesignals. Coalesce to the machine's fps. - Encoding: Raw to start (tiny frames, trivially correct). Add
CopyRectand then a compressed encoding (Tight/ZRLE, both noVNC supports) only if we ever want to cut bandwidth — not needed for this frame size, so it stays a later optimisation, not a v1 requirement. - Scaling: the client scales; we send native pixels (RFB has no server scaling). noVNC and native clients both upscale the low-res frame.
9. Audio (deferred, path documented)
Update: the WebAudio side-channel is now fully planned in
notes/VNC.md(phases A1–A3), alongside a description of the browser console as built.
RFB has no audio. The speaker beep is dropped over VNC in v1 — acceptable, and called out in docs. Two later paths, neither blocking v1:
- WebAudio side-channel.
snd::Wavealready renders the speaker to i16 PCM SDL-free. Stream those samples over a second WebSocket and play them via the Web Audio API in the noVNC page. Self-contained, browser-only. - RDP
rdpsnd. If Track B (RDP) is built, it carries audio natively.
10. Security
- Default bind is
127.0.0.1. Exposing a machine is an explicitbind: 0.0.0.0(or--serve vnc://0.0.0.0:…). - VNC auth: the RFB DES challenge (
password) is weak on its own; the intended posture is VNC "None" behind a boundary — an SSH tunnel, or a TLS-terminating reverse proxy / Guacamole that does real auth. This mirrors Proxmox, which fronts noVNC with a ticketed, authenticated web tier rather than trusting VNC auth. - WebSocket/TLS: terminate
wss://at nginx/Caddy (or Guacamole); EWM speaks plaintext RFB/WS on localhost behind it. A ticket/token scheme like Proxmox'svncproxyis a possible later addition for the built-in web tier. - Document all of this loudly; the failure mode (an open VNC port on
0.0.0.0with no auth) should never be the default.
11. Alternatives considered and rejected (as the primary path)
- Xvfb + x11vnc + the real SDL binary. Blocked by
sdl::check_renderer()'s accelerated-renderer requirement (software renderer under Xvfb → EWM exits); one full X server per VM; still needs input plumbing; heavier and more fragile than reusing the pure renderer. Could be made to work with a GL virtual display (Mesallvmpipe/ VirtualGL), but that is more moving parts than the embedded server, for no benefit. Rejected. xrdp+ an X session. Same X-server-per-VM weight, plus RDP's security stack, to reach a Windows client we do not need. Rejected as primary (see Track B for native RDP done properly).- Guacamole as a substitute for a protocol. Guacamole is a gateway, not a display server — EWM still has to speak VNC/RDP underneath it. So it is a recommended web tier (Section 5C), not an alternative to the embedded server.
- A Tokio-based VNC crate (
rustvncserver). Capable and current, but pulls the async runtime into a project that is deliberately blocking-threads + tiny-deps. Kept as a reference/fallback, not the default.
12. Status
Each row is one PR. two (][+ / //e, bitmap) is the lead target; one
(Apple 1 / Replica 1, TTY) reuses the same frontend since its display is also
a pure Vec<u32>.
| Phase | Description | Size | Status |
|---|---|---|---|
| 0 | This plan; remote config + --serve parsing (validates, errors "not built") |
S | Prototype ✅ |
| 1 | Frontend-agnostic rendering path: a pure Vec<u32> overlay compositor (overlay.rs) both frontends share, so passive overlays (drive lights, pause box) reach VNC too |
M | Landed ✅ (as a compositor — see note) |
| 2 | rfb.rs: minimal RFB server (handshake, ServerInit, Raw update, KeyEvent/Pointer) on a background thread; --serve vnc://… serves two to a native VNC client |
L | Prototype ✅ |
| 3 | Input completeness: control keys, Ctrl/Alt, arrows, reset, paddle buttons; //e vs ][+ keyboard parity with the SDL path |
M | Prototype (core keymap ✅; paddles partial) |
| 4 | Embedded WebSocket transport (hand-rolled ws.rs, not tungstenite — see §14) → noVNC connects directly, no websockify |
M | Prototype ✅ |
| 5 | Vendored noVNC web page + tiny built-in HTTP handler; per-machine console + a "hub" index (Proxmox-console feel) | M | Prototype ✅ (console; hub deferred to Phase 6, where the machine list exists) |
| 6 | Multi-VM orchestration: ewm-vnc@.service systemd template / scripts/ewm-farm.sh; config→port; docs |
M | Not started |
| 7 | one (Apple 1 / Replica 1) served through the same frontend |
S | Not started |
| B1 | (optional Track B) ironrdp-server RDP frontend |
L | Optional |
| B2 | (optional) Audio side-channel (WebAudio) and/or Guacamole deployment recipe | M | Audio planned — see notes/VNC.md |
Prototype note (branch claude/remote-console)
A first working prototype landed on this branch, collapsing Phases 0/2/3 into one pass so the feature is tryable now with a native VNC client. What was built:
remoteconfig block +--serve vnc://host:port(config.rs,two.rs). Boots headless when present;"rdp"and port 0 are rejected invalidate().rfb.rs— a hand-rolled RFB 3.8/3.7/3.3 server onstd::net, Raw encoding, one handler thread per client (multiple viewers of one machine),mpscinput back to the emulator, big-endian-RGBA pixels shipped withu32::to_be_bytes(no per-pixel conversion). Security isNoneby default, or VNC authentication (the RFB DES challenge, RFC 6143 §7.2.2) when a password is set — which is what lets macOS Screen Sharing connect, since it refuses theNonetype. Unit-tested: full-handshake round-trip + firstFramebufferUpdatedecode, key/pointer delivery, view-only, and VNC-auth accept/reject.des.rs— a tiny from-scratch DES (FIPS 46-3, ECB, encrypt-only) for the VNC-auth challenge, unit-tested against the textbook and all-zero vectors, and cross-checked end-to-end against OpenSSL's DES (an OpenSSL-computed auth response is accepted by the server, so the wire format matches a reference).- Headless serve loop in
two::serve()— the SDL frame loop's shape without SDL, reusingbuild_machine()and the pureScrrenderer. Keysym→byte translation (printable, arrows, Return, ESC, Tab/Del, Ctrl-letter, Ctrl+F12 reset) mirrors the SDL keyboard table. Translated bytes are paced through a queue (RemoteKeys::pump): the Apple II keyboard is a one-byte latch and a browser delivers a whole typed word within one frame, so each byte feeds only after the ROM consumed the previous one (strobe via$C010) — otherwise every key but the last is overwritten. Free side effect: type-ahead while booting. web.rs+novnc/(Phase 5) — the embedded web console. noVNC's engine (core/+vendor/pako, pinned v1.6.0, MPL-2.0 — license vendored alongside, seeewm/novnc/README.md) plus EWM's own minimal console page are embedded into the binary byewm/build.rsand served for plain HTTP requests on the WebSocket port — one port carries the page and its RFB stream, the Proxmox shape, and the page connects back to wherever it was served from so nothing needs configuring. Strictly opt-in tiers:--serve vnc://…= native VNC only;?ws=PORTadds the raw websocket (bring-your-own noVNC, plain HTTP →426);?web=PORTserves the console too (remote.webin config). No filesystem access at runtime; the asset table is static and literal (no path traversal to have).ws.rs(Phase 4) — a hand-rolled WebSocket (RFC 6455) server transport: HTTP upgrade (SHA-1 + base64, tested against the RFC vectors), binary-frame encode/decode with client-mask enforcement, ping→pong, close echo. Exposed asio::Read/io::Writeadapters, sorfb.rs's state machine runs over plain TCP or WebSocket verbatim — one protocol, two transports, both listeners feeding the same machine.remote.websocket/--serve …?ws=5701adds the browser port; noVNC connects to it directly, no websockify. (noVNC'sSetPixelFormatrequest — little-endian, shifts 0/8/16 — is byte-identical on the wire to our advertised big-endian 24/16/8, so ignoring it is safe.)
Verified end-to-end: booted the DOS 3.3 System Master and a bare Applesoft ][+
over vnc://, typed PRINT 2+2 and saw 4. All gates green (cargo fmt --check, clippy -D warnings, cargo test incl. the golden-BMP tripwires).
Phase 1 — landed as a shared compositor, not a Driver struct. The
prototype ran a parallel headless serve() loop, which was fine for the
machine + input but meant the SDL loop's overlays (drive lights, the pause
box) were composited with SDL canvas calls the VNC path never saw — so a
paused machine looked frozen-but-unmarked in a browser. Rather than a full
Two-owning Driver (both loops already build the machine identically via
power_on_machine, so wrapping ownership bought little), Phase 1 extracted
the thing that was actually duplicated and missing remotely: the render
compositing. overlay.rs is a pure Vec<u32> Compositor that stamps the
passive overlays onto the machine's screen frame; both frontends hold one —
the SDL loop uploads its output to one texture (replacing the separate LED
and pause textures), the serve loop publishes it to VNC. Interactive and
window-scaling chrome stays per-frontend by design (§15): the command palette
is SDL-only UI, the status bar is a strip below the screen, and scanlines are
an up-scaling effect the client does itself. Golden-BMP tests still pass
(they capture the pre-overlay scr.frame()), so the machine's rendered
screen is byte-for-byte unchanged.
Try it:
# macOS Screen Sharing refuses "None" auth, so give it a password:
cargo run -p ewm -- two --serve 'vnc://127.0.0.1:5901?password=secret' \
--set machine:slots:6:drive1=disks/DOS33-SystemMaster.dsk
# then, in another terminal:
open vnc://127.0.0.1:5901 # enter the password when prompted
# or embed it: open vnc://:secret@127.0.0.1:5901
# Other clients (TigerVNC, RealVNC) accept "None", so a password is optional:
cargo run -p ewm -- two --serve vnc://127.0.0.1:5901 --set machine:slots:6:card=empty
# The password can also come from the config `remote` block or a --set:
# --set remote:password=secret
# Expose to the LAN (still weak auth — see §10, keep it behind a tunnel):
# --serve 'vnc://0.0.0.0:5901?password=secret'
# Browser (Phase 5): ?web=<port> serves the embedded console — a live Apple II
# in a tab with no external tooling at all:
cargo run -p ewm -- two --serve 'vnc://127.0.0.1:5901?web=5701' \
--set machine:slots:6:drive1=disks/DOS33-SystemMaster.dsk
open http://127.0.0.1:5701/
# Or bring your own noVNC against the raw websocket (Phase 4, no console page):
# --serve 'vnc://127.0.0.1:5901?ws=5701'
# then point any noVNC's vnc.html at host=127.0.0.1&port=5701.
13. Phases in detail
Phase 0 — Plan + config/CLI surface (no server yet)
This document, plus the remote config struct in config.rs (with schema +
apply_set support and validation, following the Display/fps pattern) and
--serve vnc://… parsing. With no server compiled in yet, --serve prints a
clear "remote console not built (Phase 2)" and exits. Gate: config
round-trips through the schema test; SDL path untouched; fmt/clippy/test green.
Phase 1 — Frontend-agnostic rendering path (the shared compositor)
Originally scoped as a Driver owning Two + Scr. As built: the valuable,
missing piece was shared compositing, not shared ownership (both frontends
already construct the machine identically). ewm/src/overlay.rs is a pure
Vec<u32> Compositor: compose(screen, w, h, &Overlays) -> &[u32] stamps
the passive overlays — drive lights (led.rs) and the pause / restored-state
box (tty.rs) — onto the machine's screen frame, SDL-free and unit-tested.
The SDL loop uploads the composited frame to its single screen texture (the
separate LED and pause textures are gone); the headless serve loop publishes
it to VNC, so the pause box and drive lights now show in a browser.
Scanlines, the status bar, and the command palette remain SDL window chrome.
Gate: the golden-BMP tests still pass (they capture the pre-overlay
scr.frame()); the PAUSED box verified over VNC in a real Chrome tab;
fmt/clippy/test green.
Phase 2 — Minimal RFB server, native client
New rfb.rs: RFB 3.8 (and 3.3) handshake, security type None (auth is
Phase 10-ish / Section 10), ServerInit advertising our pixel format and
frame dims, FramebufferUpdate with Raw encoding on
FramebufferUpdateRequest, and decode of KeyEvent + PointerEvent. Runs on
a background listener thread (thread-per-connection, mpsc to the driver,
drained per frame — the wozbug::Server shape). Wire --serve vnc://… to run
the driver headless and pump the server each tick. Port the ASCII/printable
subset of the keymap (Section 7). Gate: connect with TigerVNC/RealVNC,
watch it boot a disk, type into AppleSoft BASIC. Add a headless unit test that
drives the RFB handshake over a loopback socket and asserts the first
FramebufferUpdate decodes to the expected dims (mirrors wozbug's
server_round_trip).
Phase 3 — Input completeness
Full keysym table: control keys, Ctrl-letter → 1–26, arrows, ESC, Tab/Delete
quirks, a reset combo, paddle buttons, and the //e/][+ keyboard branch.
Track modifier state from key up/down. Gate: a keysym→byte unit test
table; manual parity check against the SDL keyboard.
Phase 4 — Embedded WebSocket transport
Serve the WebSocket Upgrade handshake on the configured websocket port and
frame RFB inside WS messages, so noVNC connects directly — no websockify.
Keep plain-TCP RFB working in parallel. Gate: noVNC (pointed straight at
ws://host:5701) boots and drives a machine in a browser.
As built (prototype): hand-rolled in ws.rs rather than pulling in
tungstenite — the plan's original choice would have added ~9 transitive
crates for a server-side subset (upgrade + binary frames + ping/pong/close)
that is ~350 lines here, hand-rolled and RFC-vector-tested exactly like
des.rs. tungstenite remains the fallback if interop issues surface. The
transport is io::Read/io::Write adapters over the socket, so rfb.rs's
state machine is shared verbatim between TCP and WS.
Phase 5 — Vendored web console (the Proxmox-console piece)
Bundle a pinned, self-contained noVNC build and serve it from a small built-in
HTTP handler on the WS port: GET / returns the console page wired to this
machine's WebSocket. Add a minimal hub index (static or a tiny endpoint)
that lists running machines and links to each console — the Proxmox node-view
feel. Gate: http://host:5701/ shows a live, interactive Apple II with no
external tooling.
As built (prototype): novnc/ vendors the pinned engine (v1.6.0 core/ +
vendor/pako, ~700 KB — app/'s full UI chrome is skipped in favour of a
small EWM console page); build.rs embeds the tree; web.rs serves it. The
console is opt-in (web/?web=PORT) — a machine can run VNC-only, or
VNC + raw websocket, or VNC + console; without web the WS port answers
plain HTTP with 426 exactly as in Phase 4. The hub index moved to
Phase 6: a per-machine process has no machine list to serve, the orchestrator
does. Gate run in a real Chrome tab: http://127.0.0.1:5701/ booted the DOS
3.3 System Master and CATALOG typed at browser speed listed the disk.
Phase 6 — Multi-VM orchestration
Suspend/resume is available (notes/STATE.md): start a machine with a state path (config
state.path/--set state:path=…) and SIGTERM hibernates it — the state saves atomically and the next start resumes exactly where it left off. A systemd unit gets VM-style suspend/resume by just adding the setting.
A systemd template unit ewm-vnc@.service (instance name = config file →
deterministic port pair) and/or scripts/ewm-farm.sh that launches a set of
configs on a port range. Document the reverse-proxy + Guacamole options
(Section 5/10). Gate: boot configs/2plus.json and configs/2e.json
(and a third) on distinct ports; reach each from a browser tab.
Phase 7 — Apple 1 / Replica 1 over the same frontend
Give one a Driver the same way (its Tty.pixels is already a Vec<u32>),
so the Woz Monitor / KRUSADER machines are reachable remotely too. Gate:
ewm one --config builtin:replica1 --serve vnc://… reachable from noVNC.
The config surface this phase wanted now exists (
plans/20260719-02-one-config.md):onespeaks the full source language, and theremoteblock is rejected for the one family in a single place (config::validate_complete) — this phase relaxes that check and wires the block, nothing more.
Track B (optional) — native RDP, audio, gateways
- B1: an
ironrdp-serverfrontend (Tokio) reusing the sameDriverboundary; browser client viaironrdp-web. Kept isolated behind a Cargo feature so the default build stays Tokio-free. - B2: the WebAudio speaker side-channel (Section 9) and a documented Guacamole deployment for authenticated fleet access.
14. Dependencies (kept deliberately small)
- v1 core: nothing beyond
std::net+ threads (thewozbugmodel) for the RFB server itself. - Phase 4: none — the WebSocket transport is hand-rolled (
ws.rs, RFC-vector-tested).tungstenite(sync, no async runtime) stays listed as the fallback if interop issues ever surface. - Phase 5: noVNC is a vendored static asset, not a Cargo dependency.
- Track B only:
ironrdp-server+ Tokio, behind a Cargo feature. - References/fallbacks (not linked by default): Oxide
rfb,rustvncserver.
Everything in v1 stays consistent with the current lean dep list
(sdl3, serde, serde_json, fontdue, chrono) and the blocking-I/O
style already used by WozBug.
15. Open questions
- Reset gesture over RFB. No standard "send Ctrl-Reset" — pick a combo (Ctrl+F12?) or expose it via the web page UI / a Guacamole menu.
- Auth for the built-in web tier. Ship "None + bind localhost + document a proxy," or build a Proxmox-style ticket now? Leaning: document the proxy for v1, revisit a token scheme in Phase 6.
- Overlays remote-side. Resolved (Phase 1). The passive display
overlays — drive lights and the pause / restored-state box — are composited
into the framebuffer by the shared
overlay.rsCompositor, so every client (native and web) sees them; this is the machine's screen output, not per-viewer chrome, so it is not a toggle. Interactive chrome stays per-frontend: the command palette is SDL-only UI (the web console grows its own HTML controls — the Reset/Reboot/Pause buttons), the status bar is a strip below the screen, and scanlines are an up-scaling effect the client applies. The one deliberate difference: baked overlays are the low-res machine pixels; the web page may later draw crisper HTML on top where it wants. - Per-connection vs shared view. v1 = one machine, many viewers see the same screen (RFB shared-flag). Fine for a personal fleet.
16. References
VNC / RFB servers in Rust:
- Oxide
rfb— trait-based server-side RFB rustvncserver(RFC 6143, 11 encodings, Tokio)- whitequark
rust-vnc
RDP in Rust:
Web consoles:
- noVNC — HTML5 VNC client
- websockify — WebSocket↔TCP bridge
- Apache Guacamole architecture (
guacd) - Proxmox
vncproxy/vncwebsocketticketed console (the reference UX).
Protocol:
In-tree precedents this design leans on:
ewm/src/wozbug.rs— the embeddedTcpListener+mpsc+ frame-loop-drain server pattern the RFB server copies.ewm/src/scr.rs/ewm/src/tty.rs— the pureVec<u32>renderers the server reads (already headless via--screenshotand the golden tests).ewm/src/config.rs— theDisplay/Machineconfig + schema +apply_settheremoteblock extends.