the platform stops knowing what VNC is
Desktop is a plugin as of today, and this is the half that leaves. sidecar/vnc/, api/desktop/ and apps/Desktop/ are gone from src/, along with the vocabulary that was the real problem. sidecar/protocol.ts had a VNC section: vnc:start, vnc:ensure-password, vnc:stop, four reply variants and two types. sidecar-registry.ts had startVnc, ensureVncPassword, stopVnc, isVncConnected. Four functions and a dozen message types in the platform, describing a feature that is not the platform's — the rule CLAUDE.md states most plainly, and its clearest breach. What replaced them is generic. sendSidecarCommand(kind, cmd) and isSidecarConnected(kind) know that a command has a type and an id and that a reply carries the same id. A plugin's sidecar speaks whatever it likes through them and nothing here has to be edited when it adds a message. The desktop PERMISSION leaves too, and that is the interesting part: it was kind: 'execution', a kind a plugin permission cannot have and never will, because a permission nobody can delegate is a contradiction. The plugin expresses the same refusal as ownerOnly: ['/'] — every path under it denied to anyone but the owner. Verified: /api/desktop, /api/desktop/vnc-password and a path that does not exist all resolve owner-only. registry.test.ts records the round trip rather than just dropping the key from its list. Also gone: the app store's `vnc` entry, which would have offered an older second way to install the same thing against a process PM2 no longer has, and four commented-out mounts left from 2026-08-13. The disconnect-scope test used startVnc as its stand-in for "another sidecar's long-running request". It now uses the generic door, which is the better instrument — that file is about the blast radius of a disconnect and never had anything to do with VNC. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -9,7 +9,6 @@ import { appRegistryMetas as previewMetas } from '../apps/Preview';
|
||||
import { appRegistryMetas as dashboardMetas } from '../apps/Dashboards';
|
||||
import { appRegistryMetas as chatHistoryMetas } from '../apps/ChatHistory';
|
||||
import { appRegistryMetas as widgetMetas } from '../apps/Widgets';
|
||||
import { appRegistryMetas as desktopMetas } from '../apps/Desktop';
|
||||
import { appRegistryMetas as monitorMetas } from '../apps/SystemMonitor';
|
||||
import { appRegistryMetas as qrTransferMetas } from '../apps/QrTransfer';
|
||||
import { appRegistryMetas as davMetas } from '../apps/Dav';
|
||||
@@ -27,7 +26,6 @@ export const apps = [
|
||||
...dashboardMetas,
|
||||
...chatHistoryMetas,
|
||||
...widgetMetas,
|
||||
...desktopMetas,
|
||||
...monitorMetas,
|
||||
...qrTransferMetas,
|
||||
...davMetas,
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { MonitorSmartphone } from 'lucide-react';
|
||||
|
||||
export const DesktopHeader = () => {
|
||||
return (
|
||||
<>
|
||||
<MonitorSmartphone className="h-3.5 w-3.5 shrink-0" />
|
||||
<span className="text-xs font-medium shrink-0">Remote Desktop</span>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -1,225 +0,0 @@
|
||||
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>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +0,0 @@
|
||||
import { DesktopView } from './DesktopView';
|
||||
|
||||
export const DesktopWrapper = () => <DesktopView className="h-full w-full" />;
|
||||
@@ -1,20 +0,0 @@
|
||||
import type { AppRegistryMeta } from '../../AppRegistry';
|
||||
import { MonitorSmartphone } from 'lucide-react';
|
||||
import { DesktopWrapper } from './DesktopWrapper';
|
||||
import { DesktopHeader } from './DesktopHeader';
|
||||
|
||||
export { DesktopView, type DesktopViewProps } from './DesktopView';
|
||||
|
||||
export const appRegistryMetas: AppRegistryMeta[] = [
|
||||
{
|
||||
key: 'officerdev/desktop',
|
||||
name: 'Remote Desktop',
|
||||
icon: MonitorSmartphone,
|
||||
component: DesktopWrapper,
|
||||
header: DesktopHeader,
|
||||
availableOnPanel: false,
|
||||
// noVNC scales the remote framebuffer itself and maps pointer coordinates against that; a wrapping
|
||||
// CSS transform would leave every click landing somewhere other than where it was aimed.
|
||||
zoomable: false,
|
||||
},
|
||||
];
|
||||
@@ -86,8 +86,6 @@ export type { FileType } from './apps/FileViewer';
|
||||
export { SeekBar, useSeekBar } from './apps/FileViewer/renderers/SeekBar';
|
||||
export { TerminalView } from './apps/Terminal';
|
||||
export type { TerminalViewProps } from './apps/Terminal';
|
||||
export { DesktopView } from './apps/Desktop';
|
||||
export type { DesktopViewProps } from './apps/Desktop';
|
||||
export {
|
||||
DashboardListApp,
|
||||
DashboardPreview,
|
||||
|
||||
Reference in New Issue
Block a user