Files
platform/src/servers/api/minesweeper/session.ts
T
pastilhasandClaude Fable 5 af918251d3 /minesweeper — a wine session streamed to the browser over RFB
a websocket byte relay onto x11vnc's tcp socket, the same shape as the
terminal's relay onto the pty sidecar. this is the piece plugins/desktop
could not have: a plugin cannot claim a websocket provider.

the rfb client is ours rather than vendored novnc — raw and copyrect,
one pixel format we dictate so a rectangle memcpys into ImageData. we
control the server, so the other eight encodings are surface we would
carry and never run.

execution, so owner-only: wine runs as the owner, which makes the game's
menus file dialogs over the owner's home.

two bugs worth keeping. the token key is BEARER_TOKEN, not token, so the
upgrade 401'd before any rfb byte moved. and the /status health check
opened a tcp connection to prove liveness — libvncserver blocks its
accept loop peeking for a websocket handshake, so a polled health check
filled the backlog and locked out the real viewer. a liveness check must
not be indistinguishable from a client; it reads /proc/net/tcp now.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-22 21:39:35 +00:00

112 lines
4.7 KiB
TypeScript

import { join } from 'node:path';
import { OFFICER_ROOT } from '../../data-path';
/**
* The Minesweeper X session: a virtual display with one Windows game on it, and x11vnc serving that
* display on loopback.
*
* The platform does not implement any of that — it drives `bin/session.sh`, which is the same script
* used by hand. One implementation, so a session started from the browser and one started from a
* terminal are the same session with the same lifecycle.
*/
export const MINESWEEPER_DIR = join(OFFICER_ROOT, 'wine-minesweeper');
const SESSION_SCRIPT = join(MINESWEEPER_DIR, 'bin', 'session.sh');
export const VNC_HOST = '127.0.0.1';
export const VNC_PORT = Number(process.env.MINESWEEPER_VNC_PORT ?? 5907);
const DISPLAY = process.env.MINESWEEPER_DISPLAY ?? ':7';
export type WindowGeometry = { width: number; height: number; x: number; y: number };
export type SessionStatus = {
installed: boolean;
running: boolean;
/** Absent until the game has drawn its window. */
window: WindowGeometry | null;
};
const run = async (cmd: string[], env: Record<string, string> = {}): Promise<{ ok: boolean; out: string }> => {
const proc = Bun.spawn(cmd, {
cwd: MINESWEEPER_DIR,
env: { ...process.env, ...env },
stdout: 'pipe',
stderr: 'pipe',
});
const [out, err] = await Promise.all([new Response(proc.stdout).text(), new Response(proc.stderr).text()]);
const code = await proc.exited;
return { ok: code === 0, out: `${out}${err}` };
};
export const isInstalled = (): boolean => Bun.file(SESSION_SCRIPT).size > 0;
/**
* Whether x11vnc is listening, decided by reading the kernel's socket table rather than by connecting.
*
* This used to open a TCP connection and drop it. That wedged the VNC server: libvncserver peeks at a
* 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 (Recv-Q 32/32), and the server stopped accepting the real viewer too.
*
* The rule it cost to learn: a liveness check must not be indistinguishable from a client. Reading
* /proc/net/tcp is free, cannot block, and cannot be mistaken for a viewer.
*/
async function isServing(): Promise<boolean> {
const wanted = VNC_PORT.toString(16).toUpperCase().padStart(4, '0');
const LISTEN = '0A';
for (const table of ['/proc/net/tcp', '/proc/net/tcp6']) {
const text = await Bun.file(table)
.text()
.catch(() => '');
for (const line of text.split('\n').slice(1)) {
const columns = line.trim().split(/\s+/);
const local = columns[1];
const state = columns[3];
if (!local || state !== LISTEN) continue;
if (local.split(':')[1] === wanted) return true;
}
}
return false;
}
/**
* The game window's size, so the browser can size its canvas to the board rather than to the whole
* framebuffer. `session.sh` parks the window at 0,0, so the offset is expected to be zero and is
* reported anyway — a non-zero one means something moved it and the canvas would be misaligned.
*
* The board changes size when the difficulty changes, which is why this is polled rather than read
* once at connect.
*/
export async function windowGeometry(): Promise<WindowGeometry | null> {
const search = await run(['xdotool', 'search', '--name', 'Minesweeper X'], { DISPLAY });
if (!search.ok) return null;
let best: WindowGeometry | null = null;
for (const id of search.out.trim().split('\n').filter(Boolean)) {
const geom = await run(['xdotool', 'getwindowgeometry', '--shell', id], { DISPLAY });
if (!geom.ok) continue;
const read = (key: string): number => Number(new RegExp(`^${key}=(\\d+)$`, 'm').exec(geom.out)?.[1] ?? NaN);
const candidate = { width: read('WIDTH'), height: read('HEIGHT'), x: read('X'), y: read('Y') };
if (Object.values(candidate).some(Number.isNaN)) continue;
// wine creates a 1x1 owner stub alongside the real window; the larger one is the game.
if (!best || candidate.width * candidate.height > best.width * best.height) best = candidate;
}
return best && best.width > 1 ? best : null;
}
export async function sessionStatus(): Promise<SessionStatus> {
const installed = isInstalled();
if (!installed) return { installed, running: false, window: null };
const running = await isServing();
return { installed, running, window: running ? await windowGeometry() : null };
}
export const startSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'start']);
export const stopSession = (): Promise<{ ok: boolean; out: string }> => run([SESSION_SCRIPT, 'stop']);