diff --git a/src/servers/sidecar/vnc/vnc-manager.ts b/src/servers/sidecar/vnc/vnc-manager.ts index bc7f59a6..d13696f9 100644 --- a/src/servers/sidecar/vnc/vnc-manager.ts +++ b/src/servers/sidecar/vnc/vnc-manager.ts @@ -3,7 +3,19 @@ import { join } from 'node:path'; import type { VncStartParams, VncSessionInfo } from '../protocol'; import { getHomeDirForRole } from '@@/data-path'; -type VncSession = { +// 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; +const MIRROR_PORT = 5900; +const READY_TIMEOUT_MS = 5000; + +// x11vnc reads :0's cookie from the logged-in user's own .Xauthority, so no root is needed. +// While the greeter owns :0 the cookie lives in lightdm's file instead and mirroring fails. +const XAUTHORITY = join(process.env.HOME ?? '', '.Xauthority'); + +type MirrorSession = { email: string; username: string; display: number; @@ -11,23 +23,7 @@ type VncSession = { pid: number; }; -const sessions = new Map(); - -function findFreeDisplay(): number { - for (let n = 1; n <= 99; n++) { - if (existsSync(`/tmp/.X${n}-lock`)) continue; - // Also check no existing session uses this display - let inUse = false; - for (const s of sessions.values()) { - if (s.display === n) { - inUse = true; - break; - } - } - if (!inUse) return n; - } - throw new Error('No free display number available'); -} +let mirror: MirrorSession | null = null; function isProcessAlive(pid: number): boolean { try { @@ -38,9 +34,10 @@ function isProcessAlive(pid: number): boolean { } } -async function ensureVncEnv(homeDir: string): Promise { +async function ensureVncPassword(homeDir: string): Promise { const vncDir = join(homeDir, '.vnc'); - if (existsSync(join(vncDir, 'passwd'))) return; + const passwdFile = join(vncDir, 'passwd'); + if (existsSync(passwdFile)) return passwdFile; mkdirSync(vncDir, { recursive: true }); @@ -59,108 +56,145 @@ async function ensureVncEnv(homeDir: string): Promise { stderr: 'ignore', }); if (proc.exitCode === 0 && proc.stdout.byteLength > 0) { - await Bun.write(join(vncDir, 'passwd'), proc.stdout); + await Bun.write(passwdFile, proc.stdout); } - // Write xstartup - await Bun.write( - join(vncDir, 'xstartup'), - `#!/bin/sh\nunset SESSION_MANAGER\nunset DBUS_SESSION_BUS_ADDRESS\neval $(dbus-launch --sh-syntax)\nexport DBUS_SESSION_BUS_ADDRESS\nexec startxfce4\n`, - ); - - Bun.spawnSync({ cmd: ['chmod', '+x', join(vncDir, 'xstartup')], stdout: 'ignore', stderr: 'ignore' }); - Bun.spawnSync({ cmd: ['chmod', '600', join(vncDir, 'passwd')], stdout: 'ignore', stderr: 'ignore' }); + Bun.spawnSync({ cmd: ['chmod', '600', passwdFile], stdout: 'ignore', stderr: 'ignore' }); Bun.spawnSync({ cmd: ['chmod', '600', join(vncDir, 'password')], stdout: 'ignore', stderr: 'ignore' }); - console.log(`[vnc] lazy-provisioned VNC environment at ${vncDir}`); + console.log(`[vnc] provisioned VNC password at ${vncDir}`); + return passwdFile; +} + +async function isPortOpen(port: number): Promise { + try { + const socket = await Bun.connect({ + hostname: '127.0.0.1', + port, + socket: { data() {}, error() {} }, + }); + socket.end(); + return true; + } catch { + return false; + } +} + +// x11vnc 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) { + if (await isPortOpen(port)) return true; + await Bun.sleep(100); + } + return false; } export async function startSession(params: VncStartParams): Promise<{ port: number; display: number }> { - // If session already exists and is alive, return it - const existing = sessions.get(params.email); - if (existing && isProcessAlive(existing.pid)) { - return { port: existing.port, display: existing.display }; - } - - // Clean up stale session - if (existing) { - sessions.delete(params.email); + if (mirror && isProcessAlive(mirror.pid)) { + return { port: mirror.port, display: mirror.display }; } + mirror = null; const homeDir = getHomeDirForRole(params.email, params.role); - const resolution = params.resolution ?? '1920x1080'; - const display = findFreeDisplay(); - const port = 5900 + display; + const passwdFile = await ensureVncPassword(homeDir); - // Lazy-provision VNC environment if missing - await ensureVncEnv(homeDir); + if (!existsSync(XAUTHORITY)) { + throw new Error(`No X authority at ${XAUTHORITY} — nobody is logged in on ${MIRROR_DISPLAY}`); + } - // Spawn VNC server const proc = Bun.spawn({ - cmd: ['vncserver', `:${display}`, '-geometry', resolution, '-depth', '24', '-localhost', 'yes'], - env: { ...process.env, HOME: homeDir }, - stdout: 'pipe', + cmd: [ + 'x11vnc', + '-display', + MIRROR_DISPLAY, + '-auth', + XAUTHORITY, + '-rfbport', + String(MIRROR_PORT), + '-rfbauth', + passwdFile, + '-localhost', + '-forever', + '-shared', + // :0 is 4K; halving the framebuffer keeps the stream sane over the tailnet + '-scale', + '0.5', + '-noxdamage', + '-quiet', + ], + stdout: 'ignore', stderr: 'pipe', }); - const exitCode = await proc.exited; - if (exitCode !== 0) { - const stderr = await new Response(proc.stderr).text(); - throw new Error(`vncserver failed (exit ${exitCode}): ${stderr.trim()}`); + // Drain stderr so the pipe cannot fill and stall x11vnc; keep the tail for errors + let stderrTail = ''; + (async () => { + const reader = (proc.stderr as ReadableStream).getReader(); + const decoder = new TextDecoder(); + try { + while (true) { + const { done, value } = await reader.read(); + if (done) break; + stderrTail = (stderrTail + decoder.decode(value, { stream: true })).slice(-2000); + } + } catch { + // process ended + } + })(); + + if (!(await waitForPort(MIRROR_PORT))) { + try { + proc.kill(); + } catch { + // already dead + } + throw new Error(`x11vnc failed to listen on ${MIRROR_PORT}: ${stderrTail.trim() || 'timed out'}`); } - // Read PID from lock file - let pid = 0; - const lockFile = `/tmp/.X${display}-lock`; - if (existsSync(lockFile)) { - const content = await Bun.file(lockFile).text(); - pid = parseInt(content.trim(), 10) || 0; + mirror = { + email: params.email, + username: params.username ?? '', + display: MIRROR_DISPLAY_NUM, + port: MIRROR_PORT, + pid: proc.pid, + }; + console.log(`[vnc] mirroring ${MIRROR_DISPLAY} on port ${MIRROR_PORT} (pid ${proc.pid})`); + + return { port: MIRROR_PORT, display: MIRROR_DISPLAY_NUM }; +} + +export function stopSession(_email: string): void { + if (!mirror) return; + + try { + process.kill(mirror.pid); + } catch { + // already dead } - sessions.set(params.email, { email: params.email, username: params.username ?? '', display, port, pid }); - console.log(`[vnc] started session for ${params.email} on :${display} (port ${port}, pid ${pid})`); - - return { port, display }; + console.log(`[vnc] stopped mirror of ${MIRROR_DISPLAY} (pid ${mirror.pid})`); + mirror = null; } -export function stopSession(email: string): void { - const session = sessions.get(email); - if (!session) return; +export function getSession(_email: string): VncSessionInfo | null { + if (!mirror) return null; - const homeDir = getHomeDirForRole(email, null); - - Bun.spawnSync({ - cmd: ['vncserver', '-kill', `:${session.display}`], - env: { ...process.env, HOME: homeDir }, - stdout: 'ignore', - stderr: 'ignore', - }); - - sessions.delete(email); - console.log(`[vnc] stopped session for ${email} on :${session.display}`); -} - -export function getSession(email: string): VncSessionInfo | null { - const session = sessions.get(email); - if (!session) return null; - - const alive = session.pid > 0 && isProcessAlive(session.pid); + const alive = mirror.pid > 0 && isProcessAlive(mirror.pid); if (!alive) { - sessions.delete(email); + mirror = null; return null; } return { - email: session.email, - display: session.display, - port: session.port, - pid: session.pid, + email: mirror.email, + display: mirror.display, + port: mirror.port, + pid: mirror.pid, alive, }; } export function stopAll(): void { - for (const email of [...sessions.keys()]) { - stopSession(email); - } + stopSession(''); }