From c14cce63764fbe5f0279b2f6f2cc44c4d0f178e9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Sat, 1 Aug 2026 20:03:40 +0000 Subject: [PATCH] vnc: clip the mirror to the primary output MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit X composes every attached output into one framebuffer, so with a 4K monitor at +0+0 and a 1080p TV at +3840+0 the framebuffer is 5760x2160 and mirroring it whole sent BOTH screens side by side, then halved them for being over the scale threshold. The remote desktop showed a squashed double-width image with the second monitor hanging off the right — correct, and useless. Clip to the primary output instead: 3840x2160+0+0 here, which then scales to a clean 1920x1080. Only clips when a primary is actually marked AND more than one output is connected. With a single output the framebuffer already IS that screen, so clipping would add a failure mode for no gain. The scale decision now keys off what is really being served — the clip when there is one, the whole framebuffer otherwise — instead of a framebuffer width that may span screens. Never showed up under LightDM because only one output was ever live there. Co-Authored-By: Claude Opus 5 (1M context) --- src/servers/sidecar/vnc/vnc-manager.ts | 55 ++++++++++++++++++++------ 1 file changed, 43 insertions(+), 12 deletions(-) diff --git a/src/servers/sidecar/vnc/vnc-manager.ts b/src/servers/sidecar/vnc/vnc-manager.ts index 590077f7..9c04a68f 100644 --- a/src/servers/sidecar/vnc/vnc-manager.ts +++ b/src/servers/sidecar/vnc/vnc-manager.ts @@ -120,18 +120,36 @@ export async function ensureVncPassword(homeDir: string): Promise<{ password: st return { password, passwdFile }; } -// Width of the mirrored display's framebuffer, or null when it cannot be read (xrandr missing, X not -// up). Callers treat null as "don't scale" — serving too many pixels beats an unreadable thumbnail. -function getFramebufferWidth(xauthority: string, display: string): number | null { +type Geometry = { w: number; h: number; x: number; y: number }; +type DisplayGeometry = { framebufferWidth: number | null; primary: Geometry | null; connected: number }; + +// X composes every attached output into ONE framebuffer, so with two monitors side by side the +// framebuffer is their combined width and mirroring it whole sends both screens squashed together. +// Read the layout so the caller can clip to a single output. +// +// Returns nulls when xrandr is missing or X is not up; callers treat that as "don't scale, don't clip", +// because serving too many pixels beats serving nothing. +function readDisplayGeometry(xauthority: string, display: string): DisplayGeometry { const proc = Bun.spawnSync({ cmd: ['xrandr', '--current'], env: { ...process.env, DISPLAY: display, XAUTHORITY: xauthority }, stdout: 'pipe', stderr: 'ignore', }); - if (proc.exitCode !== 0) return null; - const match = proc.stdout.toString().match(/current\s+(\d+)\s*x\s*(\d+)/); - return match ? Number(match[1]) : null; + if (proc.exitCode !== 0) return { framebufferWidth: null, primary: null, connected: 0 }; + + const out = proc.stdout.toString(); + const fb = out.match(/current\s+(\d+)\s*x\s*(\d+)/); + const connected = (out.match(/^\S+ connected/gm) ?? []).length; + + // "HDMI-A-0 connected primary 3840x2160+0+0 (normal left ..." — the geometry only appears on an + // output that is actually enabled, so a connected-but-off output correctly yields no match. + const p = out.match(/^\S+ connected primary (\d+)x(\d+)\+(\d+)\+(\d+)/m); + const primary = p + ? { w: Number(p[1]), h: Number(p[2]), x: Number(p[3]), y: Number(p[4]) } + : null; + + return { framebufferWidth: fb ? Number(fb[1]) : null, primary, connected }; } async function isPortOpen(port: number): Promise { @@ -175,14 +193,26 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb throw new Error(`No X authority found (GDM or ~/.Xauthority) — nobody is logged in on ${display}`); } - // Halving a 4K framebuffer keeps the stream sane over the tailnet, but the same 0.5 applied to a - // small screen is just lost detail — and with no monitor plugged in, X falls back to something - // tiny (800x480 here), which halves to an unreadable 400x240. Scale only when there is genuinely - // too much to send. - const width = getFramebufferWidth(xauthority, display); + // With more than one output attached the framebuffer spans them all, and mirroring it whole shows + // every monitor side by side, squashed. Clip to the primary so the remote view is one screen at the + // right proportions. Only when a primary is actually marked and there is more than one output — + // otherwise the framebuffer already IS the single screen and clipping would just add a failure mode. + const geo = readDisplayGeometry(xauthority, display); + const clipTo = geo.connected > 1 && geo.primary ? geo.primary : null; + const clip = clipTo ? ['-clip', `${clipTo.w}x${clipTo.h}+${clipTo.x}+${clipTo.y}`] : []; + if (clipTo) { + console.log(`[vnc] ${geo.connected} outputs attached — clipping to the primary ${clip[1]}`); + } + + // Halving a 4K screen keeps the stream sane over the tailnet, but the same 0.5 applied to a small + // screen is just lost detail — and with no monitor plugged in, X falls back to something tiny + // (800x480 here), which halves to an unreadable 400x240. Scale only when there is genuinely too much + // to send, and judge that on what is actually being served: the clip if there is one, else the + // whole framebuffer. + const width = clipTo ? clipTo.w : geo.framebufferWidth; const scale = width !== null && width > SCALE_ABOVE_WIDTH ? ['-scale', '0.5'] : []; if (width !== null) { - console.log(`[vnc] ${display} framebuffer is ${width}px wide — ${scale.length ? 'scaling to 50%' : 'serving 1:1'}`); + console.log(`[vnc] serving ${width}px wide — ${scale.length ? 'scaling to 50%' : '1:1'}`); } const proc = Bun.spawn({ @@ -199,6 +229,7 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb '-localhost', '-forever', '-shared', + ...clip, ...scale, '-noxdamage', '-quiet',