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
653 lines
26 KiB
HTML
653 lines
26 KiB
HTML
<!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>
|