First command in what will presumably be several, so the shape matters more
than the feature: name the OUTCOME, verify it against the world, and report
what the game now IS rather than that a click was sent.
POST /api/minesweeper/difficulty { "level": "expert" }
-> { "ok": true, "level": "expert", "changed": true }
The proxy forwards <prefix>/*, so this needed no platform-side change.
── clicks, not keys, and that was measured ──
The obvious mechanism is `alt+g` then the underlined letter. It does not work
reliably: the same sequence gave a different difficulty depending on what was
selected before, and `alt+g e` landed on Intermediate more than once. Arrow
navigation was no better. A menu keystroke is a SEQUENCE, so it depends on
timing and on where the highlight already was; a click is one event that names
its target. Six scrambled clicks in a row hit six correct items.
There is a second reason keys are wrong here. With no window manager, X input
focus is PointerRoot — keys go to whatever the pointer is over — so a sequence
that RESIZES the window can move it out from under the pointer and silently
drop the rest of its own keystrokes. That is exactly what the first attempt
did, and it looked like a mapping problem.
── two things that make it a command rather than a click ──
`withStableWindow` holds off the re-centring watcher for the duration. A menu
interaction is two clicks ~450ms apart and the watcher ticks every second, so
it WILL move the window between them, and the second click then lands where
the item used to be — which on this game is the board. A stray click on a live
board reveals a cell and can lose the game.
And the check is the window's SIZE, not that the click was sent: 152x214,
280x342, 504x342 are distinct, so the result is verifiable. That is what makes
retrying safe, and what makes a repeat a no-op (`changed: false`) rather than a
second difficulty change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
455 lines
20 KiB
TypeScript
455 lines
20 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.
|
|
*
|
|
* 512x384. The width is expert (504x342, measured) plus four pixels a side; the HEIGHT is not, and the
|
|
* extra 42 is load-bearing.
|
|
*
|
|
* At 512x352 — expert plus ten pixels of height — the game refuses to open a replay with "Your screen
|
|
* cannot fit a window of that size", even though the replay window is exactly the 504x342 it just
|
|
* measured. It is not a positional check: from a centred beginner window the expert window would run
|
|
* 236px off the right edge and the game repositions it happily. It is a minimum-screen check with slack
|
|
* the game never spends, and the slack it wants is roughly a title bar it does not have here. 384 was
|
|
* verified by opening an expert replay; 352 was verified refusing one.
|
|
*
|
|
* Growing the stage costs nothing at the sizes people use, which is why the number is generous rather
|
|
* than minimal: the panel caps the scale at 2x, so anything up to about 726x451 draws the game at exactly
|
|
* the same size. The stage only starts costing pixels once `fit` drops below the cap.
|
|
*
|
|
* Two things are still clipped here, both 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 Extras → Auto
|
|
* Options is 328 wide and opens 25px right of the game window, so it needs a 554-wide stage to fit whole
|
|
* from 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 the width.
|
|
*/
|
|
const SCREEN_W = 512;
|
|
const SCREEN_H = 384;
|
|
|
|
/**
|
|
* 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.
|
|
*/
|
|
export 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);
|
|
}
|
|
|
|
/**
|
|
* Drive the game's own UI with the window held still.
|
|
*
|
|
* Anything that opens a menu clicks twice — once on the menu bar, once on the item — and the re-centring
|
|
* watcher runs between those two clicks often enough to matter. If the window moves after the first
|
|
* click, the second lands wherever the item used to be, which on this game means somewhere on the BOARD.
|
|
* A stray click on a live board reveals a cell, and can lose a game outright, so this is not a tidiness
|
|
* fix.
|
|
*
|
|
* Re-centres before handing the watcher back, so the window is not left off-centre for up to a second
|
|
* after a difficulty change resizes it.
|
|
*/
|
|
export async function withStableWindow<T>(fn: () => Promise<T>): Promise<T> {
|
|
const wasWatching = centring !== null;
|
|
if (centring) {
|
|
clearTimeout(centring);
|
|
centring = null;
|
|
}
|
|
try {
|
|
return await fn();
|
|
} finally {
|
|
await centre().catch(() => undefined);
|
|
if (wasWatching) watchForResize();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
|
|
/** The display the session is on, for anything that has to drive the game's own UI. Null when it is down. */
|
|
export const activeDisplay = (): string | null => display;
|
|
|
|
export const runDir = RUN_DIR;
|
|
export const prefixIsReady = prefixReady;
|