diff --git a/src/servers/sidecar/vnc/vnc-manager.ts b/src/servers/sidecar/vnc/vnc-manager.ts index 372d93a3..4abffd32 100644 --- a/src/servers/sidecar/vnc/vnc-manager.ts +++ b/src/servers/sidecar/vnc/vnc-manager.ts @@ -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 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@.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//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 { @@ -169,7 +136,7 @@ async function isPortOpen(port: number): Promise { } } -// 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 { const deadline = Date.now() + READY_TIMEOUT_MS; while (Date.now() < deadline) { @@ -180,76 +147,47 @@ async function waitForPort(port: number): Promise { } 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).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 = { ...(process.env as Record) }; + 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, }; }