vnc: a dedicated virtual desktop instead of mirroring the screen

The TV runs at 4K so it can play 4K video; a usable remote desktop wants about 1080p. One framebuffer
cannot be both, so mirroring meant every remote session was a scaled-down 4K desktop — dense to read
and expensive to encode. This gives remote its own display at 1920x1080 and leaves the TV alone.

Xvnc (TigerVNC) rather than x11vnc: it is the X server AND the VNC server in one process, so nothing
polls or scales — the server knows which rectangles changed and encodes them directly, where x11vnc
had to diff a framebuffer it did not own. It also implements RandR SetDesktopSize, so the client's
existing resizeSession makes the desktop resize itself to the browser panel. No scaling on either
side at any panel size, which removes the density problem rather than trading it for blur.

XFCE rather than GNOME, and NOT because it is lighter. Ubuntu's GNOME is managed by per-USER systemd
units — org.gnome.Shell@x11.service, gnome-session-manager@ubuntu.service and the whole
org.gnome.SettingsDaemon.* set all sit under user@<uid>.service, and gnome-session@.target is marked
RefuseManualStart. A second GNOME session for the SAME user collides with every one of them. That is
almost certainly what 106e5bd recorded as "a ghost logind session that broke lightdm login" — a
property of the session model, not something care avoids. XFCE has no such per-user units and
coexists with the TV session: same user, same home, same files, different shell.

The cookie goes in ~/.vnc/Xauthority, not ~/.Xauthority, which belongs to the TV session and is not
ours to write. Display is chosen by scanning for a free number from :2 up, checking the lock file as
well as the socket because a stale lock alone stops an X server starting. Teardown kills the session
before the server, since killing Xvnc first leaves XFCE's children reparented and running.

Verified end to end on a scratch display before committing: Xvnc listened, XFCE came up with xfwm4,
xfdesktop, xfce4-panel and xfsettingsd, RandR reported a 1920x1080 VNC-0 output, and the GNOME
session on the TV was untouched throughout.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 21:21:23 +00:00
co-authored by Claude Opus 5
parent 51b249b90a
commit 83bf746ab8
+119 -164
View File
@@ -1,71 +1,45 @@
import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs';
import { existsSync, mkdirSync } from 'node:fs';
import { randomBytes } from 'node:crypto';
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 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;
// 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.
// A dedicated virtual desktop for remote access, NOT a mirror of the physical screen.
//
// 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;
}
// This replaces mirroring, and the reason is a hard constraint rather than a preference: the TV runs at
// 4K so it can play 4K video, while a usable remote desktop wants ~1080p. One framebuffer cannot be both,
// and mirroring meant everything remote was a scaled-down 4K desktop — dense, and expensive to encode.
//
// Xvnc (TigerVNC) is the X server AND the VNC server in one process. Nothing polls or scales: the X
// server knows exactly which rectangles changed and encodes them directly, where x11vnc had to diff a
// framebuffer it did not own. It also implements RandR SetDesktopSize, so the noVNC client's
// `resizeSession` makes the desktop resize itself to the browser panel — no scaling on either side, at
// any panel size.
//
// XFCE, not GNOME, and this is NOT about it being lighter. Ubuntu's GNOME is managed by per-USER systemd
// units — org.gnome.Shell@x11.service, gnome-session-manager@ubuntu.service and the whole
// org.gnome.SettingsDaemon.* set all live under user@<uid>.service, and gnome-session@.target is marked
// RefuseManualStart. A second GNOME session for the SAME user collides with every one of those. That is
// almost certainly what b2f0d13 recorded as "a ghost logind session that broke lightdm login". XFCE has
// no such per-user units, so it coexists with the GNOME session on the TV — same user, same home, same
// files, just a different shell.
const GEOMETRY = '1920x1080';
const DEPTH = 24;
const VNC_PORT = 5900;
const READY_TIMEOUT_MS = 15_000;
const FIRST_DISPLAY = 2;
const MAX_DISPLAY = 32;
// 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 = [
uid != null ? `/run/user/${uid}/gdm/Xauthority` : null,
join(process.env.HOME ?? '', '.Xauthority'),
].filter((p): p is string => Boolean(p));
return candidates.find((p) => existsSync(p)) ?? null;
}
type MirrorSession = {
type VirtualDesktop = {
email: string;
username: string;
display: number;
port: number;
pid: number;
sessionPid: number;
};
let mirror: MirrorSession | null = null;
let desktop: VirtualDesktop | null = null;
function isProcessAlive(pid: number): boolean {
try {
@@ -76,14 +50,23 @@ function isProcessAlive(pid: number): boolean {
}
}
// :0 and :1 belong to GDM and the TV session. Skip anything with a socket or a lock file — a stale lock
// with no server still makes an X server refuse to start on that number.
function findFreeDisplay(): number {
for (let n = FIRST_DISPLAY; n < MAX_DISPLAY; n++) {
if (!existsSync(`/tmp/.X11-unix/X${n}`) && !existsSync(`/tmp/.X${n}-lock`)) return n;
}
throw new Error(`No free X display between :${FIRST_DISPLAY} and :${MAX_DISPLAY}`);
}
// Provision the pair of files a VNC session needs: `password` in plaintext, which the API hands to
// the browser, and `passwd` in rfbauth format, which x11vnc authenticates against. Idempotent —
// the browser, and `passwd` in rfbauth format, which the VNC server authenticates against. Idempotent —
// returns the existing password when both are already present.
//
// x11vnc writes the rfbauth file itself via -storepasswd. This used to shell out to tigervnc's
// `vncpasswd -f`, which is not installed by default (and is not in tigervnc-common, despite what
// setup-desktop.sh claimed), so the rfbauth file silently never appeared — the failure was swallowed
// because only a zero exit code triggered the write.
// Written with `x11vnc -storepasswd`. That looks odd now that Xvnc serves the desktop, but the rfbauth
// format is the same for both and this path is known-good: it replaced a call to tigervnc's `vncpasswd`,
// which is not shipped in tigervnc-common, so the file silently never appeared and only a zero exit code
// triggered the write, swallowing the failure.
export async function ensureVncPassword(homeDir: string): Promise<{ password: string; passwdFile: string }> {
const vncDir = join(homeDir, '.vnc');
const passwdFile = join(vncDir, 'passwd');
@@ -120,39 +103,23 @@ export async function ensureVncPassword(homeDir: string): Promise<{ password: st
return { password, passwdFile };
}
type Geometry = { w: number; h: number; x: number; y: number };
type DisplayGeometry = { framebufferWidth: number | null; primary: Geometry | null; connected: number };
// The X cookie for the virtual display. Its own file, not ~/.Xauthority — that one belongs to the TV
// session and writing to it risks disturbing a session we do not own.
function ensureXauthority(vncDir: string, display: number): string {
const authFile = join(vncDir, 'Xauthority');
const cookie = randomBytes(16).toString('hex');
// 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',
cmd: ['xauth', '-f', authFile, 'add', `:${display}`, 'MIT-MAGIC-COOKIE-1', cookie],
stdout: 'ignore',
stderr: 'pipe',
});
if (proc.exitCode !== 0) return { framebufferWidth: null, primary: null, connected: 0 };
if (proc.exitCode !== 0) {
throw new Error(`xauth failed for :${display}: ${proc.stderr.toString().trim()}`);
}
const out = proc.stdout.toString();
const fb = out.match(/current\s+(\d+)\s*x\s*(\d+)/);
// Count ENABLED outputs, not merely connected ones: a screen that is plugged in but switched off
// still reports "connected" and contributes nothing to the framebuffer. Only an enabled output
// carries a WxH+X+Y geometry, so requiring that is what distinguishes the two.
const connected = (out.match(/^\S+ connected(?: primary)? \d+x\d+\+\d+\+\d+/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 };
Bun.spawnSync({ cmd: ['chmod', '600', authFile], stdout: 'ignore', stderr: 'ignore' });
return authFile;
}
async function isPortOpen(port: number): Promise<boolean> {
@@ -169,7 +136,7 @@ async function isPortOpen(port: number): Promise<boolean> {
}
}
// x11vnc stays in the foreground, so readiness is the listening port rather than exit code.
// Xvnc stays in the foreground, so readiness is the listening port rather than exit code.
async function waitForPort(port: number): Promise<boolean> {
const deadline = Date.now() + READY_TIMEOUT_MS;
while (Date.now() < deadline) {
@@ -180,76 +147,47 @@ async function waitForPort(port: number): Promise<boolean> {
}
export async function startSession(params: VncStartParams): Promise<{ port: number; display: number }> {
if (mirror && isProcessAlive(mirror.pid)) {
return { port: mirror.port, display: mirror.display };
if (desktop && isProcessAlive(desktop.pid)) {
return { port: desktop.port, display: desktop.display };
}
mirror = null;
desktop = null;
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 ${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] serving ${width}px wide — ${scale.length ? 'scaling to 50%' : '1:1'}`);
}
const display = findFreeDisplay();
const authFile = ensureXauthority(join(homeDir, '.vnc'), display);
const proc = Bun.spawn({
cmd: [
'x11vnc',
'-display',
display,
'-auth',
xauthority,
'Xvnc',
`:${display}`,
'-geometry',
GEOMETRY,
'-depth',
String(DEPTH),
'-rfbport',
String(MIRROR_PORT),
String(VNC_PORT),
'-rfbauth',
passwdFile,
'-SecurityTypes',
'VncAuth',
// Loopback only: the browser reaches this through the platform's websocket bridge, never directly.
'-localhost',
'-forever',
'-shared',
...clip,
...scale,
// No -noxdamage. It was set when this was written, with no recorded reason, and it is expensive:
// without the DAMAGE extension x11vnc cannot be told which rectangles changed, so it polls the
// WHOLE framebuffer continuously to find out. That makes cost scale with screen area rather than
// with what actually moved — on a 4K mirror it is the single biggest source of latency, and it is
// why a smaller machine felt instant by comparison. This X server reports DAMAGE, MIT-SHM and
// XFIXES, so the optimisation is genuinely available.
//
// If stale patches ever appear on screen (some drivers under-report damage), putting -noxdamage
// back is the fix — at the cost of the polling above.
'-quiet',
// Without this a second browser tab silently disconnects the first.
'-AlwaysShared',
'-auth',
authFile,
'-desktop',
'officer',
'-dpi',
'96',
],
stdout: 'ignore',
stderr: 'pipe',
});
// Drain stderr so the pipe cannot fill and stall x11vnc; keep the tail for errors
// Drain stderr so the pipe cannot fill and stall the server; keep the tail for errors.
let stderrTail = '';
(async () => {
const reader = (proc.stderr as ReadableStream<Uint8Array>).getReader();
@@ -265,54 +203,71 @@ export async function startSession(params: VncStartParams): Promise<{ port: numb
}
})();
if (!(await waitForPort(MIRROR_PORT))) {
if (!(await waitForPort(VNC_PORT))) {
try {
proc.kill();
} catch {
// already dead
}
throw new Error(`x11vnc failed to listen on ${MIRROR_PORT}: ${stderrTail.trim() || 'timed out'}`);
throw new Error(`Xvnc failed to listen on ${VNC_PORT}: ${stderrTail.trim() || 'timed out'}`);
}
mirror = {
// The desktop itself, once the server is accepting. dbus-run-session gives this session its own bus:
// inheriting the GNOME session's bus is how two desktops start fighting over the same daemons.
const env: Record<string, string> = { ...(process.env as Record<string, string>) };
delete env.SESSION_MANAGER;
delete env.DBUS_SESSION_BUS_ADDRESS;
const session = Bun.spawn({
cmd: ['dbus-run-session', '--', 'startxfce4'],
env: { ...env, DISPLAY: `:${display}`, XAUTHORITY: authFile, XDG_SESSION_TYPE: 'x11' },
stdout: 'ignore',
stderr: 'ignore',
});
desktop = {
email: params.email,
username: params.username ?? '',
display: displayNum,
port: MIRROR_PORT,
display,
port: VNC_PORT,
pid: proc.pid,
sessionPid: session.pid,
};
console.log(`[vnc] mirroring ${display} on port ${MIRROR_PORT} (pid ${proc.pid})`);
console.log(`[vnc] virtual desktop on :${display} (${GEOMETRY}) port ${VNC_PORT} — Xvnc ${proc.pid}, xfce ${session.pid}`);
return { port: MIRROR_PORT, display: displayNum };
return { port: VNC_PORT, display };
}
export function stopSession(_email: string): void {
if (!mirror) return;
if (!desktop) return;
try {
process.kill(mirror.pid);
} catch {
// already dead
// The session first: killing Xvnc out from under XFCE leaves its children reparented and running.
for (const pid of [desktop.sessionPid, desktop.pid]) {
try {
if (pid > 0) process.kill(pid);
} catch {
// already dead
}
}
console.log(`[vnc] stopped mirror of :${mirror.display} (pid ${mirror.pid})`);
mirror = null;
console.log(`[vnc] stopped the virtual desktop on :${desktop.display}`);
desktop = null;
}
export function getSession(_email: string): VncSessionInfo | null {
if (!mirror) return null;
if (!desktop) return null;
const alive = mirror.pid > 0 && isProcessAlive(mirror.pid);
const alive = desktop.pid > 0 && isProcessAlive(desktop.pid);
if (!alive) {
mirror = null;
desktop = null;
return null;
}
return {
email: mirror.email,
display: mirror.display,
port: mirror.port,
pid: mirror.pid,
email: desktop.email,
display: desktop.display,
port: desktop.port,
pid: desktop.pid,
alive,
};
}