Minesweeper X under wine on a headless display, as a plugin. It was /api/minesweeper, a Dashboard screen, an officerdev panel, a permission registry entry and a hand-written websocket provider in server.tsx. The socket is why it had to live in the platform: Bun's route table is fixed at serve(), so nothing installed later could add one. That ceiling is gone, and this is the first plugin through the per-plugin relay. Three things changed rather than moved: The sidecar owns the session. The platform used to spawn bin/session.sh detached, so the game outlived every restart, nothing supervised it, and a crashed run left wine processes holding the prefix — the next start hung rather than failed. PM2 supervises a sidecar, and stop() uses 'wineserver -k' scoped by WINEPREFIX instead of 'pkill -f wineserver', which would have killed every other wine prefix on the machine. x11vnc moved from 127.0.0.1:5907 to a unix socket in a mode-0700 directory. -nopw was only ever safe because of the machine, not the platform: any local account could open that port and take the display. A fixed port is squattable too. There is now no listener to authenticate against. The wine prefix is confined to C:. This is the one that mattered. The platform classified Minesweeper as kind:'execution' — never grantable — because a fresh prefix maps the whole filesystem to Z: and symlinks the profile into the owner's real home, and the game has Open and Save dialogs. A plugin permission has no kind and is always grantable, so extracting it without this would have converted a sealed capability into a file picker running as the owner. Re-asserted on every start, because wine restores z: whenever it updates a prefix. Not solved: one display and one game, so two grantees share a board. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
262 lines
10 KiB
TypeScript
262 lines
10 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, 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<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;
|
|
}
|
|
|
|
/** 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<WindowGeometry | null> {
|
|
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<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();
|
|
}
|
|
|
|
const built = await ensurePrefix(log);
|
|
if (!built.ok) {
|
|
lastError = built.detail;
|
|
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();
|
|
}
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
// ── 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<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> {
|
|
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;
|