From f3cc1242c4fb9a1196166a5fe613e921c8efc2ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sun, 23 Aug 2026 17:43:13 +0000 Subject: [PATCH] minesweeper: extracted from the platform MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Minesweeper X under wine on a headless display, as a plugin. It was /api/minesweeper, a Dashboard screen, an officerdev panel, a permission registry entry and a hand-written websocket provider in server.tsx. The socket is why it had to live in the platform: Bun's route table is fixed at serve(), so nothing installed later could add one. That ceiling is gone, and this is the first plugin through the per-plugin relay. Three things changed rather than moved: The sidecar owns the session. The platform used to spawn bin/session.sh detached, so the game outlived every restart, nothing supervised it, and a crashed run left wine processes holding the prefix — the next start hung rather than failed. PM2 supervises a sidecar, and stop() uses 'wineserver -k' scoped by WINEPREFIX instead of 'pkill -f wineserver', which would have killed every other wine prefix on the machine. x11vnc moved from 127.0.0.1:5907 to a unix socket in a mode-0700 directory. -nopw was only ever safe because of the machine, not the platform: any local account could open that port and take the display. A fixed port is squattable too. There is now no listener to authenticate against. The wine prefix is confined to C:. This is the one that mattered. The platform classified Minesweeper as kind:'execution' — never grantable — because a fresh prefix maps the whole filesystem to Z: and symlinks the profile into the owner's real home, and the game has Open and Save dialogs. A plugin permission has no kind and is always grantable, so extracting it without this would have converted a sealed capability into a file picker running as the owner. Re-asserted on every start, because wine restores z: whenever it updates a prefix. Not solved: one display and one game, so two grantees share a board. Co-Authored-By: Claude Opus 5 Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043 --- .gitignore | 1 + README.md | 65 ++++++++ api/router.ts | 27 ++++ game/Minesweeper X.exe | Bin 0 -> 68284 bytes manifest.ts | 125 ++++++++++++++ sidecar/index.ts | 209 ++++++++++++++++++++++++ sidecar/paths.ts | 39 +++++ sidecar/prefix.ts | 147 +++++++++++++++++ sidecar/run.ts | 39 +++++ sidecar/session.ts | 261 ++++++++++++++++++++++++++++++ web/MinesweeperPanel.tsx | 260 +++++++++++++++++++++++++++++ web/layout.ts | 9 ++ web/panels.ts | 10 ++ web/rfb.ts | 341 +++++++++++++++++++++++++++++++++++++++ 14 files changed, 1533 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 api/router.ts create mode 100644 game/Minesweeper X.exe create mode 100644 manifest.ts create mode 100644 sidecar/index.ts create mode 100644 sidecar/paths.ts create mode 100644 sidecar/prefix.ts create mode 100644 sidecar/run.ts create mode 100644 sidecar/session.ts create mode 100644 web/MinesweeperPanel.tsx create mode 100644 web/layout.ts create mode 100644 web/panels.ts create mode 100644 web/rfb.ts diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c2658d7 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +node_modules/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..caa4035 --- /dev/null +++ b/README.md @@ -0,0 +1,65 @@ +# Minesweeper + +Minesweeper X, running under wine on a headless X display, streamed into an Officer panel. + +It is a game. It is also the smallest honest proof that Officer can put an arbitrary X11 program in a +panel — a framebuffer, a process on it, a VNC server reading it, a client in the browser — and it is the +first plugin to use the platform's per-plugin websocket relay. + +## What it needs + +Six host binaries, declared in `manifest.ts` and checked before install: `wine`, `Xvfb`, `x11vnc`, +`xdotool`, `xdpyinfo`, `winetricks`. + +The awkward one is wine. Minesweeper X is a **32-bit** PE (machine `0x014c`, Visual Basic 6), so the host +needs i386 multiarch and a `win32` prefix. A 64-bit-only wine cannot load it, and the failure is a bare +`Bad EXE format`. On Debian or Ubuntu: + +``` +sudo dpkg --add-architecture i386 +sudo apt update +sudo apt install wine wine32:i386 xvfb x11vnc x11-utils xdotool winetricks +``` + +## The game + +[Minesweeper X](https://minesweepergame.com/download/minesweeper-x.zip) — the competition build, with +timing and replay extras. `game/Minesweeper X.exe` is committed; it is 68 KB and it is the thing being +run rather than state produced by running it. + +| | | +| --- | --- | +| zip sha256 | `21089193924919ef876a5b464f06bed9cc149867c51e40c259288ebe623014f0` | +| exe sha256 | `e414c207adaa14999b0464c3d0a701f20ed11282d5ed0f84b27b62d25389f661` | + +The download needs a browser `User-Agent`; the site answers `403` to curl's default. + +## Where its state lives + +`DATA_PATH/minesweeper/` — never in this directory, which `plugins:update` will `git pull` over. + +- `prefix/` — the wine prefix, ~600 MB, built on first `POST /start`. Deleting it is the standard fix for + a wedged wine, and it is safe: the next start rebuilds it. +- `run/` — pid state, logs, and `vnc.sock`. **Mode 0700, and that is load-bearing.** + +## Two things worth knowing before you change anything + +**x11vnc has no password, and that is correct.** It binds a unix socket inside `run/`, not a port. The +kernel refuses every other account before x11vnc is asked anything, so a password would be a second lock +on a door only one person can reach. The version this replaced bound `127.0.0.1:5907` with `-nopw`, which +is a different claim entirely — every local account could open it and take the display. + +**The wine prefix is confined to `C:` on every start.** A fresh prefix maps the whole filesystem to `Z:` +and symlinks the profile into the real home, and Minesweeper X has Open and Save dialogs for its replays. +That is why this was an ungrantable `execution` capability in the platform. `sidecar/prefix.ts` closes it +instead, which is what makes the plugin's permission safe to grant. Wine restores `z:` whenever it updates +a prefix, so the confinement is re-asserted rather than done once. + +## Contract + +``` +GET /api/minesweeper/status { installed, running, window, error? } +POST /api/minesweeper/start idempotent; builds the prefix on a cold install (minutes) +POST /api/minesweeper/stop + /api/plugin-ws/minesweeper the display, as RFB +``` diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..ee16f13 --- /dev/null +++ b/api/router.ts @@ -0,0 +1,27 @@ +import { createSidecarProxy } from '@@/sidecar/create-proxy'; +import { mountPrefix } from '@@/plugins/manifest'; +import { manifest } from '../manifest'; + +// /api/minesweeper/* — authenticate, then forward to officer-minesweeper. Three routes, no logic: +// +// GET /status { installed, running, window } +// POST /start brings the session up (idempotent) +// POST /stop takes it down +// +// The pixels do not come through here. They come through `/api/plugin-ws/minesweeper`, which the platform +// relays onto the same sidecar's `/ws` — the socket and these routes share one listener and one announced +// port, which is the whole reason a plugin can have both. +// +// The prefix is DERIVED rather than written. `mountPrefix` returns `/minesweeper` for a first-party +// publisher, but published by anyone else this mounts at `/api/p//minesweeper`, and the proxy +// strips exactly `prefix.length` characters to build the sidecar's path. A literal would forward +// `/alice/minesweeper/status` to a sidecar expecting `/status`. +const proxy = createSidecarProxy({ + name: 'minesweeper', + prefix: `/api${mountPrefix({ appName: 'minesweeper', manifest })}`, + // Building the wine prefix on a cold install fetches and unpacks the VB6 runtime. It happens once, inside + // POST /start, and it is minutes rather than seconds. + timeoutSeconds: 600, +}); + +export const router = proxy.router; diff --git a/game/Minesweeper X.exe b/game/Minesweeper X.exe new file mode 100644 index 0000000000000000000000000000000000000000..7220fdb9a7c42645767a249021ee7a0791a8ef34 GIT binary patch literal 68284 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.0.0', + + label: 'Minesweeper', + summary: 'Minesweeper X under wine, on a headless display, streamed to the browser', + icon: 'Bomb', + color: '#64748b', + + // The relay target. Not a port: a sidecar name, so the port is one the platform watched arrive at + // registration rather than one this file asserted. See `servers/api/plugin-ws/target.ts`. + websocket: { sidecar: 'minesweeper' }, + + permissions: [ + { + key: 'minesweeper', + label: 'Minesweeper', + description: 'Play Minesweeper on the server display. The game is confined to its own wine prefix and can reach nothing else on the machine.', + }, + ], + + osDependencies: [ + { + binary: 'wine', + reason: 'Runs the game. Minesweeper X is a 32-bit Windows executable, so this host also needs i386 multiarch (`dpkg --add-architecture i386`) and the 32-bit wine package — a 64-bit-only wine cannot load it.', + packages: { apt: 'wine wine32:i386', dnf: 'wine', pacman: 'wine', brew: 'wine-stable' }, + scope: 'owner', + }, + { + binary: 'Xvfb', + reason: 'The virtual display the game draws on. A framebuffer with no hardware behind it, so nothing appears on any real monitor.', + packages: { apt: 'xvfb', dnf: 'xorg-x11-server-Xvfb', pacman: 'xorg-server-xvfb' }, + scope: 'owner', + }, + { + binary: 'x11vnc', + reason: 'Reads that display and serves it, so the browser has something to draw. Bound to a private unix socket, never a port.', + packages: { apt: 'x11vnc', dnf: 'x11vnc', pacman: 'x11vnc' }, + scope: 'owner', + }, + { + binary: 'xdotool', + reason: 'Finds the game window and reads its size, so the panel can fit its canvas to the board instead of the whole framebuffer.', + packages: { apt: 'xdotool', dnf: 'xdotool', pacman: 'xdotool' }, + scope: 'owner', + }, + { + binary: 'xdpyinfo', + reason: 'Waits for the display to be usable. A live Xvfb process is not the same as a display that answers, and starting the game too early fails silently.', + packages: { apt: 'x11-utils', dnf: 'xorg-x11-utils', pacman: 'xorg-xdpyinfo' }, + scope: 'owner', + }, + { + binary: 'winetricks', + reason: 'Installs the Visual Basic 6 runtime into the wine prefix on first start. Minesweeper X needs MSVBVM60.DLL, which wine does not ship; without it the process exits immediately with nothing but an unresolved-import line.', + packages: { apt: 'winetricks', dnf: 'winetricks', pacman: 'winetricks' }, + scope: 'owner', + }, + ], +}; diff --git a/sidecar/index.ts b/sidecar/index.ts new file mode 100644 index 0000000..ef47f41 --- /dev/null +++ b/sidecar/index.ts @@ -0,0 +1,209 @@ +import type { ServerWebSocket, Socket } from 'bun'; +import { createSidecarConnector } from '@@/sidecar/connect'; +import { API_URL } from '@@/officer-url.mjs'; +import { VNC_SOCKET, ensureDirs } from './paths'; +import { isRunning, start, status, stop } from './session'; + +/* + officer-minesweeper — owns the game session, and serves both halves of the plugin from one listener. + + ───────────────────────────────────────────────────────────────────────────────────────────────── + HTTP CONTRACT — the platform strips its /api/minesweeper mount prefix before forwarding. + + GET /status { installed, running, window, error? } + POST /start brings the session up; idempotent, and builds the wine prefix on a cold install + POST /stop takes it down + GET /ws the display — an RFB stream, relayed frame-for-byte onto x11vnc's unix socket + + ───────────────────────────────────────────────────────────────────────────────────────────────── + WHY ONE PORT SERVES BOTH + + A sidecar announces exactly one port, and that single announcement is the entire reason the platform + trusts it: the port was observed arriving at registration rather than asserted by a manifest. So `/ws` + lives here, beside the routes, on the listener that was announced — and the platform's plugin socket + relay moves frames onto it without knowing that RFB exists. + + The conversion from frames to a byte stream happens HERE, in the only process that knows what it is + carrying. That is the whole shape: the platform authenticates and relays; the plugin speaks its protocol. + + ───────────────────────────────────────────────────────────────────────────────────────────────── + WHAT REACHES x11vnc + + Nothing but this file. x11vnc binds a socket inside a mode-0700 directory and has no port at all, so the + kernel refuses every other account before any credential could be checked. That is why `-nopw` is right + rather than lazy — see the block comment in `session.ts` where it is spawned. + + This listener is loopback and unauthenticated, exactly like every other sidecar in the fleet; the platform + is the authenticator and `X-Officer-User` is how a request proves it came through one. +*/ + +const NAME = 'minesweeper'; +const log = (line: string) => console.log(`[${NAME}] ${line}`); + +ensureDirs(); + +/** An ephemeral free port, grabbed by briefly binding one. Announced at registration; never written down. */ +function getFreePort(): number { + const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); + const p = probe.port; + probe.stop(true); + if (p == null) throw new Error('failed to acquire a free port'); + return p; +} + +const port = getFreePort(); + +type WsData = { userId: number }; +type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean }; +const relays = new Map, Relay>(); + +const teardown = (ws: ServerWebSocket, code: number, reason: string) => { + const relay = relays.get(ws); + relays.delete(ws); + if (relay) relay.done = true; + try { + relay?.socket?.end(); + } catch { + /* already gone */ + } + try { + ws.close(code, reason); + } catch { + /* already closing */ + } +}; + +const server = Bun.serve({ + port, + hostname: '127.0.0.1', + + async fetch(req, srv) { + const url = new URL(req.url); + + // Loopback plus this header is the trust signal every sidecar here uses: a request without it did not + // come through the platform, and the platform is what decided the caller may play. + const officerUser = req.headers.get('X-Officer-User'); + const userId = Number(officerUser); + if (!officerUser || !Number.isInteger(userId) || userId <= 0) { + return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); + } + + if (url.pathname === '/ws') { + if (srv.upgrade(req, { data: { userId } satisfies WsData })) return undefined as unknown as Response; + return new Response('expected a websocket upgrade', { status: 426 }); + } + + try { + if (req.method === 'GET' && url.pathname === '/status') return Response.json(await status()); + if (req.method === 'POST' && url.pathname === '/start') { + const result = await start(log); + // 503 rather than 500: the session is unavailable and the reason is in the body, which is what the + // panel shows instead of an empty canvas. + return Response.json(result, { status: result.running ? 200 : 503 }); + } + if (req.method === 'POST' && url.pathname === '/stop') return Response.json(await stop()); + } catch (err) { + console.error(`[${NAME}] ${req.method} ${url.pathname} failed`, err); + return Response.json({ error: 'internal error' }, { status: 500 }); + } + + return Response.json({ error: 'not found' }, { status: 404 }); + }, + + websocket: { + open(ws: ServerWebSocket) { + /* + The queue is not an optimisation. The client is connected the moment the upgrade returns and an RFB + client sends its protocol version immediately, while `Bun.connect` is still resolving. Dropping + those bytes desynchronises the handshake in a way that renders as a corrupt display rather than as + a lost packet. + */ + const relay: Relay = { socket: null, queue: [], done: false }; + relays.set(ws, relay); + + if (!isRunning()) { + teardown(ws, 1011, 'the session is not running'); + return; + } + + void Bun.connect({ + unix: VNC_SOCKET, + socket: { + open(socket) { + if (relay.done) { + socket.end(); + return; + } + relay.socket = socket; + for (const chunk of relay.queue) socket.write(chunk); + relay.queue.length = 0; + }, + data(_socket, chunk) { + try { + ws.send(chunk); + } catch { + /* viewer gone; close tidies up */ + } + }, + close: () => teardown(ws, 1000, 'display closed'), + error: () => teardown(ws, 1011, 'display error'), + }, + }).catch(() => teardown(ws, 1011, 'could not reach the display')); + }, + + message(ws: ServerWebSocket, raw: string | Buffer) { + const relay = relays.get(ws); + if (!relay) return; + // RFB is binary both ways; a text frame is a client that is not speaking it. + const bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : new Uint8Array(raw); + if (relay.socket) relay.socket.write(bytes); + else relay.queue.push(bytes); + }, + + close(ws: ServerWebSocket) { + const relay = relays.get(ws); + relays.delete(ws); + if (relay) relay.done = true; + try { + relay?.socket?.end(); + } catch { + /* already gone */ + } + }, + }, +}); + +log(`listening on 127.0.0.1:${port}`); + +const connection = createSidecarConnector({ + apiUrl: `${API_URL}/api/sidecar/register`, + name: NAME, + handles: [NAME], + onCommand(cmd, reply) { + const c = cmd as { type: string; id?: string }; + if (c.type === 'ping') reply({ type: 'pong', id: c.id } as never); + else reply({ type: 'error', id: c.id, error: `Unknown command type: ${c.type}` } as never); + }, + onConnected() { + // The announcement the plugin websocket relay resolves against. One port, both halves. + connection.send({ type: `${NAME}:server`, port } as never); + log(`reported server port ${port} to the platform`); + }, +}); + +async function shutdown(signal: string) { + log(`${signal} received, shutting down`); + try { + server.stop(true); + } catch { + /* already stopped */ + } + // The game is not left running. It is a child of this process, so PM2 would reach it anyway — but wine's + // own daemons are not, and leaving those holding the prefix is what makes the NEXT start hang. + await stop().catch(() => undefined); + connection.destroy(); + process.exit(0); +} + +process.on('SIGTERM', () => void shutdown('SIGTERM')); +process.on('SIGINT', () => void shutdown('SIGINT')); diff --git a/sidecar/paths.ts b/sidecar/paths.ts new file mode 100644 index 0000000..0f8b7c3 --- /dev/null +++ b/sidecar/paths.ts @@ -0,0 +1,39 @@ +import { join } from 'node:path'; +import { mkdirSync } from 'node:fs'; +import { DATA_PATH } from '@@/data-path'; + +/** + * Where the session's state lives, and why none of it is in the plugin's own directory. + * + * A plugin directory is a git checkout that `plugins:update` will `git pull` over and `plugins:delete` will + * remove. The wine prefix is ~600 MB of generated state that takes minutes to rebuild — putting it there + * would mean an update fights the working tree and a reinstall costs a fresh download of the VB6 runtime. + * + * So it goes under `DATA_PATH/minesweeper/`, the same convention music's cache uses. The game executable + * DOES ship in the repository: it is 68 KB, it is the thing being run rather than state produced by running + * it, and its checksum is in the README. + */ +export const STATE_DIR = join(DATA_PATH, 'minesweeper'); + +/** The wine prefix — a `win32` one, because the game is an i386 PE. Generated; never committed. */ +export const WINEPREFIX = join(STATE_DIR, 'prefix'); + +/** + * Runtime scratch: pid files, logs, and the VNC socket. + * + * Created mode 0700 and that is load-bearing rather than tidy — it is the ONLY thing standing between + * another local account and an unauthenticated VNC display. x11vnc runs with `-nopw`, which is correct + * precisely because this directory refuses everyone but the owner. See `vnc.ts`. + */ +export const RUN_DIR = join(STATE_DIR, 'run'); + +/** x11vnc's listening socket. A path, not a port: unreachable without traversing RUN_DIR. */ +export const VNC_SOCKET = join(RUN_DIR, 'vnc.sock'); + +/** Ships with the plugin. `import.meta.dir` is `/sidecar`. */ +export const GAME_EXE = join(import.meta.dir, '..', 'game', 'Minesweeper X.exe'); + +export function ensureDirs(): void { + mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 }); + mkdirSync(RUN_DIR, { recursive: true, mode: 0o700 }); +} diff --git a/sidecar/prefix.ts b/sidecar/prefix.ts new file mode 100644 index 0000000..fd0b3a1 --- /dev/null +++ b/sidecar/prefix.ts @@ -0,0 +1,147 @@ +import { existsSync, lstatSync, mkdirSync, readdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { WINEPREFIX } from './paths'; +import { run } from './run'; + +/** + * Building the wine prefix, once, on first start. + * + * ── Why this is code and not an install step ── + * + * The plugin system has no install hook, and it should not grow one for this: an install that takes four + * minutes and downloads from Microsoft is not something to do behind a progress bar the owner cannot + * cancel. Doing it inside the first `POST /start` means it happens when somebody actually wants to play, + * it can report what it is doing, and a machine that installs the plugin and never opens it pays nothing. + * + * It is also self-healing. Deleting the prefix — which is the standard fix for a wedged wine — is safe, + * because the next start rebuilds it. + * + * ── The two facts that decide the whole setup ── + * + * Minesweeper X is a 68 KB i386 PE (machine `0x014c`, verified), so the prefix must be `win32`. A 64-bit + * prefix cannot load it and the failure is a bare `Bad EXE format`. + * + * It was built with Visual Basic 6, so it needs `MSVBVM60.DLL`, which wine does not ship. Without it the + * process exits immediately having printed one line — `err:module:import_dll Library MSVBVM60.DLL not + * found` — and nothing else. That is a quiet enough failure that it is worth checking for the DLL directly + * rather than inferring readiness from the prefix existing. + */ + +/** The one file whose presence means this prefix can actually run the game. */ +const VB6_RUNTIME = join(WINEPREFIX, 'drive_c', 'windows', 'system32', 'msvbvm60.dll'); + +export const prefixReady = (): boolean => existsSync(VB6_RUNTIME); + +export const WINE_ENV = { + WINEPREFIX, + WINEARCH: 'win32', + // wine narrates every unimplemented call otherwise, and the game triggers thousands. + WINEDEBUG: '-all', + // Never prompt to install .NET or an embedded IE. The dialog appears on a display nobody is watching, + // and the start then hangs until it times out. + WINEDLLOVERRIDES: 'mscoree,mshtml=', +}; + +export type PrefixResult = { ok: boolean; detail: string }; + +/** + * Create the prefix if it is not there, then install the VB6 runtime into it. + * + * `winetricks -q vb6run` fetches Microsoft's own `VB6.0-KB290887-X86.exe` and verifies its checksum before + * unpacking, which is why this does not carry a redistributable of its own. + */ +export async function ensurePrefix(log: (line: string) => void): Promise { + // Before the early return as well: an existing prefix may predate this, or have had `z:` restored by a + // wine upgrade. It is a few `unlink` calls, so it is cheaper to re-assert than to detect. + if (prefixReady()) { + confinePrefix(); + return { ok: true, detail: 'prefix ready' }; + } + + if (!Bun.which('wine')) { + return { ok: false, detail: 'wine is not installed. On Debian or Ubuntu this also needs i386 multiarch: `sudo dpkg --add-architecture i386 && sudo apt update && sudo apt install wine wine32:i386`' }; + } + if (!Bun.which('winetricks')) { + return { ok: false, detail: 'winetricks is not installed, and it is what fetches the Visual Basic 6 runtime the game needs' }; + } + + if (!existsSync(WINEPREFIX)) { + log('creating the 32-bit wine prefix (first run only, this takes a minute)'); + // `wineboot -i` rather than letting the first wine call create it implicitly: implicit creation happens + // inside the game launch, races the window-detection poll, and looks like the game failing to start. + const boot = await run(['wineboot', '-i'], WINE_ENV, 300_000); + if (!boot.ok && !existsSync(WINEPREFIX)) { + return { ok: false, detail: `wineboot failed: ${(boot.stderr || boot.stdout).trim().slice(-400)}` }; + } + } + + log('installing the Visual Basic 6 runtime into the prefix (downloads from Microsoft)'); + const tricks = await run(['winetricks', '-q', 'vb6run'], WINE_ENV, 600_000); + + // winetricks exits 0 having done nothing often enough that the exit code is not the answer. The file is. + if (!prefixReady()) { + return { + ok: false, + detail: `the VB6 runtime did not install; msvbvm60.dll is still missing. winetricks said: ${(tricks.stderr || tricks.stdout).trim().slice(-400)}`, + }; + } + + // Last, not first: `winetricks` runs wine, and wine recreates `z:` on the way past. + confinePrefix(); + + log('prefix ready and confined to drive C'); + return { ok: true, detail: 'prefix built' }; +} + +/** + * Cut the prefix off from the rest of the machine. + * + * ── Why this exists, and why it is not optional ── + * + * A fresh wine prefix is not a sandbox. `wineboot` creates `dosdevices/z: -> /`, which maps the ENTIRE + * filesystem to a drive letter, and symlinks the profile's Documents, Downloads and Music straight into the + * owner's real home. Minesweeper X has a replay feature with Open and Save dialogs, so anyone looking at the + * display gets a file browser over all of that, running as the server owner. + * + * That is precisely why the platform's permission registry classified Minesweeper as `execution` — a kind + * that can never be granted to anyone. But **a plugin permission is always grantable**, by design and with + * no field to opt out of. So extracting this feature without also fixing the prefix would have converted a + * structurally ungrantable capability into one an owner could hand to a Member, complete with a file picker + * over the owner's home. The extraction is what made this urgent; the hole was always there. + * + * `docs/affordances.md` has the rule that applies: if it can be fixed instead of documented, fix it. A note + * saying "do not grant this" outlives everyone's memory of why. + * + * ── What is left ── + * + * `C:` alone, pointing at the prefix. The game keeps its own directory, its settings and its replays; it can + * see nothing else. Serial and parallel device nodes go too — a 1997 puzzle game has no business holding an + * open descriptor on `/dev/ttyS0`. + * + * Re-asserted on every start rather than only at creation, because wine recreates `z:` whenever it decides + * the prefix needs updating — a wine package upgrade is enough, and it would restore the hole silently. + */ +export function confinePrefix(): void { + const dosdevices = join(WINEPREFIX, 'dosdevices'); + if (existsSync(dosdevices)) { + for (const entry of readdirSync(dosdevices)) { + // Keep `c:` and only `c:`. Everything else is a door out of the prefix or a device node. + if (entry.toLowerCase() === 'c:') continue; + rmSync(join(dosdevices, entry), { force: true, recursive: true }); + } + } + + const users = join(WINEPREFIX, 'drive_c', 'users'); + if (!existsSync(users)) return; + for (const user of readdirSync(users)) { + const profile = join(users, user); + for (const folder of readdirSync(profile)) { + const path = join(profile, folder); + // A symlink here points into the real home. Replace it with an ordinary directory so the game still + // has somewhere to save a replay, and that somewhere is inside the prefix. + if (!lstatSync(path).isSymbolicLink()) continue; + rmSync(path, { force: true }); + mkdirSync(path, { recursive: true }); + } + } +} diff --git a/sidecar/run.ts b/sidecar/run.ts new file mode 100644 index 0000000..4a549df --- /dev/null +++ b/sidecar/run.ts @@ -0,0 +1,39 @@ +/** + * Running a command and waiting for a condition — the two things every step below needs. + * + * The waiting matters more than it looks. Every readiness check here polls for an OBSERVABLE FACT rather + * than trusting a live pid, because each of these processes can be alive and not yet usable: Xvfb forks + * before its socket answers, wine returns while the window is still being created, and x11vnc exits after + * printing an error if the display went away. A pid check passes for all three and the next step then fails + * in a way that reads as the game being broken. + */ + +export type RunResult = { ok: boolean; stdout: string; stderr: string; code: number }; + +export async function run(cmd: string[], env: Record = {}, timeoutMs = 30_000): Promise { + const proc = Bun.spawn(cmd, { + env: { ...process.env, ...env }, + stdout: 'pipe', + stderr: 'pipe', + stdin: 'ignore', + }); + + const killer = setTimeout(() => proc.kill('SIGKILL'), timeoutMs); + try { + const [stdout, stderr] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]); + const code = await proc.exited; + return { ok: code === 0, stdout, stderr, code }; + } finally { + clearTimeout(killer); + } +} + +/** Poll `check` until it is true. Resolves false on timeout rather than throwing — every caller reports. */ +export async function waitFor(check: () => boolean | Promise, timeoutMs: number, everyMs = 250) { + const deadline = Date.now() + timeoutMs; + for (;;) { + if (await check()) return true; + if (Date.now() >= deadline) return false; + await Bun.sleep(everyMs); + } +} diff --git a/sidecar/session.ts b/sidecar/session.ts new file mode 100644 index 0000000..65945c9 --- /dev/null +++ b/sidecar/session.ts @@ -0,0 +1,261 @@ +import type { Subprocess } from 'bun'; +import { existsSync, rmSync } from 'node:fs'; +import { GAME_EXE, RUN_DIR, VNC_SOCKET, ensureDirs } from './paths'; +import { WINE_ENV, ensurePrefix, prefixReady } from './prefix'; +import { run, waitFor } from './run'; + +/** + * The Minesweeper session: a virtual display, one Windows game on it, and x11vnc serving that display over + * a private unix socket. + * + * ── The sidecar owns these processes ── + * + * They are spawned as its children, so PM2's tree-kill reaches them and a `pm2 restart officer-minesweeper` + * genuinely restarts the session. The version this replaced had the PLATFORM spawn a detached shell script: + * the game outlived every platform restart, nothing supervised it, and a crashed run left wine processes + * holding the prefix so the next start hung rather than failed. That last one was diagnosed the hard way — + * four `winedevice` processes survived a stop that reported success. + * + * ── Stopping wine properly ── + * + * Killing the game is not enough: `wineserver` and `winedevice` outlive it and keep the prefix locked. The + * script this replaces reached for `pkill -f wineserver`, which would also kill every OTHER wine prefix on + * the machine — someone's actual application, killed by a game of Minesweeper. `wineserver -k` with + * `WINEPREFIX` set is the scoped equivalent and is what wine provides for exactly this. + */ + +type Proc = Subprocess<'ignore', 'pipe', 'pipe'>; + +export type WindowGeometry = { width: number; height: number; x: number; y: number }; +export type SessionStatus = { + /** The game is present and the host has what it needs to run it. */ + installed: boolean; + running: boolean; + /** Null until the game has drawn its window. */ + window: WindowGeometry | null; + /** Set when the last start failed, so the panel can say why instead of showing an empty canvas. */ + error?: string; +}; + +const SCREEN_W = 800; +const SCREEN_H = 600; + +let xvfb: Proc | null = null; +let game: Proc | null = null; +let vnc: Proc | null = null; +let display: string | null = null; +let lastError: string | null = null; +let starting: Promise | null = null; + +const alive = (p: Proc | null): boolean => !!p && p.exitCode === null && p.signalCode === null; + +/** + * A display number nothing else is using. + * + * An X display IS a unix socket at `/tmp/.X11-unix/X`, so this is a real check rather than a guess. The + * version this replaces hardcoded `:7`, which silently attaches to whatever is already there — including + * the owner's own session if they happen to run one on that number. + */ +function pickDisplay(): string | null { + for (let n = 20; n < 100; n++) { + if (!existsSync(`/tmp/.X11-unix/X${n}`)) return `:${n}`; + } + return null; +} + +/** The game's window. wine also creates a 1x1 owner stub, so the larger of the two is the real one. */ +export async function windowGeometry(): Promise { + if (!display) return null; + const search = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY: display }, 5_000); + if (!search.ok) return null; + + let best: WindowGeometry | null = null; + for (const id of search.stdout.trim().split('\n').filter(Boolean)) { + const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY: display }, 5_000); + if (!geom.ok) continue; + const read = (key: string) => Number(new RegExp(`^${key}=(\\d+)$`, 'm').exec(geom.stdout)?.[1] ?? NaN); + const candidate = { width: read('WIDTH'), height: read('HEIGHT'), x: read('X'), y: read('Y') }; + if (Object.values(candidate).some(Number.isNaN)) continue; + if (!best || candidate.width * candidate.height > best.width * best.height) best = candidate; + } + return best && best.width > 1 ? best : null; +} + +/** + * Whether the session is up, decided by the socket file plus a live x11vnc. + * + * Deliberately NOT by connecting. An earlier version opened a connection and dropped it, which wedged the + * server: libvncserver peeks at every new connection to decide whether it is a WebSocket handshake, and + * that peek blocks its main loop until the client speaks or the peek times out. A health check that + * connects and says nothing is therefore a small stall — and the panel polls status on a timer, so the + * stalls overlapped, the accept backlog filled, and the real viewer stopped getting in. + * + * The rule that cost: a liveness check must not be indistinguishable from a client. + */ +export const isRunning = (): boolean => alive(vnc) && alive(xvfb) && existsSync(VNC_SOCKET); + +export async function status(): Promise { + const installed = existsSync(GAME_EXE) && !!Bun.which('wine') && !!Bun.which('Xvfb') && !!Bun.which('x11vnc'); + const running = isRunning(); + return { + installed, + running, + window: running ? await windowGeometry() : null, + ...(lastError && !running ? { error: lastError } : {}), + }; +} + +const spawn = (cmd: string[], env: Record, logName: string): Proc => { + const proc = Bun.spawn(cmd, { env: { ...process.env, ...env }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }); + // Wine and x11vnc both say useful things only when something is wrong. Prefixed so `pm2 logs + // officer-minesweeper` shows which process is complaining. + for (const stream of [proc.stdout, proc.stderr]) { + void (async () => { + for await (const chunk of stream as ReadableStream) { + const text = new TextDecoder().decode(chunk).trimEnd(); + if (text) console.log(`[minesweeper:${logName}] ${text}`); + } + })().catch(() => { + /* stream closed with the process */ + }); + } + return proc as Proc; +}; + +async function startInner(log: (line: string) => void): Promise { + ensureDirs(); + lastError = null; + + if (!existsSync(GAME_EXE)) { + lastError = 'the game executable is missing from the plugin directory'; + return status(); + } + + const built = await ensurePrefix(log); + if (!built.ok) { + lastError = built.detail; + return status(); + } + + // ── the display ── + if (!alive(xvfb)) { + display = pickDisplay(); + if (!display) { + lastError = 'no free X display number between :20 and :99'; + return status(); + } + log(`Xvfb on ${display} at ${SCREEN_W}x${SCREEN_H}`); + // `-nolisten tcp` — the display is reached through its unix socket and has no business on a port. + xvfb = spawn(['Xvfb', display, '-screen', '0', `${SCREEN_W}x${SCREEN_H}x24`, '-nolisten', 'tcp'], {}, 'xvfb'); + + const up = await waitFor(async () => (await run(['xdpyinfo', '-display', display!], {}, 5_000)).ok, 15_000); + if (!up) { + lastError = 'the virtual display never became usable'; + await stop(); + return status(); + } + } + + const env = { ...WINE_ENV, DISPLAY: display! }; + + // ── the game ── + if (!alive(game)) { + log('launching Minesweeper X under wine'); + game = spawn(['wine', GAME_EXE], env, 'wine'); + + const drew = await waitFor(async () => !!(await windowGeometry()), 30_000); + if (!drew) { + lastError = 'the game started but never drew a window'; + await stop(); + return status(); + } + + // Park the window at the framebuffer origin. Everything downstream then crops by using a canvas + // smaller than the framebuffer, with no offset arithmetic and nothing to keep in sync when the board + // resizes with the difficulty. + const found = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY: display! }, 5_000); + for (const id of found.stdout.trim().split('\n').filter(Boolean)) { + await run(['xdotool', 'windowmove', id, '0', '0'], { DISPLAY: display! }, 5_000); + } + } + + // ── the display server ── + if (!alive(vnc)) { + // A socket left by a crashed run refuses to bind. Removing it is safe: nothing else may write here. + rmSync(VNC_SOCKET, { force: true }); + log('x11vnc on a private unix socket'); + /* + `-nopw` with `-unixsock`, and the pair is the whole security argument. + + There is no listening port. The only way in is opening a socket file inside RUN_DIR, which is mode + 0700 and owned by whoever runs this sidecar — so the kernel refuses every other account before + x11vnc is asked anything. A password would be a second lock on a door only one person can reach, and + one more secret to store, rotate and leak. + + The version this replaces bound `127.0.0.1:5907` with `-nopw`, which is a different claim entirely: + every local account could open it and take the display. Survivable on a single-user machine, and not + once members have Linux accounts. A fixed port is squattable too — anything can bind 5907 first and + then be relayed to, by the platform, on this plugin's behalf. + */ + vnc = spawn( + // -forever: survive a viewer disconnecting. -shared: more than one viewer. -noxdamage: the damage + // extension reports nothing useful on Xvfb and costs a poll that misses updates. + ['x11vnc', '-display', display!, '-unixsock', VNC_SOCKET, '-forever', '-shared', '-nopw', '-noxdamage', '-wait', '5', '-defer', '5'], + { DISPLAY: display! }, + 'x11vnc', + ); + + const listening = await waitFor(() => existsSync(VNC_SOCKET), 15_000); + if (!listening) { + lastError = 'x11vnc never created its socket'; + await stop(); + return status(); + } + } + + log('ready'); + return status(); +} + +/** Idempotent, and safe to call concurrently — two panels opening at once must not race two sessions up. */ +export function start(log: (line: string) => void): Promise { + if (isRunning()) return status(); + if (starting) return starting; + starting = startInner(log).finally(() => { + starting = null; + }); + return starting; +} + +async function reap(proc: Proc | null, name: string): Promise { + if (!alive(proc)) return; + proc!.kill('SIGTERM'); + const gone = await waitFor(() => !alive(proc), 3_000, 100); + if (!gone) { + console.log(`[minesweeper] ${name} ignored SIGTERM; killing`); + proc!.kill('SIGKILL'); + } +} + +export async function stop(): Promise { + await reap(vnc, 'x11vnc'); + vnc = null; + rmSync(VNC_SOCKET, { force: true }); + + await reap(game, 'wine'); + game = null; + + // Scoped to THIS prefix. `wineserver` and `winedevice` are not our children and outlive the game, holding + // the prefix so the next start hangs; `-k` is wine's own way to end them, and unlike `pkill -f wineserver` + // it cannot touch another prefix somebody else is using. + if (Bun.which('wineserver')) await run(['wineserver', '-k'], WINE_ENV, 10_000).catch(() => undefined); + + await reap(xvfb, 'Xvfb'); + xvfb = null; + display = null; + + return status(); +} + +export const runDir = RUN_DIR; +export const prefixIsReady = prefixReady; diff --git a/web/MinesweeperPanel.tsx b/web/MinesweeperPanel.tsx new file mode 100644 index 0000000..16b533b --- /dev/null +++ b/web/MinesweeperPanel.tsx @@ -0,0 +1,260 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { Bomb, Loader2, Play, RefreshCw } from 'lucide-react'; +import { useClient } from 'hooks/useClient'; +import type { RfbError } from './rfb'; +import { RfbClient } from './rfb'; + +type SessionStatus = { + installed: boolean; + running: boolean; + window: { width: number; height: number; x: number; y: number } | null; +}; + +// The framebuffer is bigger than the board — the board sits at 0,0 and the rest is empty desktop. +// Rendering into an offscreen canvas the size of the framebuffer and showing only the board's corner +// of it is what makes this look like a window rather than a small game adrift in grey. +const VISIBLE = { width: 164, height: 226 }; + +const BUTTON_MASK: Record = { 0: 1, 1: 2, 2: 4 }; + +// Plugin panels have no localisation mechanism yet — `officerdev`'s `useTranslation`/`panelCatalogs` +// (merged by `I18nBridge`) is platform-internal and not exported from the barrel, and +// `scripts/i18n/scan-hardcoded.ts` explicitly does not scan `plugins/`. So these stay plain English +// sentences rather than catalog keys, same as every other plugin's user-facing strings. +const describeRfbError = (error: RfbError): string => { + switch (error.code) { + case 'connectionFailed': + return 'The connection to the Minesweeper session failed.'; + case 'refused': + return 'The VNC server refused the connection.'; + case 'authUnsupported': + return 'The VNC server wants authentication, which this client does not implement.'; + case 'handshakeRejected': + return 'The VNC server rejected the handshake.'; + case 'unknownMessage': + return `The VNC server sent an unknown message type (${error.type}).`; + case 'unknownEncoding': + return `The VNC server used encoding ${error.encoding}, which this client did not ask for.`; + } +}; + +export const MinesweeperPanel = () => { + // useClient() rebuilds its verbs on EVERY render, so putting one in a dependency list gives that + // list a new identity every render. Held in a ref instead — the same fix the Jellyfin player needed + // after its playback reports silently stopped for days. Getting this wrong here meant `refresh` was + // unstable, its mount effect re-ran every render, and each run set state and caused another render: + // an endless fetch loop that starved the socket and left the panel saying "connecting…" forever. + const client = useClient(); + const clientRef = useRef(client); + clientRef.current = client; + const canvasRef = useRef(null); + const offscreenRef = useRef(null); + const rfbRef = useRef(null); + const buttonsRef = useRef(0); + const geometryRef = useRef(VISIBLE); + + const [status, setStatus] = useState(null); + const [error, setError] = useState(null); + const [connected, setConnected] = useState(false); + const [busy, setBusy] = useState(false); + + const adopt = useCallback((next: SessionStatus) => { + // The board is 152x214 on beginner and wider on the other difficulties. Taking the size from + // status means the canvas is right on the first paint instead of after the first poll. + if (next.window) geometryRef.current = { width: next.window.width, height: next.window.height }; + setStatus(next); + }, []); + + const refresh = useCallback(async () => { + try { + adopt(await clientRef.current.get('/minesweeper/status')); + } catch { + setError('Could not reach the server.'); + } + }, [adopt]); + + useEffect(() => { + void refresh(); + }, [refresh]); + + const start = async () => { + setBusy(true); + setError(null); + try { + adopt(await clientRef.current.post('/minesweeper/start', {})); + } catch { + setError('The session would not start. Is wine-minesweeper installed on the server?'); + } finally { + setBusy(false); + } + }; + + // Paint the visible canvas from the offscreen one. Only the board's rectangle is copied, so the + // surrounding desktop never reaches the screen. + const present = useCallback(() => { + const canvas = canvasRef.current; + const offscreen = offscreenRef.current; + if (!canvas || !offscreen) return; + const context = canvas.getContext('2d'); + if (!context) return; + const { width, height } = geometryRef.current; + if (canvas.width !== width || canvas.height !== height) { + canvas.width = width; + canvas.height = height; + } + context.drawImage(offscreen, 0, 0, width, height, 0, 0, width, height); + }, []); + + useEffect(() => { + if (!status?.running) return; + + const scheme = window.location.protocol === 'https:' ? 'wss' : 'ws'; + // 'BEARER_TOKEN' is the key the rest of the app uses (Terminal.tsx, useAudioRecording). Reading + // 'token' here sent an empty string, and upgradeWs answered 401 before any RFB byte was exchanged. + const token = localStorage.getItem('BEARER_TOKEN') ?? sessionStorage.getItem('BEARER_TOKEN') ?? ''; + // Plugin relay, not a platform websocket provider: `/api/plugin-ws/minesweeper` rather than the old + // `/api/minesweeper/ws`. Same byte relay onto x11vnc, mounted by the plugin instead of the shell. + const url = `${scheme}://${window.location.host}/api/plugin-ws/minesweeper?token=${encodeURIComponent(token)}`; + + const rfb = new RfbClient(url, { + onReady: (width, height) => { + const offscreen = document.createElement('canvas'); + offscreen.width = width; + offscreen.height = height; + offscreenRef.current = offscreen; + setConnected(true); + setError(null); + }, + onFrame: (image, x, y) => { + offscreenRef.current?.getContext('2d')?.putImageData(image, x, y); + present(); + }, + onCopyRect: (from, to) => { + const offscreen = offscreenRef.current; + const context = offscreen?.getContext('2d'); + if (!offscreen || !context) return; + context.drawImage(offscreen, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h); + present(); + }, + onResize: (width, height) => { + const offscreen = offscreenRef.current; + if (offscreen) { + offscreen.width = width; + offscreen.height = height; + } + rfb.requestUpdate(false); + }, + onError: (rfbError) => setError(describeRfbError(rfbError)), + onClose: () => setConnected(false), + }); + + rfbRef.current = rfb; + return () => { + rfb.close(); + rfbRef.current = null; + setConnected(false); + }; + }, [status?.running, present]); + + // The board resizes when the difficulty changes, and nothing in RFB announces that — the framebuffer + // stays the same size. Polling the window geometry is what keeps the visible canvas matched to it. + useEffect(() => { + if (!connected) return; + const timer = setInterval(async () => { + try { + const next = await clientRef.current.get('/minesweeper/status'); + if (!next.window) return; + const { width, height } = next.window; + if (width !== geometryRef.current.width || height !== geometryRef.current.height) { + geometryRef.current = { width, height }; + present(); + } + } catch { + /* a failed poll is not worth surfacing; the next one will do */ + } + }, 4000); + return () => clearInterval(timer); + }, [connected, present]); + + const toFramebuffer = (ev: React.MouseEvent) => { + const rect = ev.currentTarget.getBoundingClientRect(); + const scaleX = ev.currentTarget.width / rect.width; + const scaleY = ev.currentTarget.height / rect.height; + return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY }; + }; + + const onPointer = (ev: React.MouseEvent, kind: 'down' | 'up' | 'move') => { + const rfb = rfbRef.current; + if (!rfb) return; + ev.preventDefault(); + + if (kind !== 'move') { + const bit = BUTTON_MASK[ev.button] ?? 0; + buttonsRef.current = kind === 'down' ? buttonsRef.current | bit : buttonsRef.current & ~bit; + } + const { x, y } = toFramebuffer(ev); + rfb.pointer(x, y, buttonsRef.current); + }; + + if (status && !status.installed) { + return ( + + +

Minesweeper is not installed on this server.

+ {/* The package name sits at the end so the sentence is one key rather than two around an + inline element — see docs/localization.md on the fragmentation that produces. */} +

+ It needs wine, a 32-bit prefix, and this package beside the platform: wine-minesweeper +

+
+ ); + } + + if (status && !status.running) { + return ( + + +

The Minesweeper session is not running.

+ {error ?

{error}

: null} + +
+ ); + } + + return ( +
+ onPointer(ev, 'down')} + onMouseUp={(ev) => onPointer(ev, 'up')} + onMouseMove={(ev) => onPointer(ev, 'move')} + onContextMenu={(ev) => ev.preventDefault()} + // Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a + // photograph of a screen rather than the screen. + className="rounded-sm shadow-lg [image-rendering:pixelated]" + style={{ cursor: connected ? 'default' : 'progress' }} + /> +
+ {connected ? 'connected' : 'connecting…'} + +
+ {error ?

{error}

: null} +
+ ); +}; + +const Centred = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); diff --git a/web/layout.ts b/web/layout.ts new file mode 100644 index 0000000..cc008d5 --- /dev/null +++ b/web/layout.ts @@ -0,0 +1,9 @@ +import type { LayoutNode } from 'officerdev'; + +// One panel. The board is 152x214 and there is nothing to put beside it — a split here would be a +// second panel of empty space. +export const defaultLayout: LayoutNode = { + type: 'panel', + id: 'minesweeper', + appType: 'minesweeper', +}; diff --git a/web/panels.ts b/web/panels.ts new file mode 100644 index 0000000..d4989cb --- /dev/null +++ b/web/panels.ts @@ -0,0 +1,10 @@ +// `panels.ts`, not `.tsx` — discovery checks for that exact name. +import { Bomb } from 'lucide-react'; +import type { AppRegistryMeta } from 'officerdev'; +import { MinesweeperPanel } from './MinesweeperPanel'; + +// Minesweeper X on a virtual display, streamed over RFB. The client is ./rfb.ts — a few hundred lines +// rather than a vendored noVNC, because we control the server and need two encodings out of ten. +export const appRegistryMetas: AppRegistryMeta[] = [ + { key: 'minesweeper', name: 'Minesweeper', icon: Bomb, component: MinesweeperPanel, availableOnPanel: false }, +]; diff --git a/web/rfb.ts b/web/rfb.ts new file mode 100644 index 0000000..96ef93d --- /dev/null +++ b/web/rfb.ts @@ -0,0 +1,341 @@ +/** + * A minimal RFB 3.8 client — enough to watch a framebuffer and click on it, and no more. + * + * ── Why this rather than noVNC ── + * + * noVNC is the obvious choice and it is a good library. It is also ~200 KB of vendored JavaScript for a + * job this file does in a few hundred lines, and vendoring is this repository's reluctant exception + * (hls.js, committed unminified with provenance hashes) rather than its habit. What we need is one + * server we control, one pixel format we choose, and two encodings. The rest of RFB — the eight other + * encodings, the compression, the authentication schemes, the clipboard, the audio — is surface we would + * carry and never execute. + * + * So: Raw and CopyRect, one pixel format, security type None. If this ever has to face a VNC server we + * do not configure, that is the moment to reach for noVNC instead of growing this. + * + * ── The pixel format ── + * + * The client DICTATES the format via SetPixelFormat rather than accepting the server's, so decoding is + * a memcpy into ImageData instead of a per-pixel shift-and-mask. We ask for 32bpp true colour with + * big-endian byte order, which puts the bytes on the wire as R,G,B,X — exactly the order a canvas + * ImageData buffer wants, so a Raw rectangle copies straight in. + */ + +export type RfbEvents = { + onReady: (width: number, height: number) => void; + onFrame: (image: ImageData, x: number, y: number) => void; + onCopyRect: (from: { x: number; y: number }, to: { x: number; y: number; w: number; h: number }) => void; + onResize: (width: number, height: number) => void; + onError: (error: RfbError) => void; + onClose: () => void; +}; + +/** + * What went wrong, as a code rather than a sentence. + * + * A protocol client has no business holding user-facing prose: it cannot reach a React hook, and the + * words belong to whoever is rendering them. The panel maps each code to a catalog key — an explicit + * map from a closed union to literal keys, which is what the naming convention asks for instead of + * building a key at runtime. + */ +export type RfbError = + | { code: 'connectionFailed' } + | { code: 'refused' } + | { code: 'authUnsupported' } + | { code: 'handshakeRejected' } + | { code: 'unknownMessage'; type: number } + | { code: 'unknownEncoding'; encoding: number }; + +const ENCODING_RAW = 0; +const ENCODING_COPY_RECT = 1; +const PSEUDO_ENCODING_DESKTOP_SIZE = -223; + +type Phase = 'version' | 'security' | 'security-result' | 'init' | 'running'; + +export class RfbClient { + private socket: WebSocket; + private buffer = new Uint8Array(0); + private phase: Phase = 'version'; + private width = 0; + private height = 0; + private events: RfbEvents; + private closed = false; + + constructor(url: string, events: RfbEvents) { + this.events = events; + this.socket = new WebSocket(url); + this.socket.binaryType = 'arraybuffer'; + this.socket.onmessage = (ev) => this.receive(new Uint8Array(ev.data as ArrayBuffer)); + this.socket.onclose = () => { + this.closed = true; + this.events.onClose(); + }; + this.socket.onerror = () => this.events.onError({ code: 'connectionFailed' }); + } + + close(): void { + this.closed = true; + try { + this.socket.close(); + } catch { + /* already closed */ + } + } + + // Uint8Array, not plain Uint8Array: the default is Uint8Array, whose + // buffer may be a SharedArrayBuffer, and WebSocket.send takes only BufferSource. Every caller here + // builds its own array, so narrowing costs nothing. The terminal relay narrows the same way. + private send(bytes: Uint8Array): void { + if (this.socket.readyState === WebSocket.OPEN) this.socket.send(bytes); + } + + private receive(chunk: Uint8Array): void { + const merged = new Uint8Array(this.buffer.length + chunk.length); + merged.set(this.buffer); + merged.set(chunk, this.buffer.length); + this.buffer = merged; + + // Each step consumes a whole message or nothing at all, so a message split across TCP chunks simply + // waits for the rest. Returning false means "not enough bytes yet". + while (!this.closed && this.step()) { + /* keep draining */ + } + } + + private take(n: number): Uint8Array | null { + if (this.buffer.length < n) return null; + const head = this.buffer.subarray(0, n); + this.buffer = this.buffer.subarray(n); + return head; + } + + private step(): boolean { + switch (this.phase) { + case 'version': + return this.stepVersion(); + case 'security': + return this.stepSecurity(); + case 'security-result': + return this.stepSecurityResult(); + case 'init': + return this.stepInit(); + case 'running': + return this.stepMessage(); + } + } + + private stepVersion(): boolean { + const head = this.take(12); + if (!head) return false; + // Answer 3.8 regardless of what the server offered; x11vnc speaks it and anything older is a server + // we are not configuring. + this.send(new TextEncoder().encode('RFB 003.008\n')); + this.phase = 'security'; + return true; + } + + private stepSecurity(): boolean { + if (this.buffer.length < 1) return false; + const count = this.buffer[0] as number; + if (count === 0) { + // A zero count means the server is refusing us, and the reason follows as a string. + const body = this.take(1 + 4); + if (!body) return false; + this.events.onError({ code: 'refused' }); + this.close(); + return false; + } + const head = this.take(1 + count); + if (!head) return false; + + const types = Array.from(head.subarray(1)); + if (!types.includes(1)) { + this.events.onError({ code: 'authUnsupported' }); + this.close(); + return false; + } + this.send(new Uint8Array([1])); // None — the relay is loopback-only and Officer is the authenticator + this.phase = 'security-result'; + return true; + } + + private stepSecurityResult(): boolean { + const head = this.take(4); + if (!head) return false; + if (new DataView(head.buffer, head.byteOffset, 4).getUint32(0) !== 0) { + this.events.onError({ code: 'handshakeRejected' }); + this.close(); + return false; + } + this.send(new Uint8Array([1])); // shared: do not disconnect other viewers + this.phase = 'init'; + return true; + } + + private stepInit(): boolean { + if (this.buffer.length < 24) return false; + const nameLength = new DataView(this.buffer.buffer, this.buffer.byteOffset + 20, 4).getUint32(0); + const head = this.take(24 + nameLength); + if (!head) return false; + + const view = new DataView(head.buffer, head.byteOffset, head.byteLength); + this.width = view.getUint16(0); + this.height = view.getUint16(2); + + this.setPixelFormat(); + this.setEncodings(); + this.phase = 'running'; + this.events.onReady(this.width, this.height); + this.requestUpdate(false); + return true; + } + + /** 32bpp true colour, big-endian, shifts 16/8/0 — i.e. R,G,B,X on the wire, which is ImageData order. */ + private setPixelFormat(): void { + const message = new Uint8Array(20); + const view = new DataView(message.buffer); + message[0] = 0; // SetPixelFormat + message[4] = 32; // bits per pixel + message[5] = 24; // depth + message[6] = 1; // big-endian + message[7] = 1; // true colour + view.setUint16(8, 255); // red max + view.setUint16(10, 255); // green max + view.setUint16(12, 255); // blue max + message[14] = 16; // red shift + message[15] = 8; // green shift + message[16] = 0; // blue shift + this.send(message); + } + + private setEncodings(): void { + const encodings = [ENCODING_COPY_RECT, ENCODING_RAW, PSEUDO_ENCODING_DESKTOP_SIZE]; + const message = new Uint8Array(4 + encodings.length * 4); + const view = new DataView(message.buffer); + message[0] = 2; // SetEncodings + view.setUint16(2, encodings.length); + encodings.forEach((encoding, i) => view.setInt32(4 + i * 4, encoding)); + this.send(message); + } + + requestUpdate(incremental = true): void { + const message = new Uint8Array(10); + const view = new DataView(message.buffer); + message[0] = 3; // FramebufferUpdateRequest + message[1] = incremental ? 1 : 0; + view.setUint16(6, this.width); + view.setUint16(8, this.height); + this.send(message); + } + + private stepMessage(): boolean { + if (this.buffer.length < 1) return false; + const type = this.buffer[0] as number; + + // 0 FramebufferUpdate, 1 SetColourMapEntries, 2 Bell, 3 ServerCutText. Only the first matters here; + // the others still have to be CONSUMED or the stream desynchronises and every later frame is garbage. + if (type === 0) return this.stepFramebufferUpdate(); + if (type === 2) return this.take(1) !== null; + if (type === 1) { + if (this.buffer.length < 6) return false; + const count = new DataView(this.buffer.buffer, this.buffer.byteOffset + 4, 2).getUint16(0); + return this.take(6 + count * 6) !== null; + } + if (type === 3) { + if (this.buffer.length < 8) return false; + const length = new DataView(this.buffer.buffer, this.buffer.byteOffset + 4, 4).getUint32(0); + return this.take(8 + length) !== null; + } + + this.events.onError({ code: 'unknownMessage', type }); + this.close(); + return false; + } + + /** + * Parsed without consuming anything until the WHOLE update is present. A partial rectangle cannot be + * drawn and cannot be put back, so the alternative is a second parser for resumed state. + */ + private stepFramebufferUpdate(): boolean { + if (this.buffer.length < 4) return false; + const rectangles = new DataView(this.buffer.buffer, this.buffer.byteOffset + 2, 2).getUint16(0); + + let offset = 4; + type Rect = { x: number; y: number; w: number; h: number; encoding: number; at: number }; + const rects: Rect[] = []; + + for (let i = 0; i < rectangles; i++) { + if (this.buffer.length < offset + 12) return false; + const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 12); + const rect = { + x: view.getUint16(0), + y: view.getUint16(2), + w: view.getUint16(4), + h: view.getUint16(6), + encoding: view.getInt32(8), + at: offset + 12, + }; + offset += 12; + + if (rect.encoding === ENCODING_RAW) offset += rect.w * rect.h * 4; + else if (rect.encoding === ENCODING_COPY_RECT) offset += 4; + else if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) { + /* no payload — the rect's w/h ARE the new size */ + } else { + this.events.onError({ code: 'unknownEncoding', encoding: rect.encoding }); + this.close(); + return false; + } + + if (this.buffer.length < offset) return false; + rects.push(rect); + } + + for (const rect of rects) { + if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) { + this.width = rect.w; + this.height = rect.h; + this.events.onResize(rect.w, rect.h); + continue; + } + if (rect.encoding === ENCODING_COPY_RECT) { + const src = new DataView(this.buffer.buffer, this.buffer.byteOffset + rect.at, 4); + this.events.onCopyRect({ x: src.getUint16(0), y: src.getUint16(2) }, rect); + continue; + } + // Raw: the wire bytes are already R,G,B,X, so this is a copy rather than a conversion. The alpha + // byte is whatever the server sent, so force it opaque. + const pixels = new Uint8ClampedArray(this.buffer.subarray(rect.at, rect.at + rect.w * rect.h * 4)); + for (let i = 3; i < pixels.length; i += 4) pixels[i] = 255; + this.events.onFrame(new ImageData(pixels, rect.w, rect.h), rect.x, rect.y); + } + + this.take(offset); + + // One request per update message, issued once the whole message is drawn. Requesting per + // rectangle would send several for one update, and requesting on a timer would queue updates the + // client has not consumed yet — the classic way a VNC view ends up lagging behind the pointer. + this.requestUpdate(true); + return true; + } + + /** buttons is an RFB button mask: bit 0 left, bit 1 middle, bit 2 right. */ + pointer(x: number, y: number, buttons: number): void { + const message = new Uint8Array(6); + const view = new DataView(message.buffer); + message[0] = 5; // PointerEvent + message[1] = buttons; + view.setUint16(2, Math.max(0, Math.min(this.width - 1, Math.round(x)))); + view.setUint16(4, Math.max(0, Math.min(this.height - 1, Math.round(y)))); + this.send(message); + } + + key(keysym: number, down: boolean): void { + const message = new Uint8Array(8); + const view = new DataView(message.buffer); + message[0] = 4; // KeyEvent + message[1] = down ? 1 : 0; + view.setUint32(4, keysym); + this.send(message); + } +}