screen: add match-size — RFB SetDesktopSize to resize the remote desktop

This commit is contained in:
iris 2026-05-21 23:07:55 +02:00 committed by Mara
parent 317d580405
commit f510a321df
2 changed files with 94 additions and 1 deletions

View file

@ -549,7 +549,12 @@ shaped).
- `GET /screen` — VNC viewer page (minimal RFB-over-WebSocket
renderer). Only accessible when `hyperhive.gui.enable = true`
in the agent's `agent.nix`; the harness shows a 🖥 screen link
in the state row when `gui_vnc_port` is present.
in the state row when `gui_vnc_port` is present. Toolbar:
`⤢ fit` CSS-downscales the canvas to the window; `⤡ match size`
sends an RFB `SetDesktopSize` request so the server (weston)
changes its real output resolution to the window dimensions —
enabled once the server advertises the `ExtendedDesktopSize`
pseudo-encoding (issue #133).
- `GET /screen/ws` — raw RFB byte relay: proxies WebSocket
frames to the weston VNC server at `127.0.0.1:<vnc_port>`.
Transparent to any RFB variant. VNC port comes from

View file

@ -37,6 +37,7 @@ html, body { height: 100%; background: var(--base); color: var(--text);
border: 1px solid var(--surface1); border-radius: 4px; cursor: pointer;
}
.tbtn.active { color: var(--green); border-color: var(--green); }
.tbtn:disabled { opacity: 0.4; cursor: default; }
#status { margin-left: auto; font-size: 0.75rem; color: var(--subtext0); }
#status.connected { color: var(--green); }
#status.error { color: var(--red); }
@ -83,6 +84,7 @@ canvas { display: block; cursor: default; }
<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>
@ -109,6 +111,7 @@ canvas { display: block; cursor: default; }
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 ---
@ -160,6 +163,23 @@ canvas { display: block; cursor: default; }
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. The button is
// enabled only once the server has advertised the ExtendedDesktopSize
// pseudo-encoding (a -308 rect). (issue #133)
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(' ');
}
@ -216,6 +236,9 @@ canvas { display: block; cursor: default; }
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) {
@ -544,6 +567,9 @@ canvas { display: block; cursor: default; }
canvas.height = fbH;
relayoutCanvas();
setStatus('connected', 'connected');
// Advertise Raw + the ExtendedDesktopSize pseudo-encoding so the
// server reports (and accepts) desktop-size changes. (issue #133)
sendSetEncodings([0, -308]);
// Request full framebuffer update
requestUpdate(0, 0, 0, fbW, fbH);
state = 'normal';
@ -583,6 +609,27 @@ canvas { display: block; cursor: default; }
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. (issue #133)
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;
@ -623,6 +670,47 @@ canvas { display: block; cursor: default; }
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. (#133)
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);