commit f3cc1242c4fb9a1196166a5fe613e921c8efc2ed Author: André Padez Date: Sun Aug 23 17:43:13 2026 +0000 minesweeper: extracted from the platform 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 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 0000000..7220fdb Binary files /dev/null and b/game/Minesweeper X.exe differ diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..0f5bf8f --- /dev/null +++ b/manifest.ts @@ -0,0 +1,125 @@ +import type { PluginManifest } from '@@/plugins/manifest'; + +/** + * Minesweeper X — a 1990s Windows game, running under wine on a virtual display, streamed to the browser. + * + * ── Why this is worth having at all ── + * + * It is a game, and it is also the smallest honest proof that Officer can put an arbitrary X11 program in a + * panel. Everything the Desktop plugin wants to do is here at one-tenth the size: a headless framebuffer, a + * process on it, a VNC server reading it, and a client in the browser. If this works, "run a Linux app in + * Officer" is a solved shape rather than a plan. + * + * ── It was in the platform until this extraction ── + * + * `/api/minesweeper`, `Screens/Dashboard/Minesweeper`, an `officerdev` panel, a permission-registry entry + * and a hand-written websocket provider in `server.tsx`. The socket is why it had to live there: Bun's route + * table is fixed at `serve()`, so nothing installed later could add one. That ceiling is gone — the platform + * now serves one wildcard socket door for every plugin — and this plugin is the first thing through it. + * + * ── The session is the sidecar's, not the platform's ── + * + * `officer-minesweeper` owns the whole lifecycle: it starts Xvfb, launches the game under wine, runs x11vnc + * against that display, and tears all three down. The platform's router forwards three routes to it and + * knows nothing else. + * + * That is not tidiness. The platform used to spawn `bin/session.sh` detached, which meant the game outlived + * every restart, nothing supervised it, and a crashed run left wine processes holding the prefix so the next + * start hung rather than failed. PM2 supervises a sidecar; a sidecar can clean up after itself on SIGTERM. + * + * ── x11vnc listens on a unix socket, and has no password ── + * + * `-nopw` is correct here and the reasoning is not "it is only a game". x11vnc binds a socket inside the + * sidecar's private run directory, mode 700, and NOTHING else can reach it — not another local account, not + * a member with a Linux user, not a process that guessed a port. There is no network listener to + * authenticate against, so a password would protect nothing and would be one more secret to store. + * + * The version this replaced listened on `127.0.0.1:5907`, which is a different claim: every local account + * could open it and take the display. That was survivable on a single-user machine and stopped being so the + * moment members got Linux accounts. A fixed port is also squattable — anything can bind 5907 first and be + * relayed to by the platform. + * + * ── It is grantable, and that took a fix rather than a decision ── + * + * In the platform this was `kind: 'execution'` — never grantable to anyone, at any level — because the wine + * process runs as the server owner and Minesweeper X has Open and Save dialogs for its replays. A fresh wine + * prefix maps the whole filesystem to `Z:` and symlinks the profile into the owner's real home, so "granted + * Minesweeper" quietly meant "granted a file picker as the owner". + * + * A plugin permission has no `kind` and is always grantable, so extraction alone would have turned a + * structurally sealed capability into a hole. `sidecar/prefix.ts` closes it instead: the prefix is confined + * to `C:` on every start, and the game can see nothing outside it. That is what makes the entry below + * honest rather than optimistic. + * + * One thing that is NOT solved: there is one display and one game, so two people granted this share a board + * and fight over the mouse. Per-viewer sessions would be a display and a prefix each — cheap, but a design + * decision rather than an implementation detail. + * + * ── `osDependencies` ── + * + * Six binaries, all `owner` scope: the session runs as the server owner, on the owner's display, in the + * owner's wine prefix. `wine` is the awkward one — the game is a 32-bit VB6 executable, so the host needs + * i386 multiarch and a `win32` prefix, and neither is something a package name can express. The sidecar + * builds the prefix on first start and says what it needs if it cannot; see `sidecar/prefix.ts`. + */ +export const manifest: PluginManifest = { + publisher: 'officerdev', + version: '1.0.0', + platform: '>=1.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); + } +}