hyperhive/hive-ag3nt/assets/screen.html
iris 4814aaefdb fix: screen fit-to-window — lift flex auto-minimum on the canvas
PR #171 made relayoutCanvas() set canvas.style.width/height
explicitly, but the canvas is a flex item: flex items default to
min-width/min-height: auto, which resolves to the canvas's intrinsic
framebuffer resolution and clamps the JS-set display size right back
up to native — so fit mode still did nothing (#133, "still same
behavior").

Add min-width/min-height: 0 (+ flex: none) on the canvas in fit mode
so the explicit downscaled size actually sticks. Scoped to
#canvas-wrap.fit so non-fit mode keeps native size + scroll.

re #133
2026-05-21 19:40:17 +02:00

682 lines
27 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">
<style>
/* Catppuccin Mocha palette (mirrors base.css) */
:root {
--base: #1e1e2e;
--mantle: #181825;
--crust: #11111b;
--text: #cdd6f4;
--subtext0:#a6adc8;
--surface0:#313244;
--surface1:#45475a;
--blue: #89b4fa;
--red: #f38ba8;
--green: #a6e3a1;
--yellow: #f9e2af;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { height: 100%; background: var(--base); color: var(--text);
font-family: 'JetBrains Mono', 'Fira Code', ui-monospace, monospace;
font-size: 14px; }
#toolbar {
display: flex; align-items: center; gap: 0.75rem;
padding: 0.4rem 0.75rem; background: var(--mantle);
border-bottom: 1px solid var(--surface0);
}
#toolbar a { color: var(--blue); text-decoration: none; font-size: 0.85rem; }
#toolbar a:hover { text-decoration: underline; }
.tbtn {
padding: 0.15rem 0.5rem; font-size: 0.72rem; font-family: inherit;
background: var(--surface0); color: var(--subtext0);
border: 1px solid var(--surface1); border-radius: 4px; cursor: pointer;
}
.tbtn.active { color: var(--green); border-color: var(--green); }
#status { margin-left: auto; font-size: 0.75rem; color: var(--subtext0); }
#status.connected { color: var(--green); }
#status.error { color: var(--red); }
#debug-log {
position: fixed; bottom: 0; left: 0; right: 0; max-height: 40vh;
overflow-y: auto; background: rgba(17,17,27,0.95);
border-top: 1px solid var(--surface1);
font-size: 0.72rem; font-family: ui-monospace, monospace;
padding: 0.4rem 0.6rem; z-index: 100;
display: none; /* hidden by default; toggled by toolbar button */
}
#debug-log .dbg-line { color: var(--subtext0); margin: 1px 0; white-space: pre; }
#debug-log .dbg-line.err { color: var(--red); }
#debug-log .dbg-line.ok { color: var(--green); }
#debug-log .dbg-line.send { color: var(--blue); }
#canvas-wrap {
display: flex; justify-content: center; align-items: flex-start;
width: 100%; height: calc(100% - 36px); overflow: auto;
background: var(--crust);
}
/* Fit mode: centre the canvas (relayoutCanvas() scales it in JS to
fit the wrap) and clip any sub-pixel rounding overflow. */
#canvas-wrap.fit { align-items: center; overflow: hidden; }
canvas { display: block; cursor: default; }
/* In fit mode relayoutCanvas() sets the canvas display size explicitly.
The canvas is a flex item, and flex items default to
min-width/min-height: auto — which resolves to the canvas's intrinsic
framebuffer resolution and clamps the JS-set size straight back up,
defeating the downscale (the bug behind #133 round 1). Pin the canvas
to exactly the size relayoutCanvas() sets: min-* 0 lifts the clamp,
flex: none stops flex grow/shrink from fighting it. */
#canvas-wrap.fit canvas { flex: none; min-width: 0; min-height: 0; }
#msg {
position: fixed; bottom: 1rem; left: 50%; transform: translateX(-50%);
background: var(--surface0); color: var(--yellow); border-radius: 6px;
padding: 0.4rem 0.9rem; font-size: 0.8rem;
opacity: 0; transition: opacity 0.3s;
pointer-events: none;
}
</style>
</head>
<body>
<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="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.
//
// This is a deliberately thin implementation — enough to display the
// desktop and forward pointer + keyboard events. For a production-grade
// viewer, replace with noVNC (issue #52 vendors the full bundle).
(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 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 ---
// Scales the canvas down so the whole desktop is visible without
// scrolling. The canvas's intrinsic resolution (width/height attrs)
// is untouched — only its CSS display size changes, set explicitly
// by relayoutCanvas(). Pointer coordinates are rescaled in
// sendPointer to stay accurate. Persisted in localStorage; default
// is fit-on.
let fitMode = localStorage.getItem('screen-fit') !== 'off';
// Size the canvas. In fit mode, scale down (never up) to the wrap,
// preserving aspect ratio. Explicit px sizing rather than CSS
// max-width/max-height: on a flex item those are overridden by the
// automatic minimum size, so fit mode was a silent no-op — the
// oversized canvas just got centred and clipped (issue #133).
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();
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 ---
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const ws = new WebSocket(`${proto}://${location.host}/screen/ws`);
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;
// 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');
// 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);
}
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);
}
// --- 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>