Files
minesweeper/sidecar/session.ts
T
pastilhasandClaude Opus 5 e6cec953e4 a fixed stage for the game, and close the VNC port nobody meant to open
── the stage ──

The framebuffer is now the picture. 800x600 whatever the difficulty, the game
centred in it by the sidecar, and the panel draws it entire — scaled to fit,
centred by its flex parent, never cropped. Changing difficulty moves the board
instead of resizing the panel.

What this replaces parked the window at the origin and had the client crop to
the window's size, polled every four seconds. Two things wrong with it. The
panel resized under the player mid-game; and wine puts the window at 3,29
rather than 0,0, so cropping from the origin showed a black strip on top and
cut a row of cells off the bottom — the "expert gets cut off" report.

Centring runs on a one-second timer because nothing announces a resize: not
RFB, which keeps the framebuffer, and not X without a window manager to listen
through. One chained `xdotool search … getwindowgeometry` per tick, one
process, and a `windowmove` only when the numbers actually changed.

The client loses the geometry poll, the offscreen canvas and the crop with it.

── the port ──

x11vnc was listening on 0.0.0.0:5900 and [::]:5900 with -nopw. Every account
on this machine, every device on the tailnet and everything on the LAN could
open it and take the display — which is precisely the hole the 5907 version
was replaced to close, reopened wider and documented as impossible.

`-unixsock` ADDS a unix socket. It does not remove the TCP one. Three flags do:
-rfbport 0 for IPv4, -rfbportv6 0 for IPv6, -noipv6 for outgoing. `-rfbport 0`
alone leaves [::]:5900 up, which is how this nearly shipped half-fixed.

Found with `ss -lntp` on the running process, and that is the only check that
was right about it at any point. Running the same argv by hand showed no IPv6
listener — most likely because the PM2 session still held [::]:5900 and the
hand-run copy failed to bind it silently. Comments in session.ts and index.ts
that asserted there was no port are corrected rather than deleted.

Verified on the live socket: the streamed framebuffer is 447k teal pixels
around a 152x214 window at 324,193 — dead centre — and window grey arrives as
c0 c0 c0, no TCP listener for the x11vnc pid.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
2026-08-23 21:17:12 +00:00

387 lines
16 KiB
TypeScript

import type { Subprocess } from 'bun';
import { existsSync, rmSync } from 'node:fs';
import { GAME_EXE, RUN_DIR, VNC_SOCKET, ensureDirs } from './paths';
import { WINE_ENV, confineAndVerify, 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;
};
/**
* The stage: a fixed framebuffer, with the game centred in it.
*
* The game window changes size with the difficulty — 152x214 on beginner, 504x342 on expert, larger
* again on a custom board — and the whole framebuffer is what gets streamed, so the panel sees one
* stable rectangle whatever is being played. The alternative, and what this replaced, was parking the
* window at the origin and having the client crop to its size: that made the panel resize under the
* player mid-game, and it was wrong by the 3x29 wine puts between the root origin and the window, which
* showed as a black strip on top and a row of cut-off cells at the bottom.
*
* 800x600 is generous on beginner and that is deliberate: it holds the largest board Minesweeper X can
* make (30x24 custom, 504x470) with room to spare, so no difficulty ever reaches an edge.
*/
const SCREEN_W = 800;
const SCREEN_H = 600;
/**
* How often to check whether the board changed size.
*
* Nothing announces a resize — not RFB, which keeps the framebuffer, and not X without a window manager
* to listen through. The game only resizes when somebody picks a difficulty, so this is idle almost
* always; one chained `xdotool` per second is the price of the re-centre feeling immediate when it is
* not.
*/
const CENTRE_INTERVAL_MS = 1_000;
/** The desktop behind the game. Windows 95's own, because that is what this is a picture of. */
const STAGE_COLOUR = '#008080';
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;
}
type FoundWindow = WindowGeometry & { id: string };
/**
* The game's window, with the id needed to move it.
*
* wine creates several 1x1 owner stubs under the same name, so the largest candidate is the real one.
*
* One `xdotool` process, not one per candidate: `search … getwindowgeometry --shell %@` chains the two
* commands and prints a block per window. That matters because this now runs on a timer rather than once
* at startup, and the old shape spawned five processes a tick.
*/
async function findWindow(): Promise<FoundWindow | null> {
if (!display) return null;
const found = await run(
['xdotool', 'search', '--name', 'Minesweeper X', 'getwindowgeometry', '--shell', '%@'],
{ DISPLAY: display },
5_000,
);
if (!found.ok) return null;
// WINDOW= opens each block; the keys after it belong to that window until the next one.
const blocks: Record<string, string>[] = [];
for (const line of found.stdout.split('\n')) {
const match = /^(\w+)=(.+)$/.exec(line.trim());
if (!match) continue;
const [, key, value] = match;
if (key === undefined || value === undefined) continue; // the regex guarantees both; the compiler does not
if (key === 'WINDOW') blocks.push({});
const block = blocks[blocks.length - 1];
if (block) block[key] = value;
}
let best: FoundWindow | null = null;
for (const block of blocks) {
const id = block.WINDOW;
const candidate = {
width: Number(block.WIDTH),
height: Number(block.HEIGHT),
x: Number(block.X),
y: Number(block.Y),
};
if (!id || Object.values(candidate).some(Number.isNaN)) continue;
if (!best || candidate.width * candidate.height > best.width * best.height) best = { id, ...candidate };
}
return best && best.width > 1 ? best : null;
}
/** The game's window geometry, for `/status`. Nothing in the panel needs it — it is a diagnostic. */
export async function windowGeometry(): Promise<WindowGeometry | null> {
const found = await findWindow();
return found ? { width: found.width, height: found.height, x: found.x, y: found.y } : null;
}
/**
* Put the game in the middle of the stage.
*
* Clamped at zero so a board wider or taller than the framebuffer loses its right and bottom edges
* rather than its menu bar — a negative origin would push the part you need to click off the top.
*/
async function centre(): Promise<void> {
const win = await findWindow();
if (!win || !display) return;
const x = Math.max(0, Math.round((SCREEN_W - win.width) / 2));
const y = Math.max(0, Math.round((SCREEN_H - win.height) / 2));
if (win.x === x && win.y === y) return;
await run(['xdotool', 'windowmove', win.id, String(x), String(y)], { DISPLAY: display }, 5_000);
}
let centring: ReturnType<typeof setTimeout> | null = null;
/**
* Re-centre whenever the board changes size, for as long as the session is up.
*
* Self-scheduling rather than `setInterval`: a tick spawns a process and awaits it, and an interval that
* fires faster than that completes would stack ticks on a slow machine.
*/
function watchForResize(): void {
if (centring) return;
const tick = async () => {
if (!isRunning()) {
centring = null;
return;
}
await centre().catch(() => undefined);
centring = setTimeout(tick, CENTRE_INTERVAL_MS);
};
centring = setTimeout(tick, CENTRE_INTERVAL_MS);
}
/**
* 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();
}
// ── 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();
}
}
// The prefix is built HERE, after the display exists and before the game needs it. Not earlier: the VB6
// installer opens a window, so a prefix built without a display fails in a way that names neither.
const built = await ensurePrefix(log, display!);
if (!built.ok) {
lastError = built.detail;
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();
}
await centre();
}
// Wine has now booted the prefix, and booting it is what can restore `Z:`. The confinement in
// `ensurePrefix` happens before that, so on its own it would be undone by exactly the event it exists to
// survive. See `confineAndVerify`.
confineAndVerify(log);
// ── 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.
//
// THE THREE PORT FLAGS ARE WHAT MAKE THE PARAGRAPH ABOVE TRUE, AND ALL THREE ARE LOAD-BEARING.
//
// `-unixsock` ADDS a unix socket; it does not remove the TCP one. So the first version of this
// listened on 0.0.0.0:5900 AND [::]:5900 with -nopw, while its own comment said there was no port
// to authenticate against — anything on the tailnet, anything on the LAN, and every other account
// on the machine could take the display. Found with `ss -lntp` on the running session. Reading the
// flags would never have shown it, because the flag that mattered was the one nobody had written.
//
// `-rfbport 0` alone leaves `[::]:5900` up; `-rfbportv6 0` is what closes it. Verified by `ss` on
// the real process both times, which is the only check that has been right about this so far —
// running the same argv by hand showed no IPv6 listener at all, almost certainly because the
// session under PM2 was still holding [::]:5900 and the hand-run copy silently failed to bind it.
//
// Do not drop one of these because a check looks clean. Check the RUNNING process.
//
// -solid: the stage is bigger than the game, so the surround is now something people look at. Teal
// rather than the root's black, which reads as a broken stream rather than a desktop. x11vnc sets it
// itself on a bare display, so this costs no new host binary.
['x11vnc', '-display', display!, '-unixsock', VNC_SOCKET, '-rfbport', '0', '-rfbportv6', '0', '-noipv6', '-solid', STAGE_COLOUR, '-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();
}
}
watchForResize();
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> {
if (centring) {
clearTimeout(centring);
centring = null;
}
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;