vnc: find the display instead of assuming :0

The mirror hardcoded :0. That held under LightDM, which gave the user session :0. GDM does not —
it keeps :0 for its own greeter and starts the user's Xorg with -displayfd, letting the number be
picked at runtime; on this machine the session lands on :1. So after migrating to Ubuntu Desktop
the mirror failed on every attempt with "Can't open display :0", with a healthy session sitting
one number over.

Resolve by socket ownership: /tmp/.X11-unix/X<n> is owned by whoever runs that X server, so the
socket owned by us is the owner's session and anything else is the greeter's. Falls back to the
lowest socket (a root-run Xorg, as LightDM had) and finally to 0, so it is never worse than the
constant it replaces.

The display is now threaded through rather than read from a module constant, so getFramebufferWidth
measures the display actually being mirrored and both log lines name it.

Found while verifying the XFCE-to-GNOME migration on this machine — the session was up and x11 with
the cookie in the GDM path, and only the display number was wrong.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 19:33:25 +00:00
co-authored by Claude Opus 5
parent 525557a952
commit 3b941f4d1f
+55 -22
View File
@@ -1,23 +1,53 @@
import { existsSync, mkdirSync } from 'node:fs';
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
import { join } from 'node:path';
import type { VncStartParams, VncSessionInfo } from '../protocol';
import { getOwnerHomeDir } from '@@/data-path';
// Mirrors the physical display instead of spawning a virtual desktop per user, so the
// browser shows the same session as the screen. There is exactly one :0, hence one
// shared mirror — the first caller's VNC password is the one it answers with.
const MIRROR_DISPLAY = ':0';
const MIRROR_DISPLAY_NUM = 0;
// browser shows the same session as the screen. There is one seat, hence one shared
// mirror — the first caller's VNC password is the one it answers with.
const MIRROR_PORT = 5900;
const READY_TIMEOUT_MS = 5000;
// Above this framebuffer width the stream is halved; at or below it, pixels are served 1:1.
const SCALE_ABOVE_WIDTH = 2560;
// x11vnc reads :0's cookie from the logged-in user's X authority, so no root is needed. Where that
// cookie lives depends on the display manager: GDM (Ubuntu GNOME on Xorg) keeps it in the per-session
// dir /run/user/<uid>/gdm/Xauthority; LightDM (or a manual startx) uses the classic ~/.Xauthority. Try
// the GDM path first, then fall back. While the greeter owns :0 the user's cookie doesn't exist yet and
// mirroring fails until someone is logged in.
// The display number is NOT fixed. Under LightDM the user session got :0, so this was hardcoded to it.
// GDM gives :0 to its own greeter and starts the user's Xorg with -displayfd, letting the kernel pick —
// in practice :1. Hardcoding :0 made the mirror fail with "Can't open display" on every GNOME machine.
//
// Find it by socket ownership instead: /tmp/.X11-unix/X<n> is owned by whoever runs that X server, so
// the one owned by us is the owner's session and any other is the greeter's. Fall back to the lowest
// socket (covers a root-run Xorg, as LightDM did) and finally to :0 so behaviour never gets worse than
// the constant it replaced.
function resolveDisplayNum(): number {
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
let sockets: number[] = [];
try {
sockets = readdirSync('/tmp/.X11-unix')
.filter((n) => /^X\d+$/.test(n))
.map((n) => Number(n.slice(1)))
.sort((a, b) => a - b);
} catch {
return 0;
}
if (uid != null) {
const own = sockets.find((n) => {
try {
return statSync(`/tmp/.X11-unix/X${n}`).uid === uid;
} catch {
return false;
}
});
if (own !== undefined) return own;
}
return sockets[0] ?? 0;
}
// x11vnc reads the display's cookie from the logged-in user's X authority, so no root is needed. Where
// that cookie lives depends on the display manager: GDM (Ubuntu GNOME on Xorg) keeps it in the
// per-session dir /run/user/<uid>/gdm/Xauthority; LightDM (or a manual startx) uses the classic
// ~/.Xauthority. Try the GDM path first, then fall back. While the greeter owns the seat the user's
// cookie doesn't exist yet and mirroring fails until someone is logged in.
function resolveXauthority(): string | null {
const uid = typeof process.getuid === 'function' ? process.getuid() : null;
const candidates = [
@@ -90,12 +120,12 @@ export async function ensureVncPassword(homeDir: string): Promise<{ password: st
return { password, passwdFile };
}
// Width of :0'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 serving an unreadable thumbnail.
function getFramebufferWidth(xauthority: string): number | null {
// 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 {
const proc = Bun.spawnSync({
cmd: ['xrandr', '--current'],
env: { ...process.env, DISPLAY: MIRROR_DISPLAY, XAUTHORITY: xauthority },
env: { ...process.env, DISPLAY: display, XAUTHORITY: xauthority },
stdout: 'pipe',
stderr: 'ignore',
});
@@ -137,26 +167,29 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb
const homeDir = getOwnerHomeDir(params.email);
const { passwdFile } = await ensureVncPassword(homeDir);
const displayNum = resolveDisplayNum();
const display = `:${displayNum}`;
const xauthority = resolveXauthority();
if (!xauthority) {
throw new Error(`No X authority found (GDM or ~/.Xauthority) — nobody is logged in on ${MIRROR_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
// 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);
const width = getFramebufferWidth(xauthority, display);
const scale = width !== null && width > SCALE_ABOVE_WIDTH ? ['-scale', '0.5'] : [];
if (width !== null) {
console.log(`[vnc] :0 framebuffer is ${width}px wide — ${scale.length ? 'scaling to 50%' : 'serving 1:1'}`);
console.log(`[vnc] ${display} framebuffer is ${width}px wide — ${scale.length ? 'scaling to 50%' : 'serving 1:1'}`);
}
const proc = Bun.spawn({
cmd: [
'x11vnc',
'-display',
MIRROR_DISPLAY,
display,
'-auth',
xauthority,
'-rfbport',
@@ -202,13 +235,13 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb
mirror = {
email: params.email,
username: params.username ?? '',
display: MIRROR_DISPLAY_NUM,
display: displayNum,
port: MIRROR_PORT,
pid: proc.pid,
};
console.log(`[vnc] mirroring ${MIRROR_DISPLAY} on port ${MIRROR_PORT} (pid ${proc.pid})`);
console.log(`[vnc] mirroring ${display} on port ${MIRROR_PORT} (pid ${proc.pid})`);
return { port: MIRROR_PORT, display: MIRROR_DISPLAY_NUM };
return { port: MIRROR_PORT, display: displayNum };
}
export function stopSession(_email: string): void {
@@ -220,7 +253,7 @@ export function stopSession(_email: string): void {
// already dead
}
console.log(`[vnc] stopped mirror of ${MIRROR_DISPLAY} (pid ${mirror.pid})`);
console.log(`[vnc] stopped mirror of :${mirror.display} (pid ${mirror.pid})`);
mirror = null;
}