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:
2026-08-23 17:43:17 +00:00
co-authored by Claude Opus 5
commit f3cc1242c4
14 changed files with 1533 additions and 0 deletions
+209
View File
@@ -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'));
+39
View File
@@ -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 });
}
+147
View File
@@ -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 });
}
}
}
+39
View File
@@ -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);
}
}
+261
View File
@@ -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;