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 = {}): 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 { 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 { 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 { 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']);