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 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
node_modules/
|
||||||
@@ -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
|
||||||
|
```
|
||||||
@@ -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/<publisher>/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;
|
||||||
Binary file not shown.
+125
@@ -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',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
@@ -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<ServerWebSocket<WsData>, Relay>();
|
||||||
|
|
||||||
|
const teardown = (ws: ServerWebSocket<WsData>, 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<WsData>) {
|
||||||
|
/*
|
||||||
|
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<WsData>, 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<WsData>) {
|
||||||
|
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'));
|
||||||
@@ -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 `<plugin>/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 });
|
||||||
|
}
|
||||||
@@ -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<PrefixResult> {
|
||||||
|
// 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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<string, string> = {}, timeoutMs = 30_000): Promise<RunResult> {
|
||||||
|
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<boolean>, 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SessionStatus> | 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<n>`, 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<WindowGeometry | null> {
|
||||||
|
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<SessionStatus> {
|
||||||
|
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<string, string>, 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<Uint8Array>) {
|
||||||
|
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<SessionStatus> {
|
||||||
|
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<SessionStatus> {
|
||||||
|
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<void> {
|
||||||
|
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<SessionStatus> {
|
||||||
|
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;
|
||||||
@@ -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<number, number> = { 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<HTMLCanvasElement | null>(null);
|
||||||
|
const offscreenRef = useRef<HTMLCanvasElement | null>(null);
|
||||||
|
const rfbRef = useRef<RfbClient | null>(null);
|
||||||
|
const buttonsRef = useRef(0);
|
||||||
|
const geometryRef = useRef(VISIBLE);
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<SessionStatus | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(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<SessionStatus>('/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<SessionStatus>('/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<SessionStatus>('/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<HTMLCanvasElement>) => {
|
||||||
|
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<HTMLCanvasElement>, 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 (
|
||||||
|
<Centred>
|
||||||
|
<Bomb className="size-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">Minesweeper is not installed on this server.</p>
|
||||||
|
{/* 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. */}
|
||||||
|
<p className="max-w-sm text-center text-xs text-muted-foreground">
|
||||||
|
It needs wine, a 32-bit prefix, and this package beside the platform: <code>wine-minesweeper</code>
|
||||||
|
</p>
|
||||||
|
</Centred>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status && !status.running) {
|
||||||
|
return (
|
||||||
|
<Centred>
|
||||||
|
<Bomb className="size-8 text-muted-foreground" />
|
||||||
|
<p className="text-sm text-muted-foreground">The Minesweeper session is not running.</p>
|
||||||
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={start}
|
||||||
|
disabled={busy}
|
||||||
|
className="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground disabled:opacity-60"
|
||||||
|
>
|
||||||
|
{busy ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
|
||||||
|
{busy ? 'Starting' : 'Start it'}
|
||||||
|
</button>
|
||||||
|
</Centred>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4">
|
||||||
|
<canvas
|
||||||
|
ref={canvasRef}
|
||||||
|
width={VISIBLE.width}
|
||||||
|
height={VISIBLE.height}
|
||||||
|
onMouseDown={(ev) => 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' }}
|
||||||
|
/>
|
||||||
|
<div className="flex items-center gap-3 text-xs text-muted-foreground">
|
||||||
|
<span>{connected ? 'connected' : 'connecting…'}</span>
|
||||||
|
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
|
||||||
|
<RefreshCw className="size-3" /> refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{error ? <p className="text-xs text-destructive">{error}</p> : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const Centred = ({ children }: { children: React.ReactNode }) => (
|
||||||
|
<div className="flex size-full flex-col items-center justify-center gap-3 p-6">{children}</div>
|
||||||
|
);
|
||||||
@@ -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',
|
||||||
|
};
|
||||||
@@ -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 },
|
||||||
|
];
|
||||||
+341
@@ -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<ArrayBuffer>, not plain Uint8Array: the default is Uint8Array<ArrayBufferLike>, 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<ArrayBuffer>): 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user