SetPixelFormat asked for 32bpp big-endian with shifts 16/8/0, which is the pixel 0x00RRGGBB and puts X,R,G,B on the wire. The Raw path copies four bytes straight into ImageData and forces alpha opaque, so every channel landed one place along and blue was dropped: window grey rendered cyan, the red LED digits rendered green, blue "1" cells and the title bar rendered black. Shifts are now 24/16/8, making the pixel 0xRRGGBB00 and the wire R,G,B,X — which is what the file already claimed, in two comments, since it was written. Verified against the live x11vnc socket rather than by reading: probing the same framebuffer under both formats returns `00 c0 c0 c0` for window grey with the old shifts and `c0 c0 c0 00` with the new ones, and `00 ff 00 00` vs `ff 00 00 00` for the digits. x11vnc honours a shift of 24 without complaint. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Officer-Session: de3ed4e6-f88a-429c-94e6-a760336ba83b
348 lines
13 KiB
TypeScript
348 lines
13 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,
|
|
* 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<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 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);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|