vnc: clip the mirror to the primary output

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) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 20:03:40 +00:00
co-authored by Claude Opus 5
parent 3b941f4d1f
commit c14cce6376
+43 -12
View File
@@ -120,18 +120,36 @@ export async function ensureVncPassword(homeDir: string): Promise<{ password: st
return { password, passwdFile }; return { password, passwdFile };
} }
// Width of the mirrored display's framebuffer, or null when it cannot be read (xrandr missing, X not type Geometry = { w: number; h: number; x: number; y: number };
// up). Callers treat null as "don't scale" — serving too many pixels beats an unreadable thumbnail. type DisplayGeometry = { framebufferWidth: number | null; primary: Geometry | null; connected: number };
function getFramebufferWidth(xauthority: string, display: string): number | null {
// 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({ const proc = Bun.spawnSync({
cmd: ['xrandr', '--current'], cmd: ['xrandr', '--current'],
env: { ...process.env, DISPLAY: display, XAUTHORITY: xauthority }, env: { ...process.env, DISPLAY: display, XAUTHORITY: xauthority },
stdout: 'pipe', stdout: 'pipe',
stderr: 'ignore', stderr: 'ignore',
}); });
if (proc.exitCode !== 0) return null; if (proc.exitCode !== 0) return { framebufferWidth: null, primary: null, connected: 0 };
const match = proc.stdout.toString().match(/current\s+(\d+)\s*x\s*(\d+)/);
return match ? Number(match[1]) : null; 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<boolean> { async function isPortOpen(port: number): Promise<boolean> {
@@ -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}`); 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 // With more than one output attached the framebuffer spans them all, and mirroring it whole shows
// small screen is just lost detail — and with no monitor plugged in, X falls back to something // every monitor side by side, squashed. Clip to the primary so the remote view is one screen at the
// tiny (800x480 here), which halves to an unreadable 400x240. Scale only when there is genuinely // right proportions. Only when a primary is actually marked and there is more than one output —
// too much to send. // otherwise the framebuffer already IS the single screen and clipping would just add a failure mode.
const width = getFramebufferWidth(xauthority, display); 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'] : []; const scale = width !== null && width > SCALE_ABOVE_WIDTH ? ['-scale', '0.5'] : [];
if (width !== null) { 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({ const proc = Bun.spawn({
@@ -199,6 +229,7 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb
'-localhost', '-localhost',
'-forever', '-forever',
'-shared', '-shared',
...clip,
...scale, ...scale,
'-noxdamage', '-noxdamage',
'-quiet', '-quiet',