desktop, extracted from the platform

x11vnc against display :0 — the session the owner is actually logged into, not a second one Officer
installs. sidecar/, api/, web/ and the host setup script, moved out of src/ whole.

Dormant when it moved: the websocket was commented out in server.tsx and the router in hono.ts since
2026-08-13, and no officer-vnc process was running on any machine. Nothing regressed and nothing was
restored — this took switched-off code out of the platform.

The point of the move is what the platform stopped knowing. Its sidecar/protocol.ts carried a VNC
section — vnc:start, vnc:ensure-password, vnc:stop, four reply variants, VncStartParams,
VncSessionInfo — and sidecar-registry.ts carried startVnc, ensureVncPassword, stopVnc and
isVncConnected. That is the platform speaking a plugin's vocabulary, which CLAUDE.md names as a rule
and this was the clearest breach of. All of it is in sidecar/protocol.ts here now.

The permission made a round trip worth recording. It was kind: 'execution' — "runs as the server owner
and cannot be shared" — and a plugin permission has no kind, deliberately, because a permission nobody
can delegate is a contradiction. So the property is expressed as ownerOnly: ['/'] instead: every path
under this plugin refused to anyone but the owner. Same answer, reached from the paths rather than
from a kind, and the first real use of a mechanism that landed the same day. The property is real —
there is one physical screen and it is the owner's.

Not finished, and BLOCKERS.md says so rather than the commit implying otherwise: the websocket is
dormant because a plugin cannot claim a WebSocket provider, host setup needs a reboot and no manifest
can express that, and there is no artwork.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 00:41:24 +00:00
co-authored by Claude Opus 5
commit b91d4df21d
16 changed files with 1284 additions and 0 deletions
+10
View File
@@ -0,0 +1,10 @@
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>
</>
);
};
+225
View File
@@ -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>
);
};
+3
View File
@@ -0,0 +1,3 @@
import { DesktopView } from './DesktopView';
export const DesktopWrapper = () => <DesktopView className="h-full w-full" />;
+9
View File
@@ -0,0 +1,9 @@
import type { LayoutNode } from 'officerdev';
// One panel, filling the route. The desktop is a single framebuffer with its own header — there is no
// second thing to put beside it, and splitting the pane would only shrink the screen being mirrored.
export const defaultLayout: LayoutNode = {
type: 'panel',
id: 'desktop-root',
appType: 'officerdev/desktop',
};
+19
View File
@@ -0,0 +1,19 @@
// `panels.ts`, not `.tsx` — discovery checks for that exact name.
import type { AppRegistryMeta } from 'officerdev';
import { MonitorSmartphone } from 'lucide-react';
import { DesktopWrapper } from './DesktopWrapper';
import { DesktopHeader } from './DesktopHeader';
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,
},
];