import type { CSSProperties } from 'react'; import { useEffect, useRef, useState } from 'react'; import { useClient } from 'hooks/useClient'; import { useMounted } from 'hooks/useMounted'; export type DesktopViewProps = { className?: string; style?: CSSProperties; }; // The BUNDLE, not public/novnc/rfb.js. That entry is unbundled noVNC source: 42 ES modules whose // imports nest six levels deep, so a native dynamic import made the browser walk the graph — fetch, // parse, discover the next level, fetch again. Six sequential round trips and 42 requests before the // desktop could even begin connecting, which over the tailnet is seconds, and a hard refresh pays it // every time. One file is one round trip. // // Regenerate after updating vendored noVNC: // bun build public/novnc/rfb.js --outfile=public/novnc/rfb.bundle.js --format=esm --minify --target=browser const NOVNC_URL = '/novnc/rfb.bundle.js'; const buildWsUrl = () => { const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const token = localStorage.getItem('BEARER_TOKEN') ?? ''; return `${protocol}//${window.location.host}/api/desktop/ws?token=${encodeURIComponent(token)}`; }; type RFBInstance = { resizeSession: boolean; scaleViewport: boolean; showDotCursor: boolean; focusOnClick: boolean; disconnect: () => void; sendCredentials: (creds: { password: string }) => void; addEventListener: (type: string, listener: (ev: CustomEvent) => void) => void; }; let rfbModulePromise: Promise<{ default: new (target: HTMLElement, url: string, options?: { credentials?: { password?: string } }) => RFBInstance; }> | null = null; const loadRFB = () => { if (!rfbModulePromise) { rfbModulePromise = import(/* @vite-ignore */ NOVNC_URL) as NonNullable; } return rfbModulePromise!; }; export const DesktopView = ({ className, style }: DesktopViewProps) => { const containerRef = useRef(null); const rfbRef = useRef(null); const isMounted = useMounted(); const client = useClient(); const [status, setStatus] = useState<'connecting' | 'connected' | 'reconnecting' | 'disconnected' | 'error'>( 'connecting', ); const [errorMsg, setErrorMsg] = useState(''); useEffect(() => { if (!isMounted) return; const container = containerRef.current; if (!container) return; let disposed = false; let attempts = 0; let retryTimer: ReturnType | null = null; // Tearing an RFB down makes it fire its own `disconnect`, and a bad password fires `securityfailure` // and then `disconnect` too. Both would otherwise be read as "officer went away, reattach". let generation = 0; let fatal = false; const MAX_ATTEMPTS = 5; const RETRY_DELAYS = [1000, 2000, 3000, 5000, 5000]; const detach = () => { const rfb = rfbRef.current; rfbRef.current = null; generation++; if (!rfb) return; try { rfb.disconnect(); } catch { /* ignore */ } }; // x11vnc mirrors :0 with `-forever`, so the desktop itself outlives this socket — losing it means // officer restarted under us, not that the session ended. Reattach instead of parking on // "Disconnected" until someone reopens the panel. const retry = (reason: string) => { if (disposed || fatal || retryTimer) return; detach(); if (attempts >= MAX_ATTEMPTS) { setStatus('disconnected'); setErrorMsg(reason); return; } const delay = RETRY_DELAYS[attempts] ?? 5000; attempts++; setStatus('reconnecting'); setErrorMsg(`${reason} — reconnecting (${attempts}/${MAX_ATTEMPTS})...`); retryTimer = setTimeout(() => { retryTimer = null; void connect(); }, delay); }; const connect = async () => { const gen = ++generation; const isCurrent = () => !disposed && gen === generation; let password = ''; try { const res = await client.get<{ password: string }>('/desktop/vnc-password'); password = res.password; } catch { if (!isCurrent()) return; // Officer being down is the common case here, and it comes back — so this is a retry, not a // dead end. A sidecar that is genuinely missing still ends up at "Disconnected" after five. retry('Failed to fetch VNC password'); return; } if (!isCurrent()) return; let RFB: Awaited>['default']; try { const mod = await loadRFB(); RFB = mod.default; } catch (err) { console.error('[desktop] Failed to load noVNC:', err); if (!isCurrent()) return; fatal = true; setStatus('error'); setErrorMsg('Failed to load noVNC library'); return; } if (!isCurrent()) return; const wsUrl = buildWsUrl(); const rfb = new RFB(container, wsUrl, { credentials: { password }, }); // The mirror cannot resize — it reflects the physical screen — so resizeSession is off. // scaleViewport STAYS ON, and it is load-bearing: noVNC maps a click as // (clientX - canvasRect.left) / display._scale, and autoscale() is the only thing that sets the // canvas's displayed size and _scale in the same call. Turn it off and _scale is pinned at 1 while // the canvas is displayed at some other size, which doubled every coordinate on 2026-08-01. rfb.resizeSession = false; rfb.scaleViewport = true; rfb.focusOnClick = true; // noVNC hides the browser's own cursor over the canvas and draws the REMOTE cursor in its place. // Until the server sends a cursor shape its image is RFB.cursors.none — so nothing is drawn, the // real cursor is hidden, and the pointer becomes invisible. Pointer coordinates are unaffected // (measured: x 5..1108, y 11..714 against a 1109x715 session — a perfect 1:1 map), which is why // this looked like a desync when it was only ever a visibility problem: the clicks were landing // correctly all along. A dot is better than nothing when the server has not supplied a shape. rfb.showDotCursor = true; rfbRef.current = rfb; rfb.addEventListener('connect', () => { if (!isCurrent()) return; attempts = 0; setErrorMsg(''); setStatus('connected'); }); // Every disconnect we did not ask for is worth retrying, clean or not: officer closing its side // tidily during a restart still reports `clean`, and the desktop behind it is still there. rfb.addEventListener('disconnect', () => { if (!isCurrent()) return; rfbRef.current = null; retry('Connection lost'); }); rfb.addEventListener('credentialsrequired', () => { rfb.sendCredentials({ password }); }); rfb.addEventListener('securityfailure', (ev: CustomEvent) => { if (!isCurrent()) return; fatal = true; setStatus('error'); setErrorMsg(ev.detail.reason || 'Security failure'); }); }; // Coming back to the tab after the retries ran out should try once more rather than stay dead. const handleVisibility = () => { if (disposed || fatal || document.visibilityState !== 'visible') return; if (rfbRef.current || retryTimer) return; attempts = 0; setStatus('connecting'); void connect(); }; document.addEventListener('visibilitychange', handleVisibility); void connect(); return () => { disposed = true; document.removeEventListener('visibilitychange', handleVisibility); if (retryTimer) clearTimeout(retryTimer); detach(); }; }, [isMounted, client]); return (
{status === 'connecting' && (
Connecting to desktop...
)} {(status === 'reconnecting' || status === 'disconnected' || status === 'error') && (
{errorMsg || 'Disconnected from desktop'}
)}
); };