/** * 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, * big-endian, with red/green/blue shifts of 24/16/8. That makes the pixel value 0xRRGGBB00, which * big-endian puts on the wire as R,G,B,X — exactly the order a canvas ImageData buffer wants, so a Raw * rectangle copies straight in. * * The shifts are the load-bearing part, and 16/8/0 is the tempting wrong answer: it is the familiar * 0x00RRGGBB, and big-endian then sends X,R,G,B. Copied into ImageData that moves every channel one * place along and drops blue — grey renders cyan, red renders green, blue renders black. The first * alpha shipped that way, with this comment already claiming otherwise. */ 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, not plain Uint8Array: the default is Uint8Array, 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): 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 24/16/8 — 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] = 24; // red shift message[15] = 16; // green shift message[16] = 8; // 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); } /** * KeyEvent. The keysym is X11's, not a browser keycode — see `keysymFor`. * * Modifiers are not sent alongside: x11vnc resolves a keysym to a keycode and presses Shift itself if * the layout needs it, so sending the symbol for `A` produces `A` without us tracking Shift. */ 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); } } /** * A browser `KeyboardEvent.key` as an X11 keysym, or null for keys the game has no use for. * * `key` rather than `code`, because it is already the CHARACTER the layout produced — the shifted one, * the accented one, the one from the user's own keyboard map. `code` is a physical position and would * need the layout re-implemented here to get back to a symbol. * * Latin-1 maps straight through: for a single printable character the keysym IS the code point. Above * that, X uses a 0x01000000 offset for Unicode, which is what the last branch does. Everything else is * a named key from `keysymdef.h`. * * Returning null is how a key stays the BROWSER's: unmapped keys are not swallowed by the panel. */ const NAMED_KEYSYMS: Record = { Backspace: 0xff08, Tab: 0xff09, Enter: 0xff0d, Escape: 0xff1b, Delete: 0xffff, Home: 0xff50, End: 0xff57, PageUp: 0xff55, PageDown: 0xff56, ArrowLeft: 0xff51, ArrowUp: 0xff52, ArrowRight: 0xff53, ArrowDown: 0xff54, Insert: 0xff63, }; /* MODIFIERS ARE DELIBERATELY ABSENT, AND THAT IS A FIX RATHER THAN AN OMISSION. They were here, and it stranded `Shift_L` in the down state on the server: press shift, release it while the canvas no longer has focus, and the keyup never arrives. Every left click then reached the game as shift-click, which reads as a right click — the board flagged instead of opening and nothing about the panel looked wrong. They are not needed. `ev.key` is the character the layout ALREADY produced, so a shifted key arrives as `A` and x11vnc presses shift itself to type it. Sending our own shift on top was redundant on the good path and load-bearing on the bad one. `keysymFor` returning null for them also means they stay the browser's, which is what you want: shift-tab should still move focus out of a game of Minesweeper. */ export function keysymFor(key: string): number | null { const named = NAMED_KEYSYMS[key]; if (named !== undefined) return named; // F1–F24 are contiguous from 0xffbe. const fn = /^F(\d{1,2})$/.exec(key); if (fn) { const n = Number(fn[1]); return n >= 1 && n <= 24 ? 0xffbd + n : null; } // Anything longer than one character here is a key we have no mapping for — leave it to the browser. if ([...key].length !== 1) return null; const code = key.codePointAt(0); if (code === undefined) return null; return code <= 0xff ? code : 0x01000000 + code; }