diff --git a/src/servers/sidecar/vnc/vnc-manager.ts b/src/servers/sidecar/vnc/vnc-manager.ts index 3d5c690c..590077f7 100644 --- a/src/servers/sidecar/vnc/vnc-manager.ts +++ b/src/servers/sidecar/vnc/vnc-manager.ts @@ -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//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 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//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; }