/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:
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user