hyperhive/hive-ag3nt/assets/screen.html
iris 62aa3bb3ec agent icon: render on dashboard + per-agent web UI
Consumes the GET /icon endpoint from #139:

- Dashboard: each container card shows the agent's icon next to its
  name (26px). Loaded from <agent-url>/icon; onerror hides it for a
  stopped container whose web server isn't answering.
- Per-agent web UI: the agent's icon next to the page title (40px),
  and /icon as the favicon on the index, stats, and screen pages.

/icon always returns an image (configured SVG or the default
hyperhive logo), so no presence check is needed.

Closes #140
2026-05-21 15:36:58 +02:00

653 lines
26 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 and let CSS scale it down to the
viewport (intrinsic resolution unchanged — see canvas.fit). */
#canvas-wrap.fit { align-items: center; overflow: hidden; }
canvas { display: block; cursor: default; }
/* max-* on a replaced element shrinks it preserving aspect ratio. */
canvas.fit { max-width: 100%; max-height: 100%; }
#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 via CSS so the whole desktop is visible
// without scrolling. The canvas's intrinsic resolution is untouched;
// only its displayed size changes (see canvas.fit / #canvas-wrap.fit).
// Pointer coordinates are rescaled in sendPointer to stay accurate.
// The choice is persisted in localStorage; default is fit-on.
let fitMode = localStorage.getItem('screen-fit') !== 'off';
function applyFitMode() {
canvas.classList.toggle('fit', fitMode);
canvasWrap.classList.toggle('fit', fitMode);
fitBtn.classList.toggle('active', fitMode);
}
fitBtn.addEventListener('click', () => {
fitMode = !fitMode;
localStorage.setItem('screen-fit', fitMode ? 'on' : 'off');
applyFitMode();
});
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;
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>