Files
minesweeper/sidecar/session.ts
T
pastilhasandClaude Opus 5 ad2aa632a5 hide the cursor the server draws — yours is the accurate one
`-nocursor`. The RFB client asks for Raw and CopyRect and nothing else, so
x11vnc has no cursor pseudo-encoding to send a shape through and composites
the pointer into the framebuffer instead. That is a second cursor in the
picture, a round trip behind the real one, and on a bare X server with no
window manager it is the big black X of the root cursor rather than an arrow.

Verified on the live socket by parking the pointer at 100,100 and counting
what is not stage-teal in a 48x48 patch around it: 960 pixels before, 0 after.

Not fixed by teaching the client the cursor encodings — the answer is not to
draw the remote cursor better, it is not to draw it at all.

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

395 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.
//
// -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;