diff --git a/docs/web-ui.md b/docs/web-ui.md index a3036da..1fa6331 100644 --- a/docs/web-ui.md +++ b/docs/web-ui.md @@ -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:`. Transparent to any RFB variant. VNC port comes from diff --git a/hive-ag3nt/assets/screen.html b/hive-ag3nt/assets/screen.html index 9a2f75a..2b91f12 100644 --- a/hive-ag3nt/assets/screen.html +++ b/hive-ag3nt/assets/screen.html @@ -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; } 🖥 screen ← agent + connecting… @@ -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);