/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:
2026-08-22 21:39:35 +00:00
co-authored by Claude Fable 5
parent ae27478a27
commit af918251d3
15 changed files with 848 additions and 1 deletions
+1
View File
@@ -106,6 +106,7 @@ export function App() {
<Route path="/system-monitor/:scope" element={<Dashboard.SystemMonitorScreen />} />
{/* TEMPORARY / EXPERIMENTAL — offline file transfer over animated QR codes */}
<Route path="/qr-transfer" element={<Dashboard.QrTransferScreen />} />
<Route path="/minesweeper" element={<Dashboard.MinesweeperScreen />} />
<Route path="/activity" element={<Dashboard.ActivityScreen />} />
<Route path="/activity/:id" element={<Dashboard.ActivityScreen />} />
@@ -0,0 +1,20 @@
import type { LayoutNode } from 'officerdev';
import { WorkspaceView } from 'officerdev';
import { useDashboardState } from 'state/useDashboardState';
import { defaultLayout } from './defaultLayout';
// /minesweeper — Minesweeper X running under wine on a virtual display, streamed over RFB.
//
// The pixels arrive through /api/minesweeper/ws, which is a byte relay onto x11vnc's own TCP socket on
// loopback — the same shape as the terminal's relay onto the pty sidecar, and the reason this works at
// all where `plugins/desktop` could not: a websocket provider is a platform concern, and a plugin
// cannot claim one.
//
// Owner-only, and not because a game is dangerous: wine runs as the server owner, so the game's menus
// are file dialogs with the owner's home behind them.
export const MinesweeperScreen = () => {
const workspace = useDashboardState<LayoutNode>('screens/minesweeper', defaultLayout);
return <WorkspaceView workspace={workspace} locked appTypes={{ allowed: ['minesweeper'], fallback: 'minesweeper' }} />;
};
@@ -0,0 +1,9 @@
import type { LayoutNode } from 'officerdev';
// One panel. The board is 152x214 and there is nothing to put beside it — a split here would be a
// second panel of empty space.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'minesweeper',
appType: 'minesweeper',
};
@@ -0,0 +1 @@
export * from './MinesweeperScreen';
@@ -22,3 +22,4 @@ export * from './Dashboards';
export * from './Terminal';
export * from './Jobs';
export * from './QrTransfer';
export * from './Minesweeper';
+4 -1
View File
@@ -16,6 +16,7 @@ import { resolveAuthToken } from './servers/auth-token';
import { isWsProviderAllowed } from './servers/permissions/authorize';
import { isTokenBlacklisted, getUserById } from 'officerdb';
import { terminalWebsocket } from './servers/api/terminal/websocket';
import { minesweeperWebsocket } from './servers/api/minesweeper/websocket';
import { chatWebsocket } from './servers/api/chat/websocket';
import { chatNotifyWebsocket } from './servers/api/chat/notify-delivery';
import { registerChatPushNotifications } from './servers/api/chat/notify-push';
@@ -159,6 +160,7 @@ const handlers: Record<string, any> = {
'chat-notify': chatNotifyWebsocket,
'task-runner': taskRunnerWebsocket,
pipeline: pipelineWebsocket,
minesweeper: minesweeperWebsocket,
sidecar: sidecarWebsocket,
};
@@ -185,6 +187,7 @@ const WS_ROUTES: Record<string, UpgradableProvider> = {
'/api/tasks/run/ws': 'task-runner',
'/api/tasks/pipeline/ws': 'pipeline',
'/api/terminal/ws': 'terminal',
'/api/minesweeper/ws': 'minesweeper',
'/api/chat/ws': 'chat',
'/api/chat/notify/ws': 'chat-notify',
};
@@ -363,7 +366,7 @@ console.log(
* cannot serve is now a typecheck failure instead of a runtime one. `sidecar` and `dev-proxy` are
* deliberately absent — they upgrade through their own handlers, not through here.
*/
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline';
type UpgradableProvider = 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline' | 'minesweeper';
async function upgradeWs(req: Request, server: any, provider: UpgradableProvider) {
const token = new URL(req.url).searchParams.get('token');
+28
View File
@@ -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());
});
+111
View File
@@ -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']);
+95
View File
@@ -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 */
}
},
};
+2
View File
@@ -26,6 +26,7 @@ import { pluginsRouter } from './api/plugins/router';
import { isPluginPublicPath, pluginForProtocolHeaders } from './plugins/public-paths';
import { agentHandoffRouter } from './api/agent-handoff/router';
import { terminalRouter } from './api/terminal/sidecar-server';
import { minesweeperRouter } from './api/minesweeper/router';
import { caldavRouter } from './api/dav/sidecar-server';
// import { memosRouter } from './api/memos/router';
import { appStoreRouter } from './api/app-store/router';
@@ -139,6 +140,7 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType<typeof createRouter>
['/dashboards', dashboardsRouter],
['/file-browser', fileBrowserRouter],
['/terminal', terminalRouter],
['/minesweeper', minesweeperRouter],
// ['/memos', memosRouter], // plugin — switched off 2026-08-13
['/app-store', appStoreRouter],
['/plugins', pluginsRouter],
+19
View File
@@ -328,6 +328,25 @@ const CORE_REGISTRY: Permission[] = [
ws: ['task-runner', 'pipeline'],
routes: ['/jobs'],
},
// Minesweeper X, on a virtual display, streamed to the browser over RFB.
//
// `execution` rather than `app`, and the reason is not that a game is dangerous. The wine process runs
// as the SERVER OWNER on the owner's display — so its File and Extras menus are file dialogs with the
// owner's home behind them, and "granted Minesweeper" would quietly mean "granted a file picker as the
// owner". `plugins/desktop` reached the same conclusion from the same premise.
//
// There is a second reason to keep it here for now: one display and one game means two viewers share a
// board and fight over the mouse. Per-viewer sessions would be a display and a wine prefix each, which
// is cheap for this but is a design decision rather than an implementation detail.
{
key: 'minesweeper',
label: 'Minesweeper',
description: 'A Windows Minesweeper session on a virtual display, as the server owner',
kind: 'execution',
api: ['/minesweeper'],
ws: ['minesweeper'],
routes: ['/minesweeper'],
},
{
key: 'items',
label: 'Permission authoring',
@@ -11,6 +11,7 @@ import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
import { appRegistryMetas as minesweeperMetas } from '../apps/Minesweeper';
import { appRegistryMetas as davMetas } from '../apps/Dav';
import { appRegistryMetas as commandHqMetas } from '../apps/CommandHQ';
import type { QueryClient } from '@tanstack/react-query';
@@ -28,6 +29,7 @@ export const apps = [
...widgetMetas,
...monitorMetas,
...qrTransferMetas,
...minesweeperMetas,
...davMetas,
...commandHqMetas,
...appStoreMetas,
@@ -0,0 +1,227 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Bomb, Loader2, Play, RefreshCw } from "lucide-react";
import { useClient } from "hooks/useClient";
import { RfbClient } from "./rfb";
type SessionStatus = {
installed: boolean;
running: boolean;
window: { width: number; height: number; x: number; y: number } | null;
};
// The framebuffer is bigger than the board — the board sits at 0,0 and the rest is empty desktop.
// Rendering into an offscreen canvas the size of the framebuffer and showing only the board's corner
// of it is what makes this look like a window rather than a small game adrift in grey.
const VISIBLE = { width: 164, height: 226 };
const BUTTON_MASK: Record<number, number> = { 0: 1, 1: 2, 2: 4 };
export const MinesweeperPanel = () => {
const client = useClient();
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const offscreenRef = useRef<HTMLCanvasElement | null>(null);
const rfbRef = useRef<RfbClient | null>(null);
const buttonsRef = useRef(0);
const geometryRef = useRef(VISIBLE);
const [status, setStatus] = useState<SessionStatus | null>(null);
const [error, setError] = useState<string | null>(null);
const [connected, setConnected] = useState(false);
const [busy, setBusy] = useState(false);
const adopt = useCallback((next: SessionStatus) => {
// The board is 152x214 on beginner and wider on the other difficulties. Taking the size from
// status means the canvas is right on the first paint instead of after the first poll.
if (next.window) geometryRef.current = { width: next.window.width, height: next.window.height };
setStatus(next);
}, []);
const refresh = useCallback(async () => {
try {
adopt(await client.get<SessionStatus>("/minesweeper/status"));
} catch {
setError("Could not reach the server.");
}
}, [client, adopt]);
useEffect(() => {
void refresh();
}, [refresh]);
const start = async () => {
setBusy(true);
setError(null);
try {
adopt(await client.post<SessionStatus>("/minesweeper/start", {}));
} catch {
setError("The session would not start. Is wine-minesweeper installed on the server?");
} finally {
setBusy(false);
}
};
// Paint the visible canvas from the offscreen one. Only the board's rectangle is copied, so the
// surrounding desktop never reaches the screen.
const present = useCallback(() => {
const canvas = canvasRef.current;
const offscreen = offscreenRef.current;
if (!canvas || !offscreen) return;
const context = canvas.getContext("2d");
if (!context) return;
const { width, height } = geometryRef.current;
if (canvas.width !== width || canvas.height !== height) {
canvas.width = width;
canvas.height = height;
}
context.drawImage(offscreen, 0, 0, width, height, 0, 0, width, height);
}, []);
useEffect(() => {
if (!status?.running) return;
const scheme = window.location.protocol === "https:" ? "wss" : "ws";
// 'BEARER_TOKEN' is the key the rest of the app uses (Terminal.tsx, useAudioRecording). Reading
// 'token' here sent an empty string, and upgradeWs answered 401 before any RFB byte was exchanged.
const token = localStorage.getItem("BEARER_TOKEN") ?? sessionStorage.getItem("BEARER_TOKEN") ?? "";
const url = `${scheme}://${window.location.host}/api/minesweeper/ws?token=${encodeURIComponent(token)}`;
const rfb = new RfbClient(url, {
onReady: (width, height) => {
const offscreen = document.createElement("canvas");
offscreen.width = width;
offscreen.height = height;
offscreenRef.current = offscreen;
setConnected(true);
setError(null);
},
onFrame: (image, x, y) => {
offscreenRef.current?.getContext("2d")?.putImageData(image, x, y);
present();
},
onCopyRect: (from, to) => {
const offscreen = offscreenRef.current;
const context = offscreen?.getContext("2d");
if (!offscreen || !context) return;
context.drawImage(offscreen, from.x, from.y, to.w, to.h, to.x, to.y, to.w, to.h);
present();
},
onResize: (width, height) => {
const offscreen = offscreenRef.current;
if (offscreen) {
offscreen.width = width;
offscreen.height = height;
}
rfb.requestUpdate(false);
},
onError: (message) => setError(message),
onClose: () => setConnected(false),
});
rfbRef.current = rfb;
return () => {
rfb.close();
rfbRef.current = null;
setConnected(false);
};
}, [status?.running, present]);
// The board resizes when the difficulty changes, and nothing in RFB announces that — the framebuffer
// stays the same size. Polling the window geometry is what keeps the visible canvas matched to it.
useEffect(() => {
if (!connected) return;
const timer = setInterval(async () => {
try {
const next = await client.get<SessionStatus>("/minesweeper/status");
if (!next.window) return;
const { width, height } = next.window;
if (width !== geometryRef.current.width || height !== geometryRef.current.height) {
geometryRef.current = { width, height };
present();
}
} catch {
/* a failed poll is not worth surfacing; the next one will do */
}
}, 4000);
return () => clearInterval(timer);
}, [connected, client, present]);
const toFramebuffer = (ev: React.MouseEvent<HTMLCanvasElement>) => {
const rect = ev.currentTarget.getBoundingClientRect();
const scaleX = ev.currentTarget.width / rect.width;
const scaleY = ev.currentTarget.height / rect.height;
return { x: (ev.clientX - rect.left) * scaleX, y: (ev.clientY - rect.top) * scaleY };
};
const onPointer = (ev: React.MouseEvent<HTMLCanvasElement>, kind: "down" | "up" | "move") => {
const rfb = rfbRef.current;
if (!rfb) return;
ev.preventDefault();
if (kind !== "move") {
const bit = BUTTON_MASK[ev.button] ?? 0;
buttonsRef.current = kind === "down" ? buttonsRef.current | bit : buttonsRef.current & ~bit;
}
const { x, y } = toFramebuffer(ev);
rfb.pointer(x, y, buttonsRef.current);
};
if (status && !status.installed) {
return (
<Centred>
<Bomb className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">Minesweeper is not installed on this server.</p>
<p className="max-w-sm text-center text-xs text-muted-foreground">
It needs <code>wine-minesweeper</code> beside the platform, with wine and a 32-bit prefix.
</p>
</Centred>
);
}
if (status && !status.running) {
return (
<Centred>
<Bomb className="size-8 text-muted-foreground" />
<p className="text-sm text-muted-foreground">The Minesweeper session is not running.</p>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
<button
type="button"
onClick={start}
disabled={busy}
className="flex items-center gap-2 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground disabled:opacity-60"
>
{busy ? <Loader2 className="size-4 animate-spin" /> : <Play className="size-4" />}
{busy ? "Starting" : "Start it"}
</button>
</Centred>
);
}
return (
<div className="flex size-full flex-col items-center justify-center gap-3 bg-muted/30 p-4">
<canvas
ref={canvasRef}
width={VISIBLE.width}
height={VISIBLE.height}
onMouseDown={(ev) => onPointer(ev, "down")}
onMouseUp={(ev) => onPointer(ev, "up")}
onMouseMove={(ev) => onPointer(ev, "move")}
onContextMenu={(ev) => ev.preventDefault()}
// Nearest-neighbour: this is a 16px-per-cell bitmap from 1995 and smoothing it looks like a
// photograph of a screen rather than the screen.
className="rounded-sm shadow-lg [image-rendering:pixelated]"
style={{ cursor: connected ? "default" : "progress" }}
/>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
<span>{connected ? "connected" : "connecting…"}</span>
<button type="button" onClick={() => void refresh()} className="flex items-center gap-1 hover:text-foreground">
<RefreshCw className="size-3" /> refresh
</button>
</div>
{error ? <p className="text-xs text-destructive">{error}</p> : null}
</div>
);
};
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex size-full flex-col items-center justify-center gap-3 p-6">{children}</div>
);
@@ -0,0 +1,13 @@
import type { AppRegistryMeta } from '../../AppRegistry';
import { Bomb } from 'lucide-react';
import { MinesweeperPanel } from './MinesweeperPanel';
export { MinesweeperPanel } from './MinesweeperPanel';
export { RfbClient } from './rfb';
// Minesweeper X on a virtual display, streamed over RFB. The client is ./rfb.ts — a few hundred lines
// rather than a vendored noVNC, because we control the server and need two encodings out of ten.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'minesweeper', name: 'Minesweeper', icon: Bomb, component: MinesweeperPanel },
];
@@ -0,0 +1,315 @@
/**
* A minimal RFB 3.8 client — enough to watch a framebuffer and click on it, and no more.
*
* ── Why this rather than noVNC ──
*
* noVNC is the obvious choice and it is a good library. It is also ~200 KB of vendored JavaScript for a
* job this file does in a few hundred lines, and vendoring is this repository's reluctant exception
* (hls.js, committed unminified with provenance hashes) rather than its habit. What we need is one
* server we control, one pixel format we choose, and two encodings. The rest of RFB — the eight other
* encodings, the compression, the authentication schemes, the clipboard, the audio — is surface we would
* carry and never execute.
*
* So: Raw and CopyRect, one pixel format, security type None. If this ever has to face a VNC server we
* do not configure, that is the moment to reach for noVNC instead of growing this.
*
* ── The pixel format ──
*
* The client DICTATES the format via SetPixelFormat rather than accepting the server's, so decoding is
* a memcpy into ImageData instead of a per-pixel shift-and-mask. We ask for 32bpp true colour with
* big-endian byte order, which puts the bytes on the wire as R,G,B,X — exactly the order a canvas
* ImageData buffer wants, so a Raw rectangle copies straight in.
*/
export type RfbEvents = {
onReady: (width: number, height: number) => void;
onFrame: (image: ImageData, x: number, y: number) => void;
onCopyRect: (from: { x: number; y: number }, to: { x: number; y: number; w: number; h: number }) => void;
onResize: (width: number, height: number) => void;
onError: (message: string) => void;
onClose: () => void;
};
const ENCODING_RAW = 0;
const ENCODING_COPY_RECT = 1;
const PSEUDO_ENCODING_DESKTOP_SIZE = -223;
type Phase = 'version' | 'security' | 'security-result' | 'init' | 'running';
export class RfbClient {
private socket: WebSocket;
private buffer = new Uint8Array(0);
private phase: Phase = 'version';
private width = 0;
private height = 0;
private events: RfbEvents;
private closed = false;
constructor(url: string, events: RfbEvents) {
this.events = events;
this.socket = new WebSocket(url);
this.socket.binaryType = 'arraybuffer';
this.socket.onmessage = (ev) => this.receive(new Uint8Array(ev.data as ArrayBuffer));
this.socket.onclose = () => {
this.closed = true;
this.events.onClose();
};
this.socket.onerror = () => this.events.onError('The connection to the Minesweeper session failed.');
}
close(): void {
this.closed = true;
try {
this.socket.close();
} catch {
/* already closed */
}
}
private send(bytes: Uint8Array): void {
if (this.socket.readyState === WebSocket.OPEN) this.socket.send(bytes);
}
private receive(chunk: Uint8Array): void {
const merged = new Uint8Array(this.buffer.length + chunk.length);
merged.set(this.buffer);
merged.set(chunk, this.buffer.length);
this.buffer = merged;
// Each step consumes a whole message or nothing at all, so a message split across TCP chunks simply
// waits for the rest. Returning false means "not enough bytes yet".
while (!this.closed && this.step()) {
/* keep draining */
}
}
private take(n: number): Uint8Array | null {
if (this.buffer.length < n) return null;
const head = this.buffer.subarray(0, n);
this.buffer = this.buffer.subarray(n);
return head;
}
private step(): boolean {
switch (this.phase) {
case 'version':
return this.stepVersion();
case 'security':
return this.stepSecurity();
case 'security-result':
return this.stepSecurityResult();
case 'init':
return this.stepInit();
case 'running':
return this.stepMessage();
}
}
private stepVersion(): boolean {
const head = this.take(12);
if (!head) return false;
// Answer 3.8 regardless of what the server offered; x11vnc speaks it and anything older is a server
// we are not configuring.
this.send(new TextEncoder().encode('RFB 003.008\n'));
this.phase = 'security';
return true;
}
private stepSecurity(): boolean {
if (this.buffer.length < 1) return false;
const count = this.buffer[0] as number;
if (count === 0) {
// A zero count means the server is refusing us, and the reason follows as a string.
const body = this.take(1 + 4);
if (!body) return false;
this.events.onError('The VNC server refused the connection.');
this.close();
return false;
}
const head = this.take(1 + count);
if (!head) return false;
const types = Array.from(head.subarray(1));
if (!types.includes(1)) {
this.events.onError('The VNC server wants authentication, which this client does not implement.');
this.close();
return false;
}
this.send(new Uint8Array([1])); // None — the relay is loopback-only and Officer is the authenticator
this.phase = 'security-result';
return true;
}
private stepSecurityResult(): boolean {
const head = this.take(4);
if (!head) return false;
if (new DataView(head.buffer, head.byteOffset, 4).getUint32(0) !== 0) {
this.events.onError('The VNC server rejected the handshake.');
this.close();
return false;
}
this.send(new Uint8Array([1])); // shared: do not disconnect other viewers
this.phase = 'init';
return true;
}
private stepInit(): boolean {
if (this.buffer.length < 24) return false;
const nameLength = new DataView(this.buffer.buffer, this.buffer.byteOffset + 20, 4).getUint32(0);
const head = this.take(24 + nameLength);
if (!head) return false;
const view = new DataView(head.buffer, head.byteOffset, head.byteLength);
this.width = view.getUint16(0);
this.height = view.getUint16(2);
this.setPixelFormat();
this.setEncodings();
this.phase = 'running';
this.events.onReady(this.width, this.height);
this.requestUpdate(false);
return true;
}
/** 32bpp true colour, big-endian, shifts 16/8/0 — i.e. R,G,B,X on the wire, which is ImageData order. */
private setPixelFormat(): void {
const message = new Uint8Array(20);
const view = new DataView(message.buffer);
message[0] = 0; // SetPixelFormat
message[4] = 32; // bits per pixel
message[5] = 24; // depth
message[6] = 1; // big-endian
message[7] = 1; // true colour
view.setUint16(8, 255); // red max
view.setUint16(10, 255); // green max
view.setUint16(12, 255); // blue max
message[14] = 16; // red shift
message[15] = 8; // green shift
message[16] = 0; // blue shift
this.send(message);
}
private setEncodings(): void {
const encodings = [ENCODING_COPY_RECT, ENCODING_RAW, PSEUDO_ENCODING_DESKTOP_SIZE];
const message = new Uint8Array(4 + encodings.length * 4);
const view = new DataView(message.buffer);
message[0] = 2; // SetEncodings
view.setUint16(2, encodings.length);
encodings.forEach((encoding, i) => view.setInt32(4 + i * 4, encoding));
this.send(message);
}
requestUpdate(incremental = true): void {
const message = new Uint8Array(10);
const view = new DataView(message.buffer);
message[0] = 3; // FramebufferUpdateRequest
message[1] = incremental ? 1 : 0;
view.setUint16(6, this.width);
view.setUint16(8, this.height);
this.send(message);
}
private stepMessage(): boolean {
if (this.buffer.length < 1) return false;
const type = this.buffer[0] as number;
// 0 FramebufferUpdate, 1 SetColourMapEntries, 2 Bell, 3 ServerCutText. Only the first matters here;
// the others still have to be CONSUMED or the stream desynchronises and every later frame is garbage.
if (type === 0) return this.stepFramebufferUpdate();
if (type === 2) return this.take(1) !== null;
if (type === 1) {
if (this.buffer.length < 6) return false;
const count = new DataView(this.buffer.buffer, this.buffer.byteOffset + 4, 2).getUint16(0);
return this.take(6 + count * 6) !== null;
}
if (type === 3) {
if (this.buffer.length < 8) return false;
const length = new DataView(this.buffer.buffer, this.buffer.byteOffset + 4, 4).getUint32(0);
return this.take(8 + length) !== null;
}
this.events.onError(`The VNC server sent an unknown message type (${type}).`);
this.close();
return false;
}
/**
* Parsed without consuming anything until the WHOLE update is present. A partial rectangle cannot be
* drawn and cannot be put back, so the alternative is a second parser for resumed state.
*/
private stepFramebufferUpdate(): boolean {
if (this.buffer.length < 4) return false;
const rectangles = new DataView(this.buffer.buffer, this.buffer.byteOffset + 2, 2).getUint16(0);
let offset = 4;
type Rect = { x: number; y: number; w: number; h: number; encoding: number; at: number };
const rects: Rect[] = [];
for (let i = 0; i < rectangles; i++) {
if (this.buffer.length < offset + 12) return false;
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset + offset, 12);
const rect = { x: view.getUint16(0), y: view.getUint16(2), w: view.getUint16(4), h: view.getUint16(6), encoding: view.getInt32(8), at: offset + 12 };
offset += 12;
if (rect.encoding === ENCODING_RAW) offset += rect.w * rect.h * 4;
else if (rect.encoding === ENCODING_COPY_RECT) offset += 4;
else if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) {
/* no payload — the rect's w/h ARE the new size */
} else {
this.events.onError(`The VNC server used encoding ${rect.encoding}, which this client did not ask for.`);
this.close();
return false;
}
if (this.buffer.length < offset) return false;
rects.push(rect);
}
for (const rect of rects) {
if (rect.encoding === PSEUDO_ENCODING_DESKTOP_SIZE) {
this.width = rect.w;
this.height = rect.h;
this.events.onResize(rect.w, rect.h);
continue;
}
if (rect.encoding === ENCODING_COPY_RECT) {
const src = new DataView(this.buffer.buffer, this.buffer.byteOffset + rect.at, 4);
this.events.onCopyRect({ x: src.getUint16(0), y: src.getUint16(2) }, rect);
continue;
}
// Raw: the wire bytes are already R,G,B,X, so this is a copy rather than a conversion. The alpha
// byte is whatever the server sent, so force it opaque.
const pixels = new Uint8ClampedArray(this.buffer.subarray(rect.at, rect.at + rect.w * rect.h * 4));
for (let i = 3; i < pixels.length; i += 4) pixels[i] = 255;
this.events.onFrame(new ImageData(pixels, rect.w, rect.h), rect.x, rect.y);
}
this.take(offset);
// One request per update message, issued once the whole message is drawn. Requesting per
// rectangle would send several for one update, and requesting on a timer would queue updates the
// client has not consumed yet — the classic way a VNC view ends up lagging behind the pointer.
this.requestUpdate(true);
return true;
}
/** buttons is an RFB button mask: bit 0 left, bit 1 middle, bit 2 right. */
pointer(x: number, y: number, buttons: number): void {
const message = new Uint8Array(6);
const view = new DataView(message.buffer);
message[0] = 5; // PointerEvent
message[1] = buttons;
view.setUint16(2, Math.max(0, Math.min(this.width - 1, Math.round(x))));
view.setUint16(4, Math.max(0, Math.min(this.height - 1, Math.round(y))));
this.send(message);
}
key(keysym: number, down: boolean): void {
const message = new Uint8Array(8);
const view = new DataView(message.buffer);
message[0] = 4; // KeyEvent
message[1] = down ? 1 : 0;
view.setUint32(4, keysym);
this.send(message);
}
}