|
|
|
@@ -0,0 +1,225 @@
|
|
|
|
|
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<typeof rfbModulePromise>;
|
|
|
|
|
}
|
|
|
|
|
return rfbModulePromise!;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
export const DesktopView = ({ className, style }: DesktopViewProps) => {
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
const rfbRef = useRef<RFBInstance | null>(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<typeof setTimeout> | 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<ReturnType<typeof loadRFB>>['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 (
|
|
|
|
|
<div
|
|
|
|
|
className={className}
|
|
|
|
|
style={{ backgroundColor: '#1a1a2e', overflow: 'hidden', position: 'relative', ...style }}
|
|
|
|
|
>
|
|
|
|
|
{status === 'connecting' && (
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
|
|
|
|
Connecting to desktop...
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
{(status === 'reconnecting' || status === 'disconnected' || status === 'error') && (
|
|
|
|
|
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
|
|
|
|
|
{errorMsg || 'Disconnected from desktop'}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
<div ref={containerRef} style={{ width: '100%', height: '100%' }} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
};
|