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; }; const SCREEN_W = 800; const SCREEN_H = 600; let xvfb: Proc | null = null; let game: Proc | null = null; let vnc: Proc | null = null; let display: string | null = null; let lastError: string | null = null; let starting: Promise | null = null; const alive = (p: Proc | null): boolean => !!p && p.exitCode === null && p.signalCode === null; /** * A display number nothing else is using. * * An X display IS a unix socket at `/tmp/.X11-unix/X`, so this is a real check rather than a guess. The * version this replaces hardcoded `:7`, which silently attaches to whatever is already there — including * the owner's own session if they happen to run one on that number. */ function pickDisplay(): string | null { for (let n = 20; n < 100; n++) { if (!existsSync(`/tmp/.X11-unix/X${n}`)) return `:${n}`; } return null; } /** The game's window. wine also creates a 1x1 owner stub, so the larger of the two is the real one. */ export async function windowGeometry(): Promise { if (!display) return null; const search = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY: display }, 5_000); if (!search.ok) return null; let best: WindowGeometry | null = null; for (const id of search.stdout.trim().split('\n').filter(Boolean)) { const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY: display }, 5_000); if (!geom.ok) continue; const read = (key: string) => Number(new RegExp(`^${key}=(\\d+)$`, 'm').exec(geom.stdout)?.[1] ?? NaN); const candidate = { width: read('WIDTH'), height: read('HEIGHT'), x: read('X'), y: read('Y') }; if (Object.values(candidate).some(Number.isNaN)) continue; if (!best || candidate.width * candidate.height > best.width * best.height) best = candidate; } return best && best.width > 1 ? best : null; } /** * Whether the session is up, decided by the socket file plus a live x11vnc. * * Deliberately NOT by connecting. An earlier version opened a connection and dropped it, which wedged the * server: libvncserver peeks at every new connection to decide whether it is a WebSocket handshake, and * that peek blocks its main loop until the client speaks or the peek times out. A health check that * connects and says nothing is therefore a small stall — and the panel polls status on a timer, so the * stalls overlapped, the accept backlog filled, and the real viewer stopped getting in. * * The rule that cost: a liveness check must not be indistinguishable from a client. */ export const isRunning = (): boolean => alive(vnc) && alive(xvfb) && existsSync(VNC_SOCKET); export async function status(): Promise { const installed = existsSync(GAME_EXE) && !!Bun.which('wine') && !!Bun.which('Xvfb') && !!Bun.which('x11vnc'); const running = isRunning(); return { installed, running, window: running ? await windowGeometry() : null, ...(lastError && !running ? { error: lastError } : {}), }; } const spawn = (cmd: string[], env: Record, logName: string): Proc => { const proc = Bun.spawn(cmd, { env: { ...process.env, ...env }, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe' }); // Wine and x11vnc both say useful things only when something is wrong. Prefixed so `pm2 logs // officer-minesweeper` shows which process is complaining. for (const stream of [proc.stdout, proc.stderr]) { void (async () => { for await (const chunk of stream as ReadableStream) { const text = new TextDecoder().decode(chunk).trimEnd(); if (text) console.log(`[minesweeper:${logName}] ${text}`); } })().catch(() => { /* stream closed with the process */ }); } return proc as Proc; }; async function startInner(log: (line: string) => void): Promise { ensureDirs(); lastError = null; if (!existsSync(GAME_EXE)) { lastError = 'the game executable is missing from the plugin directory'; return status(); } // ── 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(); } // 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); } } // 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. ['x11vnc', '-display', display!, '-unixsock', VNC_SOCKET, '-forever', '-shared', '-nopw', '-noxdamage', '-wait', '5', '-defer', '5'], { DISPLAY: display! }, 'x11vnc', ); const listening = await waitFor(() => existsSync(VNC_SOCKET), 15_000); if (!listening) { lastError = 'x11vnc never created its socket'; await stop(); return status(); } } log('ready'); return status(); } /** Idempotent, and safe to call concurrently — two panels opening at once must not race two sessions up. */ export function start(log: (line: string) => void): Promise { if (isRunning()) return status(); if (starting) return starting; starting = startInner(log).finally(() => { starting = null; }); return starting; } async function reap(proc: Proc | null, name: string): Promise { if (!alive(proc)) return; proc!.kill('SIGTERM'); const gone = await waitFor(() => !alive(proc), 3_000, 100); if (!gone) { console.log(`[minesweeper] ${name} ignored SIGTERM; killing`); proc!.kill('SIGKILL'); } } export async function stop(): Promise { await reap(vnc, 'x11vnc'); vnc = null; rmSync(VNC_SOCKET, { force: true }); await reap(game, 'wine'); game = null; // Scoped to THIS prefix. `wineserver` and `winedevice` are not our children and outlive the game, holding // the prefix so the next start hangs; `-k` is wine's own way to end them, and unlike `pkill -f wineserver` // it cannot touch another prefix somebody else is using. if (Bun.which('wineserver')) await run(['wineserver', '-k'], WINE_ENV, 10_000).catch(() => undefined); await reap(xvfb, 'Xvfb'); xvfb = null; display = null; return status(); } export const runDir = RUN_DIR; export const prefixIsReady = prefixReady;