`Run-time error '76': Path not found` on finishing a game. Error 76 is VB6's "path not found", and the path it could not find was the game's own. Confining the prefix removed `z:`, which left `C:` as the only drive — and the executable was in the plugin checkout, which `C:` does not cover. So wine gave the game `\\?\unix\home\pastilhas\...\Minesweeper X.exe` as its own location (confirmed with `winepath`), VB6 file I/O cannot write to a path of that shape, and the first thing that needs to is the auto-saved recording. Which happens at the END of a game, so the failure arrived after somebody had played. The comment in prefix.ts already claimed "the game keeps its own directory, its settings and its replays". It did not. It does now: `installGame` copies the 68 KB executable into `C:\Minesweeper` on every start and the game runs from there, with cwd set to match so relative writes stop landing in C:\windows. Copied rather than symlinked — a symlink out of drive_c is a door out of the prefix, which is the exact thing confinePrefix exists to close. The cost is that replays now live inside prefix/, so deleting it to unwedge wine takes them along; that is in the README rather than left to be discovered. Verified end to end rather than by inspection: set Auto-Save Recording to All, played a beginner game to a loss, and `Beg 1.16 - Aug 23 2026 22.09.18 PM (Lost).mvr` appeared in C:\Minesweeper. Before this, that same moment was the error box. The default is "High scores", which is why an ordinary losing game never reproduced it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
412 lines
18 KiB
TypeScript
412 lines
18 KiB
TypeScript
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.
|
|
*
|
|
* 560x400 is expert (504x342) plus a margin, which is what the three standard difficulties need. A
|
|
* CUSTOM board larger than this is clipped at the right and bottom edges rather than shrunk — `centre`
|
|
* clamps at zero so the menu bar stays reachable. That is the trade: the sizes people actually play are
|
|
* as large as they can be, and the one that is not is still playable. Widen these two numbers if that
|
|
* turns out to be the wrong way round.
|
|
*/
|
|
const SCREEN_W = 560;
|
|
const SCREEN_H = 400;
|
|
|
|
/**
|
|
* 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, 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<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)) {
|
|
// 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<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;
|