Top panel: Expert / Intermediate / Beginner. Bottom: that difficulty's saved
games, best first, click to play one on the board.
── the selection lives in the URL ──
`/minesweeper/expert`, not a channel and not panel state. It survives a
refresh, it can be linked, and the two panels never pass anything between
themselves — both read `useParams`. The plugin section route already existed;
nothing had used it.
The side effect hangs off the URL too: an effect on the selected level posts
the difficulty, so arriving at `/minesweeper/expert` by click, deep link or
back button all leave the game on expert. `POST /difficulty` verifies and is
idempotent, so a repeat changes nothing. One `<Navigate replace>` after the
hooks canonicalises both the bare route and a bogus section, and it waits for
`/status` so the URL lands on what the game IS rather than a guess — which is
why `difficulty` is now part of that payload.
Levels are NavLinks so cmd-click works and the active style comes from the
router. Replay rows are buttons, because they do not navigate anywhere, they
make the game do something. That is the line navigation-audit.md draws.
── two things on the server ──
`GET /replays` reads the directory rather than any index, because the filename
IS the metadata — Minesweeper X names a recording `<Lvl> <secs> - <date>.mvr`
and keeps no catalogue. A name that does not parse is skipped rather than
guessed at. Ordering is the server's: a loss is never "best" however short it
was, so wins rank by time and losses fall to the end, still watchable.
`POST /replay` drives Extras > Play Recording... and TYPES the filename into a
file dialog, which is why it only accepts a name `isKnownReplay` just matched
against the directory. An unchecked client string reaching `xdotool type` can
carry a path, a wildcard, or a newline that submits something else. Verified by
the dialog closing, not by the window size — a beginner replay leaves the
window beginner-sized, which is also what nothing-happened looks like.
Verified end to end against the running session: the expert list comes back
ranked 58.63 through 82.06 with losses after, playing 58.63 starts on the
board, and `{"file":"../../../etc/passwd"}` is a 404.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
266 lines
12 KiB
TypeScript
266 lines
12 KiB
TypeScript
import type { ServerWebSocket, Socket } from 'bun';
|
|
import { createSidecarConnector } from '@@/sidecar/connect';
|
|
import { API_URL } from '@@/officer-url.mjs';
|
|
import { VNC_SOCKET, ensureDirs } from './paths';
|
|
import { activeDisplay, isRunning, start, status, stop } from './session';
|
|
import { DIFFICULTIES, currentDifficulty, isDifficulty, playReplay, selectDifficulty } from './menu';
|
|
import { isKnownReplay, listReplays } from './replays';
|
|
|
|
/*
|
|
officer-minesweeper — owns the game session, and serves both halves of the plugin from one listener.
|
|
|
|
─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
HTTP CONTRACT — the platform strips its /api/minesweeper mount prefix before forwarding.
|
|
|
|
GET /status { installed, running, window, difficulty, error? }
|
|
POST /start brings the session up; idempotent, and builds the wine prefix on a cold install
|
|
POST /stop takes it down
|
|
POST /difficulty { level: beginner | intermediate | expert } — drives the game's own Game menu
|
|
GET /replays ?level=<one of those> — the saved games, best first
|
|
POST /replay { file } — opens one, by name, and only a name /replays just returned
|
|
GET /ws the display — an RFB stream, relayed frame-for-byte onto x11vnc's unix socket
|
|
|
|
─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
WHY ONE PORT SERVES BOTH
|
|
|
|
A sidecar announces exactly one port, and that single announcement is the entire reason the platform
|
|
trusts it: the port was observed arriving at registration rather than asserted by a manifest. So `/ws`
|
|
lives here, beside the routes, on the listener that was announced — and the platform's plugin socket
|
|
relay moves frames onto it without knowing that RFB exists.
|
|
|
|
The conversion from frames to a byte stream happens HERE, in the only process that knows what it is
|
|
carrying. That is the whole shape: the platform authenticates and relays; the plugin speaks its protocol.
|
|
|
|
─────────────────────────────────────────────────────────────────────────────────────────────────
|
|
WHAT REACHES x11vnc
|
|
|
|
Nothing but this file. x11vnc binds a socket inside a mode-0700 directory, so the kernel refuses every
|
|
other account before any credential could be checked. That is why `-nopw` is right rather than lazy —
|
|
see the block comment in `session.ts` where it is spawned.
|
|
|
|
This paragraph used to end "and has no port at all", and that was false for as long as it existed:
|
|
`-unixsock` ADDS a unix socket, it does not remove the TCP listener, so x11vnc was on 0.0.0.0:5900 with
|
|
no password. `-rfbport 0` is what makes the sentence true, and it is in `session.ts` for that reason.
|
|
|
|
This listener is loopback and unauthenticated, exactly like every other sidecar in the fleet; the platform
|
|
is the authenticator and `X-Officer-User` is how a request proves it came through one.
|
|
*/
|
|
|
|
const NAME = 'minesweeper';
|
|
const log = (line: string) => console.log(`[${NAME}] ${line}`);
|
|
|
|
ensureDirs();
|
|
|
|
/** An ephemeral free port, grabbed by briefly binding one. Announced at registration; never written down. */
|
|
function getFreePort(): number {
|
|
const probe = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') });
|
|
const p = probe.port;
|
|
probe.stop(true);
|
|
if (p == null) throw new Error('failed to acquire a free port');
|
|
return p;
|
|
}
|
|
|
|
const port = getFreePort();
|
|
|
|
type WsData = { userId: number };
|
|
type Relay = { socket: Socket | null; queue: Uint8Array[]; done: boolean };
|
|
const relays = new Map<ServerWebSocket<WsData>, Relay>();
|
|
|
|
const teardown = (ws: ServerWebSocket<WsData>, 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 closing */
|
|
}
|
|
};
|
|
|
|
const server = Bun.serve({
|
|
port,
|
|
hostname: '127.0.0.1',
|
|
|
|
async fetch(req, srv) {
|
|
const url = new URL(req.url);
|
|
|
|
// Loopback plus this header is the trust signal every sidecar here uses: a request without it did not
|
|
// come through the platform, and the platform is what decided the caller may play.
|
|
const officerUser = req.headers.get('X-Officer-User');
|
|
const userId = Number(officerUser);
|
|
if (!officerUser || !Number.isInteger(userId) || userId <= 0) {
|
|
return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 });
|
|
}
|
|
|
|
if (url.pathname === '/ws') {
|
|
if (srv.upgrade(req, { data: { userId } satisfies WsData })) return undefined as unknown as Response;
|
|
return new Response('expected a websocket upgrade', { status: 426 });
|
|
}
|
|
|
|
try {
|
|
if (req.method === 'GET' && url.pathname === '/status') {
|
|
// `difficulty` is derived here rather than in `status()` so that `session.ts` does not have to
|
|
// import from `menu.ts`, which imports from it — the cycle would work and would still be a trap.
|
|
const current = await status();
|
|
return Response.json({ ...current, difficulty: current.window ? currentDifficulty(current.window) : null });
|
|
}
|
|
if (req.method === 'POST' && url.pathname === '/start') {
|
|
const result = await start(log);
|
|
// 503 rather than 500: the session is unavailable and the reason is in the body, which is what the
|
|
// panel shows instead of an empty canvas.
|
|
return Response.json(result, { status: result.running ? 200 : 503 });
|
|
}
|
|
if (req.method === 'POST' && url.pathname === '/stop') return Response.json(await stop());
|
|
|
|
// The one command so far, and the shape the rest should follow: name the OUTCOME, verify it, and
|
|
// report what the game actually is now rather than that a click was sent.
|
|
if (req.method === 'POST' && url.pathname === '/difficulty') {
|
|
const display = activeDisplay();
|
|
if (!isRunning() || !display) {
|
|
return Response.json({ error: 'the session is not running' }, { status: 409 });
|
|
}
|
|
const body = (await req.json().catch(() => null)) as { level?: unknown } | null;
|
|
if (!isDifficulty(body?.level)) {
|
|
return Response.json(
|
|
{ error: `level must be one of ${Object.keys(DIFFICULTIES).join(', ')}` },
|
|
{ status: 400 },
|
|
);
|
|
}
|
|
const result = await selectDifficulty(body.level, display);
|
|
return Response.json(result, { status: result.ok ? 200 : 502 });
|
|
}
|
|
|
|
// Read off the directory the game writes to, so it needs no running session.
|
|
if (req.method === 'GET' && url.pathname === '/replays') {
|
|
const level = url.searchParams.get('level');
|
|
if (level !== null && !isDifficulty(level)) {
|
|
return Response.json({ error: `level must be one of ${Object.keys(DIFFICULTIES).join(', ')}` }, { status: 400 });
|
|
}
|
|
return Response.json({ replays: listReplays(level ?? undefined) });
|
|
}
|
|
|
|
if (req.method === 'POST' && url.pathname === '/replay') {
|
|
const display = activeDisplay();
|
|
if (!isRunning() || !display) {
|
|
return Response.json({ error: 'the session is not running' }, { status: 409 });
|
|
}
|
|
const body = (await req.json().catch(() => null)) as { file?: unknown } | null;
|
|
// Checked against the DIRECTORY, not against a pattern. The name is about to be typed into a
|
|
// file dialog, and the only string safe to type there is one that names a file we just listed.
|
|
if (!isKnownReplay(body?.file)) {
|
|
return Response.json({ error: 'no such replay' }, { status: 404 });
|
|
}
|
|
const result = await playReplay(body.file, display);
|
|
return Response.json(result, { status: result.ok ? 200 : 502 });
|
|
}
|
|
} catch (err) {
|
|
console.error(`[${NAME}] ${req.method} ${url.pathname} failed`, err);
|
|
return Response.json({ error: 'internal error' }, { status: 500 });
|
|
}
|
|
|
|
return Response.json({ error: 'not found' }, { status: 404 });
|
|
},
|
|
|
|
websocket: {
|
|
open(ws: ServerWebSocket<WsData>) {
|
|
/*
|
|
The queue is not an optimisation. The client is connected the moment the upgrade returns and an RFB
|
|
client sends its protocol version immediately, while `Bun.connect` is still resolving. Dropping
|
|
those bytes desynchronises the handshake in a way that renders as a corrupt display rather than as
|
|
a lost packet.
|
|
*/
|
|
const relay: Relay = { socket: null, queue: [], done: false };
|
|
relays.set(ws, relay);
|
|
|
|
if (!isRunning()) {
|
|
teardown(ws, 1011, 'the session is not running');
|
|
return;
|
|
}
|
|
|
|
void Bun.connect({
|
|
unix: VNC_SOCKET,
|
|
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 {
|
|
/* viewer gone; close tidies up */
|
|
}
|
|
},
|
|
close: () => teardown(ws, 1000, 'display closed'),
|
|
error: () => teardown(ws, 1011, 'display error'),
|
|
},
|
|
}).catch(() => teardown(ws, 1011, 'could not reach the display'));
|
|
},
|
|
|
|
message(ws: ServerWebSocket<WsData>, raw: string | Buffer) {
|
|
const relay = relays.get(ws);
|
|
if (!relay) return;
|
|
// RFB is binary both ways; 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<WsData>) {
|
|
const relay = relays.get(ws);
|
|
relays.delete(ws);
|
|
if (relay) relay.done = true;
|
|
try {
|
|
relay?.socket?.end();
|
|
} catch {
|
|
/* already gone */
|
|
}
|
|
},
|
|
},
|
|
});
|
|
|
|
log(`listening on 127.0.0.1:${port}`);
|
|
|
|
const connection = createSidecarConnector({
|
|
apiUrl: `${API_URL}/api/sidecar/register`,
|
|
name: NAME,
|
|
handles: [NAME],
|
|
onCommand(cmd, reply) {
|
|
const c = cmd as { type: string; id?: string };
|
|
if (c.type === 'ping') reply({ type: 'pong', id: c.id } as never);
|
|
else reply({ type: 'error', id: c.id, error: `Unknown command type: ${c.type}` } as never);
|
|
},
|
|
onConnected() {
|
|
// The announcement the plugin websocket relay resolves against. One port, both halves.
|
|
connection.send({ type: `${NAME}:server`, port } as never);
|
|
log(`reported server port ${port} to the platform`);
|
|
},
|
|
});
|
|
|
|
async function shutdown(signal: string) {
|
|
log(`${signal} received, shutting down`);
|
|
try {
|
|
server.stop(true);
|
|
} catch {
|
|
/* already stopped */
|
|
}
|
|
// The game is not left running. It is a child of this process, so PM2 would reach it anyway — but wine's
|
|
// own daemons are not, and leaving those holding the prefix is what makes the NEXT start hang.
|
|
await stop().catch(() => undefined);
|
|
connection.destroy();
|
|
process.exit(0);
|
|
}
|
|
|
|
process.on('SIGTERM', () => void shutdown('SIGTERM'));
|
|
process.on('SIGINT', () => void shutdown('SIGINT'));
|