Files
minesweeper/web/rfb.ts
T
pastilhasandClaude Opus 5 f3cc1242c4 minesweeper: extracted from the platform
Minesweeper X under wine on a headless display, as a plugin. It was
/api/minesweeper, a Dashboard screen, an officerdev panel, a permission
registry entry and a hand-written websocket provider in server.tsx.

The socket is why it had to live in the platform: Bun's route table is fixed
at serve(), so nothing installed later could add one. That ceiling is gone,
and this is the first plugin through the per-plugin relay.

Three things changed rather than moved:

The sidecar owns the session. The platform used to spawn bin/session.sh
detached, so the game outlived every restart, nothing supervised it, and a
crashed run left wine processes holding the prefix — the next start hung
rather than failed. PM2 supervises a sidecar, and stop() uses 'wineserver -k'
scoped by WINEPREFIX instead of 'pkill -f wineserver', which would have killed
every other wine prefix on the machine.

x11vnc moved from 127.0.0.1:5907 to a unix socket in a mode-0700 directory.
-nopw was only ever safe because of the machine, not the platform: any local
account could open that port and take the display. A fixed port is squattable
too. There is now no listener to authenticate against.

The wine prefix is confined to C:. This is the one that mattered. The platform
classified Minesweeper as kind:'execution' — never grantable — because a fresh
prefix maps the whole filesystem to Z: and symlinks the profile into the
owner's real home, and the game has Open and Save dialogs. A plugin permission
has no kind and is always grantable, so extracting it without this would have
converted a sealed capability into a file picker running as the owner.
Re-asserted on every start, because wine restores z: whenever it updates a
prefix.

Not solved: one display and one game, so two grantees share a board.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
2026-08-23 17:43:17 +00:00

342 lines
12 KiB
TypeScript

/**
* 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: (error: RfbError) => void;
onClose: () => void;
};
/**
* What went wrong, as a code rather than a sentence.
*
* A protocol client has no business holding user-facing prose: it cannot reach a React hook, and the
* words belong to whoever is rendering them. The panel maps each code to a catalog key — an explicit
* map from a closed union to literal keys, which is what the naming convention asks for instead of
* building a key at runtime.
*/
export type RfbError =
| { code: 'connectionFailed' }
| { code: 'refused' }
| { code: 'authUnsupported' }
| { code: 'handshakeRejected' }
| { code: 'unknownMessage'; type: number }
| { code: 'unknownEncoding'; encoding: number };
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({ code: 'connectionFailed' });
}
close(): void {
this.closed = true;
try {
this.socket.close();
} catch {
/* already closed */
}
}
// Uint8Array<ArrayBuffer>, not plain Uint8Array: the default is Uint8Array<ArrayBufferLike>, whose
// buffer may be a SharedArrayBuffer, and WebSocket.send takes only BufferSource. Every caller here
// builds its own array, so narrowing costs nothing. The terminal relay narrows the same way.
private send(bytes: Uint8Array<ArrayBuffer>): 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({ code: 'refused' });
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({ code: 'authUnsupported' });
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({ code: 'handshakeRejected' });
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({ code: 'unknownMessage', 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({ code: 'unknownEncoding', encoding: rect.encoding });
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);
}
}