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>
112 lines
4.7 KiB
TypeScript
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']);
|