hyperhive/frontend/packages/agent/src/screen.html
iris e70584b632 feat(frontend): split theme CSS vars into a standalone theme.css
Phase 1 of stylix integration (#1457): extract the Catppuccin palette
into a dedicated, separately-linked stylesheet so a theme swap can
replace just that file without rebuilding the rest of the frontend.

- shared/src/theme.css (new): the `:root` palette, moved out of
  base.css (which now holds only the body typography it references).
- shared/package.json: export `./theme.css`.
- dashboard/src/theme.css + agent/src/theme.css (new): one-line
  re-exports of @hive/shared/theme.css so each package's esbuild emits
  its own standalone `dist/static/theme.css` (palette is NOT inlined
  into the page bundles).
- both build.mjs: add theme.css to the CSS build list.
- every page (dashboard index/flow/logs, agent index/stats/screen):
  link `theme.css` first, ahead of the page CSS, so the `:root` vars
  resolve for everything.
- docs/web-ui/css-vars.md: document the split + the no-rebuild rationale.

Behaviour-neutral — same colours, just relocated. Verified both
`npm run build` outputs: theme.css emits standalone (383b) with the
palette; no `--*` palette defs duplicated into common.css/agent.css.

Phase 2 (nix derivation that swaps theme.css from stylix colours) is a
follow-up; touches nix/frontend.nix, coordinating with damocles.

Part of #1457.
2026-06-06 08:46:45 +02:00

698 lines
28 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>screen</title>
<link rel="icon" type="image/svg+xml" href="icon">
<link rel="stylesheet" href="static/theme.css">
<link rel="stylesheet" href="static/agent.css">
</head>
<body class="screen-shell">
<div id="toolbar">
<strong>🖥 screen</strong>
<a href="./" title="back to agent page">← agent</a>
<button id="fit-toggle" class="tbtn" title="Toggle fit-to-window scaling">⤢ fit</button>
<button id="match-toggle" class="tbtn" title="Resize the remote desktop to fit this window" disabled>⤡ match size</button>
<button id="debug-toggle" class="tbtn" title="Toggle RFB debug log">debug</button>
<span id="status">connecting…</span>
</div>
<div id="canvas-wrap"><canvas id="c"></canvas></div>
<div id="msg"></div>
<div id="debug-log"></div>
<script>
// Minimal RFB-over-WebSocket renderer. Connects to `screen/ws` on
// the same host; the harness relays raw RFB bytes to the VNC server
// running inside the container. See docs/web-ui.md::Per-agent
// endpoints (GET /screen) for the design rationale + canvas-sizing
// implementation notes.
(function () {
'use strict';
const canvas = document.getElementById('c');
const ctx = canvas.getContext('2d');
const status = document.getElementById('status');
const msg = document.getElementById('msg');
const debugLog = document.getElementById('debug-log');
const debugBtn = document.getElementById('debug-toggle');
const fitBtn = document.getElementById('fit-toggle');
const matchBtn = document.getElementById('match-toggle');
const canvasWrap = document.getElementById('canvas-wrap');
// --- Debug log ---
let debugVisible = false;
debugBtn.addEventListener('click', () => {
debugVisible = !debugVisible;
debugLog.style.display = debugVisible ? 'block' : 'none';
debugBtn.classList.toggle('active', debugVisible);
});
// --- Fit-to-window toggle ---
// See docs/web-ui.md::Per-agent endpoints (GET /screen) for the
// localStorage persistence + canvas-intrinsic-resolution +
// pointer-rescale model.
let fitMode = localStorage.getItem('screen-fit') !== 'off';
// Scale down (never up) to the wrap, preserving aspect ratio.
// Explicit px sizing — see GET /screen docs for the flex-item
// min-width:auto clamp this avoids.
function relayoutCanvas() {
if (fitMode && canvas.width && canvas.height
&& canvasWrap.clientWidth && canvasWrap.clientHeight) {
const scale = Math.min(
canvasWrap.clientWidth / canvas.width,
canvasWrap.clientHeight / canvas.height,
1,
);
canvas.style.width = (canvas.width * scale) + 'px';
canvas.style.height = (canvas.height * scale) + 'px';
} else if (!fitMode) {
canvas.style.width = '';
canvas.style.height = '';
}
}
function applyFitMode() {
canvasWrap.classList.toggle('fit', fitMode);
fitBtn.classList.toggle('active', fitMode);
relayoutCanvas();
}
fitBtn.addEventListener('click', () => {
fitMode = !fitMode;
localStorage.setItem('screen-fit', fitMode ? 'on' : 'off');
applyFitMode();
});
window.addEventListener('resize', relayoutCanvas);
applyFitMode();
// --- Match-size: resize the remote desktop to this window ---
// Sends an RFB SetDesktopSize request so the VNC server (weston)
// changes its actual output resolution to match the browser
// viewport — sharper than fit-mode's CSS downscale. Gated on the
// server's ExtendedDesktopSize advert; see docs/web-ui.md::Per-agent
// endpoints (GET /screen).
let extDesktopSupported = false;
let screenId = 1; // captured from the server's ExtendedDesktopSize advert
matchBtn.addEventListener('click', () => {
if (!extDesktopSupported) return;
// Even dimensions — some servers reject odd ones.
const w = Math.max(2, canvasWrap.clientWidth & ~1);
const h = Math.max(2, canvasWrap.clientHeight & ~1);
dbg('→ request desktop resize to ' + w + 'x' + h, 'send');
sendSetDesktopSize(w, h);
});
function hex(bytes) {
return Array.from(bytes).map(b => b.toString(16).padStart(2,'0')).join(' ');
}
function dbg(text, cls) {
console.log('[rfb]', text);
const line = document.createElement('div');
line.className = 'dbg-line' + (cls ? ' ' + cls : '');
line.textContent = text;
debugLog.appendChild(line);
debugLog.scrollTop = debugLog.scrollHeight;
}
function setStatus(text, cls) {
status.textContent = text;
status.className = cls || '';
if (cls === 'error') dbg('ERROR: ' + text, 'err');
}
function flash(text) {
msg.textContent = text;
msg.style.opacity = '1';
setTimeout(() => { msg.style.opacity = '0'; }, 2500);
}
// --- WebSocket connection ---
// Path-relative URL — `document.baseURI` resolves against the
// page's URL so the agent page mounted under a nginx prefix
// (e.g. /agent/<name>/screen) still hits the right upstream.
// Swap protocol on top gives ws(s)://host/<prefix>/screen/ws.
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const wsUrl = new URL('screen/ws', document.baseURI);
wsUrl.protocol = proto + ':';
const ws = new WebSocket(wsUrl.toString());
ws.binaryType = 'arraybuffer';
ws.onopen = () => { dbg('WebSocket open — starting RFB handshake', 'ok'); setStatus('handshaking…'); };
ws.onerror = () => setStatus('connection error', 'error');
ws.onclose = (e) => {
setStatus(`disconnected (${e.code})`, 'error');
flash('VNC disconnected — reload to reconnect');
};
// Accumulate received bytes in a simple ring queue
const chunks = [];
let totalBytes = 0;
ws.onmessage = (ev) => {
chunks.push(new Uint8Array(ev.data));
totalBytes += ev.data.byteLength;
processRfb();
};
// --- Minimal RFB state machine ---
// We implement just enough to handshake and receive FramebufferUpdate
// rectangles encoded as Raw (encoding 0). Other encodings are skipped.
// Keyboard and pointer events are forwarded.
let state = 'version';
let fbW = 0, fbH = 0;
let pixelFormat = null; // set after ServerInit
let updateRects = 0;
// ExtendedDesktopSize pseudo-encoding (-308), as the unsigned 32-bit
// value the rect-header encoding field is read as.
const EXT_DESKTOP_SIZE_U32 = (-308) >>> 0;
// Drain bytes from the queue into a flat buffer view
function drainTo(n) {
if (totalBytes < n) return null;
const out = new Uint8Array(n);
let off = 0;
while (off < n) {
const c = chunks[0];
const take = Math.min(c.length, n - off);
out.set(c.subarray(0, take), off);
off += take;
if (take === c.length) {
chunks.shift();
} else {
chunks[0] = c.subarray(take);
}
}
totalBytes -= n;
return out;
}
function send(data) {
if (ws.readyState === WebSocket.OPEN) {
const arr = data instanceof Uint8Array ? data : new Uint8Array(data);
dbg('→ send [' + arr.length + 'b]: ' + hex(arr.slice(0, 32)) + (arr.length > 32 ? '…' : ''), 'send');
ws.send(data);
}
}
function u32be(b, o) { return ((b[o]<<24)|(b[o+1]<<16)|(b[o+2]<<8)|b[o+3])>>>0; }
function u16be(b, o) { return ((b[o]<<8)|b[o+1])>>>0; }
// ── Apple-DH (security type 30) helpers ─────────────────────────────────
// Protocol (from neatvnc apple-dh.c):
// Server → client: generator(2) + key_size(2) + prime[key_size] + server_pub[key_size]
// Client → server: client_pub[key_size] + aes128ecb(MD5(shared_secret), creds[128])
// After SecurityResult=0: normal plaintext VNC (no session encryption)
//
// BigInt mod-pow — handles 2048-bit DH arithmetic.
function modpow(base, exp, mod) {
let r = 1n;
base = base % mod;
while (exp > 0n) {
if (exp & 1n) r = r * base % mod;
exp >>= 1n;
base = base * base % mod;
}
return r;
}
function bytesToBigInt(b) {
let n = 0n;
for (const byte of b) n = (n << 8n) | BigInt(byte);
return n;
}
function bigIntToBytes(n, len) {
const out = new Uint8Array(len);
for (let i = len - 1; i >= 0; i--) { out[i] = Number(n & 0xffn); n >>= 8n; }
return out;
}
// Compact MD5 — needed because Web Crypto doesn't expose MD5.
// Based on the RFC 1321 reference implementation, minified.
function md5(data) {
const b = data instanceof Uint8Array ? data : new Uint8Array(data);
const len = b.length;
// pad message
const padLen = ((len + 8) >>> 6 << 4) + 16;
const m = new Uint32Array(padLen);
for (let i = 0; i < len; i++) m[i>>2] |= b[i] << ((i&3)*8);
m[len>>2] |= 0x80 << ((len&3)*8);
m[padLen-2] = len*8;
const T = new Int32Array(64);
for (let i = 0; i < 64; i++) T[i] = (Math.abs(Math.sin(i+1)) * 0x100000000)|0;
let [a, b2, c, d] = [0x67452301, 0xefcdab89, 0x98badcfe, 0x10325476];
const S = [7,12,17,22, 5,9,14,20, 4,11,16,23, 6,10,15,21];
function add(x,y){return (x+y)|0;}
function r(v,s){return (v<<s)|(v>>>(32-s));}
for (let i = 0; i < padLen; i += 16) {
let [aa,bb,cc,dd] = [a,b2,c,d];
for (let j = 0; j < 64; j++) {
let [f, g] = j<16 ? [(bb&cc)|((~bb)&dd), j]
: j<32 ? [(dd&bb)|((~dd)&cc), (5*j+1)%16]
: j<48 ? [bb^cc^dd, (3*j+5)%16]
: [cc^(bb|(~dd)), (7*j)%16];
f = add(add(aa, f), add(m[i+g], T[j]));
// Rotation amount: round = j>>4 (changes every 16 steps),
// position-in-round = j%4. S is laid out as 4 rounds × 4.
[aa,dd,cc,bb] = [dd, cc, bb, add(bb, r(f, S[(j%4)+((j>>4)*4)]))];
}
[a,b2,c,d] = [add(a,aa), add(b2,bb), add(c,cc), add(d,dd)];
}
const out = new Uint8Array(16);
[a,b2,c,d].forEach((x,i) => {
out[i*4]=(x)&0xff; out[i*4+1]=(x>>8)&0xff;
out[i*4+2]=(x>>16)&0xff; out[i*4+3]=(x>>24)&0xff;
});
return out;
}
// AES-128-ECB encrypt 128 bytes using Web Crypto AES-CBC (null IV per block = ECB).
async function aes128ecb(key16, data128) {
const keyObj = await crypto.subtle.importKey('raw', key16, {name:'AES-CBC'}, false, ['encrypt']);
const out = new Uint8Array(128);
const iv = new Uint8Array(16); // zeroed IV → ECB mode for single blocks
for (let i = 0; i < 8; i++) {
const enc = await crypto.subtle.encrypt({name:'AES-CBC',iv}, keyObj, data128.slice(i*16,(i+1)*16));
out.set(new Uint8Array(enc,0,16), i*16);
}
return out;
}
// Apple-DH state — stored between async continuations.
let appleDhState = null;
// ────────────────────────────────────────────────────────────────────────
function processRfb() {
while (true) {
if (!tryStep()) break;
}
}
function tryStep() {
switch (state) {
case 'version': {
const b = drainTo(12);
if (!b) return false;
dbg('← server version: ' + new TextDecoder().decode(b).replace('\n','\\n'));
// Send back same version (RFB 003.008)
send(new TextEncoder().encode('RFB 003.008\n'));
state = 'security-types';
return true;
}
case 'security-types': {
const b = drainTo(1);
if (!b) return false;
const n = b[0];
dbg('← security-types: count=' + n + (n === 0 ? ' (server error!)' : ''));
if (n === 0) { setStatus('server sent 0 security types', 'error'); return false; }
const types = drainTo(n);
if (!types) { chunks.unshift(b); totalBytes += 1; return false; }
dbg('← security-types offered: [' + Array.from(types).join(', ') + ']');
// Prefer type 1 (None), then type 19 (VeNCrypt — used by neatvnc/weston
// even with --disable-transport-layer-security), else first offered.
let prefer;
if (types.indexOf(1) !== -1) prefer = 1; // plain None
else if (types.indexOf(19) !== -1) prefer = 19; // VeNCrypt
else prefer = types[0];
// Prefer: 1 (None) → 19 (VeNCrypt) → 30 (Apple-DH)
if (types.indexOf(1) !== -1) prefer = 1;
else if (types.indexOf(19) !== -1) prefer = 19;
else if (types.indexOf(30) !== -1) prefer = 30;
else {
dbg('no supported type in [' + Array.from(types).join(', ') + '] — need 1, 19, or 30', 'err');
setStatus('unsupported security types: [' + Array.from(types).join(', ') + ']', 'error');
ws.close();
return false;
}
dbg('→ choosing security type ' + prefer +
(prefer === 1 ? ' (None)' : prefer === 19 ? ' (VeNCrypt)' : ' (Apple-DH)'));
send(new Uint8Array([prefer]));
if (prefer === 1) state = 'security-result';
else if (prefer === 19) state = 'vencrypt-version';
else state = 'apple-dh-params';
return true;
}
case 'security-vnc-challenge': {
// VNC auth (type 2): we don't have the password, so send zeros.
// This will fail for password-protected servers; fine for our
// weston VNC which uses None via VeNCrypt.
const b = drainTo(16);
if (!b) return false;
dbg('← vnc-challenge (16 bytes): ' + hex(b));
send(new Uint8Array(16));
state = 'security-result';
return true;
}
// ── VeNCrypt (type 19) sub-handshake ───────────────────────────────
// neatvnc (weston VNC backend) uses VeNCrypt as the outer type even
// with --disable-transport-layer-security, offering sub-type 1 (None).
case 'vencrypt-version': {
// Server sends: major (u8), minor (u8) — e.g. 0, 2
const b = drainTo(2);
if (!b) return false;
dbg('← VeNCrypt version: ' + b[0] + '.' + b[1]);
// Echo same version back
send(new Uint8Array([b[0], b[1]]));
state = 'vencrypt-subtypes';
return true;
}
case 'vencrypt-subtypes': {
// Server sends: nSubtypes (u8), then nSubtypes × u32 sub-type ids
const nb = drainTo(1);
if (!nb) return false;
const nSub = nb[0];
dbg('← VeNCrypt nSubtypes=' + nSub);
const raw = drainTo(nSub * 4);
if (!raw) { chunks.unshift(nb); totalBytes += 1; return false; }
// Build sub-type array from big-endian u32s
const subs = [];
for (let i = 0; i < nSub; i++) subs.push(u32be(raw, i * 4));
dbg('← VeNCrypt sub-types: [' + subs.join(', ') + ']');
// Prefer sub-type 1 (VeNCrypt None) — no TLS, no password.
// Fall back to first offered.
const sub = subs.includes(1) ? 1 : subs[0];
dbg('→ choosing VeNCrypt sub-type ' + sub);
// Send chosen sub-type as big-endian u32
send(new Uint8Array([sub>>>24, (sub>>>16)&0xff, (sub>>>8)&0xff, sub&0xff]));
state = 'vencrypt-accept';
return true;
}
case 'vencrypt-accept': {
// Server sends 1 byte: 1=accepted, 0=refused
const b = drainTo(1);
if (!b) return false;
dbg('← VeNCrypt accept byte: ' + b[0] + (b[0] === 1 ? ' (ok)' : ' (REFUSED)'));
if (b[0] !== 1) { setStatus('VeNCrypt sub-type refused', 'error'); return false; }
// Sub-type 1 (None): proceed to SecurityResult
state = 'security-result';
return true;
}
// ── Apple-DH (type 30) ────────────────────────────────────────────
// Server sends: generator(2 BE) + key_size(2 BE) + prime[key_size] +
// server_pub[key_size]
// Client sends: client_pub[key_size] + aes128ecb(MD5(shared), creds[128])
// No session encryption after auth — plain RFB follows.
case 'apple-dh-params': {
const hdr = drainTo(4);
if (!hdr) return false;
const generator = u16be(hdr, 0);
const keySize = u16be(hdr, 2);
dbg('← Apple-DH: generator=' + generator + ' key_size=' + keySize);
const rest = drainTo(keySize * 2);
if (!rest) { chunks.unshift(hdr); totalBytes += 4; return false; }
const prime = rest.slice(0, keySize);
const serverPub = rest.slice(keySize);
dbg('← Apple-DH prime[0:4]=' + hex(prime.slice(0,4)) +
' server_pub[0:4]=' + hex(serverPub.slice(0,4)));
// Async DH computation — pause state machine, resume when done.
appleDhState = { generator, keySize, prime, serverPub };
state = 'apple-dh-wait';
(async () => {
try {
const p = bytesToBigInt(appleDhState.prime);
const g = BigInt(appleDhState.generator);
const ks = appleDhState.keySize;
// Generate client private key: random ks bytes, then mod p
const privBytes = crypto.getRandomValues(new Uint8Array(ks));
const priv = bytesToBigInt(privBytes) % p;
// Client public key = g ^ priv mod p
const clientPub = modpow(g, priv, p);
const clientPubBytes = bigIntToBytes(clientPub, ks);
// Shared secret = server_pub ^ priv mod p
const serverPubInt = bytesToBigInt(appleDhState.serverPub);
const shared = modpow(serverPubInt, priv, p);
const sharedBytes = bigIntToBytes(shared, ks);
// AES key = MD5(shared_secret)
const aesKey = md5(sharedBytes);
dbg('Apple-DH: shared MD5 key=' + hex(aesKey));
// Credentials: 64 bytes username + 64 bytes password.
// weston's vnc_handle_auth (libweston/backend-vnc/vnc.c) does
// getpwnam(username) and requires pw_uid == weston's own uid
// BEFORE PAM is ever consulted — an empty/garbage username is
// rejected outright. weston runs as root, so the username must
// be "root". The password stays empty; pam_permit.so on the
// weston-remote-access PAM service accepts it.
const creds = new Uint8Array(128);
creds.set(new TextEncoder().encode('root'), 0);
const encCreds = await aes128ecb(aesKey, creds);
// Send: encrypted_creds + client_pub
// neatvnc struct rfb_apple_dh_client_msg has encrypted_credentials
// at offset 0 and public_key at offset 128 (flexible array after).
const response = new Uint8Array(ks + 128);
response.set(encCreds, 0);
response.set(clientPubBytes, 128);
send(response);
dbg('→ Apple-DH response sent (' + response.length + ' bytes)', 'ok');
state = 'security-result';
processRfb(); // resume state machine
} catch(e) {
setStatus('Apple-DH error: ' + e.message, 'error');
}
})();
return false; // pause — async resumes
}
case 'apple-dh-wait':
// Async handshake in progress — don't consume bytes
return false;
// ──────────────────────────────────────────────────────────────────
case 'security-result': {
const b = drainTo(4);
if (!b) return false;
const code = u32be(b, 0);
dbg('← security-result: ' + code + ' (bytes: ' + hex(b) + ')' + (code === 0 ? ' ✓' : ' FAIL'), code === 0 ? 'ok' : 'err');
if (code !== 0) { setStatus('auth failed (code ' + code + ')', 'error'); return false; }
// ClientInit: shared flag = 1
send(new Uint8Array([1]));
state = 'server-init';
return true;
}
case 'server-init': {
const b = drainTo(24);
if (!b) return false;
// RFB ServerInit: width @ bytes 0-1, height @ bytes 2-3.
fbW = u16be(b, 0); fbH = u16be(b, 2);
// pixel format: bpp=b[4], depth=b[5], big-endian=b[6], true-colour=b[7]
// red/green/blue max/shift at b[8..17]
pixelFormat = {
bpp: b[4], depth: b[5], bigEndian: b[6], trueColour: b[7],
redMax: u16be(b, 8), greenMax: u16be(b, 10), blueMax: u16be(b, 12),
redShift: b[14], greenShift: b[15], blueShift: b[16],
bytesPerPixel: b[4] / 8,
};
const nameLen = u32be(b, 20);
const nameBytes = drainTo(nameLen);
if (!nameBytes) { chunks.unshift(b); totalBytes += 24; return false; }
dbg('← server-init: ' + fbW + 'x' + fbH + ' bpp=' + pixelFormat.bpp, 'ok');
canvas.width = fbW;
canvas.height = fbH;
relayoutCanvas();
setStatus('connected', 'connected');
// Advertise Raw + the ExtendedDesktopSize pseudo-encoding so
// the server reports (and accepts) desktop-size changes.
sendSetEncodings([0, -308]);
// Request full framebuffer update
requestUpdate(0, 0, 0, fbW, fbH);
state = 'normal';
return true;
}
case 'normal': {
const b = drainTo(1);
if (!b) return false;
const msgType = b[0];
if (msgType === 0) {
// FramebufferUpdate: type(1) + padding(1) + nRects(2). The type
// byte is already consumed above; hdr covers padding + nRects.
const hdr = drainTo(3);
if (!hdr) { chunks.unshift(b); totalBytes += 1; return false; }
updateRects = u16be(hdr, 1);
state = 'rect-header';
} else if (msgType === 2) {
// Bell: ignore
} else if (msgType === 3) {
// ServerCutText
const hdr = drainTo(7);
if (!hdr) { chunks.unshift(b); totalBytes += 1; return false; }
const len = u32be(hdr, 3);
const text = drainTo(len);
if (!text) { chunks.unshift(b); totalBytes += 1 + 7; return false; }
}
return true;
}
case 'rect-header': {
if (updateRects === 0) { state = 'normal'; requestUpdate(1, 0, 0, fbW, fbH); return true; }
const b = drainTo(12);
if (!b) return false;
const x = u16be(b, 0), y = u16be(b, 2), w = u16be(b, 4), h = u16be(b, 6);
const enc = (b[8]<<24|b[9]<<16|b[10]<<8|b[11])>>>0;
if (enc === 0 && pixelFormat) {
const bytes = w * h * pixelFormat.bytesPerPixel;
const pixels = drainTo(bytes);
if (!pixels) { chunks.unshift(b); totalBytes += 12; return false; }
drawRaw(x, y, w, h, pixels);
} else if (enc === EXT_DESKTOP_SIZE_U32) {
// ExtendedDesktopSize: w,h carry the new desktop dimensions;
// the rect body is nScreens(1) + pad(3) + nScreens×16. The
// header's x = change reason, y = request status.
const nScreens = peekByte();
if (nScreens < 0) { chunks.unshift(b); totalBytes += 12; return false; }
const body = drainTo(4 + nScreens * 16);
if (!body) { chunks.unshift(b); totalBytes += 12; return false; }
if (nScreens > 0) screenId = u32be(body, 4); // reuse the server's screen id
if (!extDesktopSupported) {
extDesktopSupported = true;
matchBtn.disabled = false;
}
if (w && h && (w !== fbW || h !== fbH)) {
dbg('← desktop resized to ' + w + 'x' + h
+ ' (reason ' + x + ', status ' + y + ')', 'ok');
fbW = w; fbH = h;
canvas.width = w; canvas.height = h;
relayoutCanvas();
requestUpdate(0, 0, 0, fbW, fbH);
}
}
updateRects--;
return true;
}
default: return false;
}
}
function drawRaw(x, y, w, h, data) {
if (!pixelFormat || w === 0 || h === 0) return;
const bpp = pixelFormat.bytesPerPixel;
const img = ctx.createImageData(w, h);
const d = img.data;
const rs = pixelFormat.redShift, gs = pixelFormat.greenShift, bs = pixelFormat.blueShift;
for (let i = 0, o = 0; i < w * h; i++, o += bpp) {
let px = 0;
if (bpp === 4) px = pixelFormat.bigEndian
? (data[o]<<24|data[o+1]<<16|data[o+2]<<8|data[o+3])>>>0
: (data[o+3]<<24|data[o+2]<<16|data[o+1]<<8|data[o])>>>0;
else if (bpp === 2) px = pixelFormat.bigEndian
? (data[o]<<8|data[o+1])>>>0 : (data[o+1]<<8|data[o])>>>0;
else px = data[o];
d[i*4] = (px >> rs) & pixelFormat.redMax;
d[i*4+1] = (px >> gs) & pixelFormat.greenMax;
d[i*4+2] = (px >> bs) & pixelFormat.blueMax;
d[i*4+3] = 255;
}
ctx.putImageData(img, x, y);
}
function requestUpdate(incremental, x, y, w, h) {
const b = new Uint8Array(10);
b[0] = 3; b[1] = incremental;
b[2] = x>>8; b[3] = x&0xff;
b[4] = y>>8; b[5] = y&0xff;
b[6] = w>>8; b[7] = w&0xff;
b[8] = h>>8; b[9] = h&0xff;
send(b);
}
// SetEncodings (msg type 2): advertise the encodings we understand.
// Negative values are pseudo-encodings (e.g. -308 ExtendedDesktopSize).
function sendSetEncodings(encs) {
const b = new Uint8Array(4 + encs.length * 4);
b[0] = 2; // message-type
b[1] = 0; // padding
b[2] = encs.length >> 8; b[3] = encs.length & 0xff;
let o = 4;
for (const e of encs) {
const v = e >>> 0; // two's-complement for negatives
b[o++] = (v>>24)&0xff; b[o++] = (v>>16)&0xff;
b[o++] = (v>>8)&0xff; b[o++] = v&0xff;
}
send(b);
}
// SetDesktopSize (msg type 251): ask the server to change the desktop
// resolution. One screen at the origin, sized to the request.
function sendSetDesktopSize(w, h) {
const b = new Uint8Array(24);
b[0] = 251; b[1] = 0; // message-type + padding
b[2] = w>>8; b[3] = w&0xff;
b[4] = h>>8; b[5] = h&0xff;
b[6] = 1; b[7] = 0; // number-of-screens + padding
// screen: id(4) x(2) y(2) width(2) height(2) flags(4)
b[8] = (screenId>>>24)&0xff; b[9] = (screenId>>>16)&0xff;
b[10] = (screenId>>>8)&0xff; b[11] = screenId&0xff;
b[12] = 0; b[13] = 0; // x-position
b[14] = 0; b[15] = 0; // y-position
b[16] = w>>8; b[17] = w&0xff;
b[18] = h>>8; b[19] = h&0xff;
b[20] = 0; b[21] = 0; b[22] = 0; b[23] = 0; // flags
send(b);
}
// Peek the first unconsumed byte without draining it. -1 when empty.
function peekByte() {
for (const c of chunks) { if (c.length) return c[0]; }
return -1;
}
// --- Input forwarding ---
canvas.addEventListener('mousemove', sendPointer);
canvas.addEventListener('mousedown', sendPointer);
canvas.addEventListener('mouseup', sendPointer);
function sendPointer(ev) {
const r = canvas.getBoundingClientRect();
// In fit mode the canvas is CSS-scaled, so the rendered rect differs
// from the intrinsic resolution — map client coords back to fb pixels.
const sx = r.width ? canvas.width / r.width : 1;
const sy = r.height ? canvas.height / r.height : 1;
const x = Math.max(0, Math.min(fbW-1, Math.round((ev.clientX - r.left) * sx)));
const y = Math.max(0, Math.min(fbH-1, Math.round((ev.clientY - r.top) * sy)));
let mask = 0;
if (ev.buttons & 1) mask |= 1;
if (ev.buttons & 4) mask |= 2;
if (ev.buttons & 2) mask |= 4;
const b = new Uint8Array(6);
b[0] = 5; b[1] = mask;
b[2] = x>>8; b[3] = x&0xff;
b[4] = y>>8; b[5] = y&0xff;
send(b);
}
document.addEventListener('keydown', (ev) => sendKey(ev, 1));
document.addEventListener('keyup', (ev) => sendKey(ev, 0));
function sendKey(ev, down) {
ev.preventDefault();
const key = rfbKeysym(ev);
const b = new Uint8Array(8);
b[0] = 4; b[1] = down; b[2] = 0; b[3] = 0;
b[4] = key>>24; b[5] = (key>>16)&0xff; b[6] = (key>>8)&0xff; b[7] = key&0xff;
send(b);
}
function rfbKeysym(ev) {
// Map common keys to X11 keysym values
const map = {
'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,
'Shift': 0xffe1, 'Control': 0xffe3, 'Alt': 0xffe9, 'Meta': 0xffe7,
'F1': 0xffbe, 'F2': 0xffbf, 'F3': 0xffc0, 'F4': 0xffc1,
'F5': 0xffc2, 'F6': 0xffc3, 'F7': 0xffc4, 'F8': 0xffc5,
'F9': 0xffc6, 'F10': 0xffc7, 'F11': 0xffc8, 'F12': 0xffc9,
};
if (map[ev.key]) return map[ev.key];
if (ev.key.length === 1) return ev.key.codePointAt(0);
return 0;
}
})();
</script>
</body>
</html>