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:
@@ -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',
|
||||||
|
|||||||
Reference in New Issue
Block a user