import type { Subprocess } from 'bun'; import { existsSync, rmSync } from 'node:fs'; import { GAME_DIR, GAME_EXE, GAME_IN_PREFIX, RUN_DIR, VNC_SOCKET, ensureDirs } from './paths'; import { WINE_ENV, confineAndVerify, ensurePrefix, installGame, 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. * * ── Why it is this small ── * * The stage is what gets scaled to fill the panel, so every pixel of empty surround is panel the game * does not get. The client scales by whole numbers only — fractional scaling makes a 16px grid uneven — * so the size here decides which step it lands on, and the steps are far apart. In a 1866-device-pixel * panel: 800 wide gives 2x, 560 wide gives 3x. Same panel, half again as much game. * * 512x352 is expert (504x342, measured) plus four pixels a side. There is no room left to give: expert * and intermediate are both 342 tall, so anything shorter clips a board somebody actually plays. * * Two things get clipped at this size, and both are deliberate. A CUSTOM board larger than the stage * loses its right and bottom edges — `centre` clamps at zero, so the menu bar stays reachable. And the * Extras → Auto Options dialog is 328 wide and opens 25px right of the game window, which needs a stage * 554 wide to fit whole when the board is beginner. That dialog cannot be closed from the panel anyway * (no window manager, so no title bar, and it has no OK button) — see the README. Fix that first, then * revisit this number. */ const SCREEN_W = 512; const SCREEN_H = 352; /** * 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 | null = null; const alive = (p: Proc | null): boolean => !!p && p.exitCode === null && p.signalCode === null; /** * A display number nothing else is using. * * An X display IS a unix socket at `/tmp/.X11-unix/X`, so this is a real check rather than a guess. The * version this replaces hardcoded `:7`, which silently attaches to whatever is already there — including * the owner's own session if they happen to run one on that number. */ function pickDisplay(): string | null { for (let n = 20; n < 100; n++) { if (!existsSync(`/tmp/.X11-unix/X${n}`)) return `:${n}`; } return null; } 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 { 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[] = []; 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 { 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 { 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 | 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 { const installed = existsSync(GAME_EXE) && !!Bun.which('wine') && !!Bun.which('Xvfb') && !!Bun.which('x11vnc'); const running = isRunning(); return { installed, running, window: running ? await windowGeometry() : null, ...(lastError && !running ? { error: lastError } : {}), }; } const spawn = (cmd: string[], env: Record, logName: string, cwd?: string): Proc => { const proc = Bun.spawn(cmd, { env: { ...process.env, ...env }, cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }); // Wine and x11vnc both say useful things only when something is wrong. Prefixed so `pm2 logs // officer-minesweeper` shows which process is complaining. for (const stream of [proc.stdout, proc.stderr]) { void (async () => { for await (const chunk of stream as ReadableStream) { const text = new TextDecoder().decode(chunk).trimEnd(); if (text) console.log(`[minesweeper:${logName}] ${text}`); } })().catch(() => { /* stream closed with the process */ }); } return proc as Proc; }; async function startInner(log: (line: string) => void): Promise { ensureDirs(); lastError = null; if (!existsSync(GAME_EXE)) { lastError = 'the game executable is missing from the plugin directory'; return status(); } // ── 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)) { // Into the prefix first. Run from the plugin directory the game has no DOS path of its own, and the // first thing that needs one is saving a replay — which happens at the END of a game, so the failure // arrives after somebody has played rather than when they start. await installGame(); log('launching Minesweeper X under wine'); // cwd as well as the exe path: without it wine reports "could not open working directory" and starts // the process in C:\windows, so anything the game writes relatively lands there instead. game = spawn(['wine', GAME_IN_PREFIX], env, 'wine', GAME_DIR); 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. // // -nocursor: the browser already draws a cursor over the canvas, and it is the accurate one. The // client asks for Raw and CopyRect and nothing else — no cursor pseudo-encoding — so x11vnc has // nowhere to send a cursor SHAPE and composites it into the framebuffer instead. That put a second // pointer in the picture, lagging the real one by a round trip and, on a bare X server with no // window manager, drawn as the big black X of the root cursor. Hiding it server-side is the fix; // adding the pseudo-encoding to the client would be a worse one, since the answer is not to draw // the remote cursor better but not to draw it at all. ['x11vnc', '-display', display!, '-unixsock', VNC_SOCKET, '-rfbport', '0', '-rfbportv6', '0', '-noipv6', '-solid', STAGE_COLOUR, '-nocursor', '-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 { if (isRunning()) return status(); if (starting) return starting; starting = startInner(log).finally(() => { starting = null; }); return starting; } async function reap(proc: Proc | null, name: string): Promise { if (!alive(proc)) return; proc!.kill('SIGTERM'); const gone = await waitFor(() => !alive(proc), 3_000, 100); if (!gone) { console.log(`[minesweeper] ${name} ignored SIGTERM; killing`); proc!.kill('SIGKILL'); } } export async function stop(): Promise { 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;