/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>
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
import { createRouter } from '../../create-router';
|
||||
import { sessionStatus, startSession, stopSession } from './session';
|
||||
|
||||
/**
|
||||
* Session control. The pixels do not come through here — they come through the websocket relay; this
|
||||
* is only "is it up, bring it up, take it down", plus the window size the panel needs to size its
|
||||
* canvas.
|
||||
*/
|
||||
export const minesweeperRouter = createRouter();
|
||||
|
||||
minesweeperRouter.get('/status', async (c) => c.json(await sessionStatus()));
|
||||
|
||||
minesweeperRouter.post('/start', async (c) => {
|
||||
const status = await sessionStatus();
|
||||
if (!status.installed) {
|
||||
return c.json({ error: 'Minesweeper is not installed on this server (wine-minesweeper is missing)' }, 501);
|
||||
}
|
||||
if (status.running) return c.json(status);
|
||||
|
||||
const result = await startSession();
|
||||
if (!result.ok) return c.json({ error: `Could not start the session: ${result.out.trim()}` }, 500);
|
||||
return c.json(await sessionStatus());
|
||||
});
|
||||
|
||||
minesweeperRouter.post('/stop', async (c) => {
|
||||
await stopSession();
|
||||
return c.json(await sessionStatus());
|
||||
});
|
||||
@@ -0,0 +1,111 @@
|
||||
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']);
|
||||
@@ -0,0 +1,95 @@
|
||||
import type { ServerWebSocket, Socket } from 'bun';
|
||||
import { VNC_HOST, VNC_PORT } from './session';
|
||||
|
||||
/**
|
||||
* A byte relay between the browser and x11vnc, in the same spirit as the terminal's relay onto the pty
|
||||
* sidecar — this file authenticates the upgrade (upgradeWs, in server.tsx) and moves bytes without
|
||||
* reading them.
|
||||
*
|
||||
* One difference decides the shape: x11vnc serves raw TCP, not a WebSocket. So this is the job
|
||||
* websockify would otherwise do, done in-process with `Bun.connect` rather than by adding a second
|
||||
* daemon and a second port to protect. RFB is a stream protocol, so frame boundaries carry no meaning
|
||||
* and chunks are forwarded exactly as they arrive.
|
||||
*/
|
||||
|
||||
export type MinesweeperWSData = { provider: 'minesweeper'; search: string };
|
||||
|
||||
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
|
||||
|
||||
const relays = new Map<ServerWebSocket<MinesweeperWSData>, Relay>();
|
||||
|
||||
const closeClient = (ws: ServerWebSocket<MinesweeperWSData>, code: number, reason: string) => {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
} catch {
|
||||
/* already gone */
|
||||
}
|
||||
try {
|
||||
ws.close(code, reason);
|
||||
} catch {
|
||||
/* already closed */
|
||||
}
|
||||
};
|
||||
|
||||
export const minesweeperWebsocket = {
|
||||
open(ws: ServerWebSocket<MinesweeperWSData>) {
|
||||
// Registered synchronously, so bytes arriving before the TCP connection is up are queued rather
|
||||
// than dropped. The RFB handshake starts with the SERVER talking, but a client that pipelines its
|
||||
// version string would otherwise lose it.
|
||||
const relay: Relay = { socket: null, queue: [], done: false };
|
||||
relays.set(ws, relay);
|
||||
|
||||
void Bun.connect({
|
||||
hostname: VNC_HOST,
|
||||
port: VNC_PORT,
|
||||
socket: {
|
||||
open(socket) {
|
||||
if (relay.done) {
|
||||
socket.end();
|
||||
return;
|
||||
}
|
||||
relay.socket = socket;
|
||||
for (const chunk of relay.queue) socket.write(chunk);
|
||||
relay.queue.length = 0;
|
||||
},
|
||||
data(_socket, chunk) {
|
||||
try {
|
||||
ws.send(chunk);
|
||||
} catch {
|
||||
/* client gone; the close handler tears down the socket */
|
||||
}
|
||||
},
|
||||
close: () => closeClient(ws, 1000, 'vnc closed'),
|
||||
error: () => closeClient(ws, 1011, 'vnc error'),
|
||||
},
|
||||
}).catch(() => {
|
||||
// No x11vnc listening — the session is not running. 1011 rather than a silent close, so the
|
||||
// panel can say so instead of showing an empty canvas.
|
||||
closeClient(ws, 1011, 'minesweeper session is not running');
|
||||
});
|
||||
},
|
||||
|
||||
message(ws: ServerWebSocket<MinesweeperWSData>, raw: string | Buffer) {
|
||||
const relay = relays.get(ws);
|
||||
if (!relay) return;
|
||||
|
||||
// RFB is binary in both directions; a text frame is a client that is not speaking it.
|
||||
const bytes = typeof raw === 'string' ? new TextEncoder().encode(raw) : new Uint8Array(raw);
|
||||
if (relay.socket) relay.socket.write(bytes);
|
||||
else relay.queue.push(bytes);
|
||||
},
|
||||
|
||||
close(ws: ServerWebSocket<MinesweeperWSData>) {
|
||||
const relay = relays.get(ws);
|
||||
relays.delete(ws);
|
||||
if (relay) relay.done = true;
|
||||
try {
|
||||
relay?.socket?.end();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user