diff --git a/src/server.tsx b/src/server.tsx index 6324821f..53f3144b 100644 --- a/src/server.tsx +++ b/src/server.tsx @@ -22,7 +22,6 @@ import { registerChatPushNotifications } from './servers/api/chat/notify-push'; import { startNudger } from './servers/api/chat/nudger'; import { taskRunnerWebsocket } from './servers/api/tasks/task-executor'; import { pipelineWebsocket } from './servers/api/tasks/pipeline-executor'; -// import { desktopWebsocket } from './servers/api/desktop/websocket'; import officerWeb from './apps/officer-web/index.gen.html'; // import { startBrowserRelay } from './servers/api/browser/relay'; // switched off — see below import { registerSidecar, unregisterSidecar, handleSidecarMessage } from './servers/sidecar-registry'; @@ -57,7 +56,7 @@ type WSData = { userId: number; email: string; username: string; - provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline' | 'desktop' | 'sidecar'; + provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline' | 'sidecar'; sessionId?: string; cwd?: string; command?: string; @@ -161,7 +160,6 @@ const handlers: Record = { 'chat-notify': chatNotifyWebsocket, 'task-runner': taskRunnerWebsocket, pipeline: pipelineWebsocket, - // desktop: desktopWebsocket, sidecar: sidecarWebsocket, }; @@ -318,7 +316,7 @@ console.log( async function upgradeWs( req: Request, server: any, - provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline' | 'desktop', + provider: 'terminal' | 'chat' | 'chat-notify' | 'task-runner' | 'pipeline', ) { const token = new URL(req.url).searchParams.get('token'); if (!token) return new Response('Unauthorized', { status: 401 }); @@ -449,7 +447,6 @@ const server = serve({ '/api/terminal/ws': (req, server) => upgradeWs(req, server, 'terminal'), '/api/chat/ws': (req, server) => upgradeWs(req, server, 'chat'), '/api/chat/notify/ws': (req, server) => upgradeWs(req, server, 'chat-notify'), - // '/api/desktop/ws': (req, server) => upgradeWs(req, server, 'desktop'), // CalDAV/CardDAV. These live OUTSIDE /api because DAV clients are given a bare domain and probe // fixed, spec-defined paths — `/.well-known/caldav` unauthenticated, before they hold any // credential at all. They need naming explicitly here or the `/*` SPA fallback below swallows them diff --git a/src/servers/api/desktop/rest.ts b/src/servers/api/desktop/rest.ts deleted file mode 100644 index 15ff05de..00000000 --- a/src/servers/api/desktop/rest.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { createRouter } from '../../create-router'; -import * as sidecar from '@@/sidecar-registry'; - -export const desktopRouter = createRouter(); - -// The desktop UI asks for the password before it can open the WebSocket — and that WebSocket is what -// starts the VNC session. So this cannot wait for a session to exist: on a fresh install nothing has -// ever written the password, and answering "not configured" deadlocked the page permanently. -// -// Officer does not read the password file itself. It lives in the owner's ~/.vnc, next to the rfbauth -// file x11vnc authenticates against, and the sidecar is the process that writes both — so it is the -// process that answers for them too. `vnc:ensure-password` is idempotent: it returns the existing -// pair when both files are already there, and provisions them when they are not. -desktopRouter.get('/vnc-password', async (ctx) => { - const user = ctx.get('user'); - - if (!sidecar.isVncConnected()) { - return ctx.json({ error: 'VNC sidecar is not connected' }, 503); - } - - try { - const password = await sidecar.ensureVncPassword(user.email); - return ctx.json({ password }); - } catch (err) { - const message = err instanceof Error ? err.message : 'Could not provision the VNC password'; - console.error('[desktop] VNC password provisioning failed:', message); - return ctx.json({ error: message }, 500); - } -}); - -// GET /vnc-status is gone. It was never called — DesktopView asks only for /vnc-password — and it was -// the sole reason the `vnc:status` command existed on the sidecar wire protocol at all. Noted as dead -// by docs/sidecar-audit-2026-07.md months ago. diff --git a/src/servers/api/desktop/websocket.ts b/src/servers/api/desktop/websocket.ts deleted file mode 100644 index 3badc162..00000000 --- a/src/servers/api/desktop/websocket.ts +++ /dev/null @@ -1,122 +0,0 @@ -import type { ServerWebSocket } from 'bun'; -import type { Socket } from 'bun'; -import * as sidecar from '@@/sidecar-registry'; - -type WSData = { userId: number; email: string; username: string; sessionId?: string }; - -type VncSession = { - tcpSocket: Socket<{ ws: ServerWebSocket }> | null; - pendingMessages: Buffer[]; -}; - -const sessions = new Map, VncSession>(); - -export const desktopWebsocket = { - async open(ws: ServerWebSocket) { - const session: VncSession = { tcpSocket: null, pendingMessages: [] }; - sessions.set(ws, session); - - let port: number; - try { - const result = await sidecar.startVnc({ - email: ws.data.email, - username: ws.data.username, - }); - port = result.port; - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to start VNC session'; - console.error('[desktop] VNC start failed:', message); - sessions.delete(ws); - ws.close(4004, 'VNC server unavailable'); - return; - } - - try { - const tcpSocket = await Bun.connect({ - hostname: '127.0.0.1', - port, - socket: { - data(_socket, data) { - try { - ws.sendBinary(Buffer.from(data)); - } catch { - // ws already closed - } - }, - close() { - sessions.delete(ws); - try { - ws.close(); - } catch { - /* ignore */ - } - }, - error(_socket, err) { - console.error('[desktop] TCP error:', err.message); - sessions.delete(ws); - try { - ws.close(); - } catch { - /* ignore */ - } - }, - connectError(_socket, err) { - console.error('[desktop] TCP connect error:', err.message); - sessions.delete(ws); - try { - ws.close(4004, 'VNC server unavailable'); - } catch { - /* ignore */ - } - }, - open(socket) { - for (const msg of session.pendingMessages) { - socket.write(msg); - } - session.pendingMessages = []; - }, - }, - data: { ws }, - }); - - session.tcpSocket = tcpSocket; - } catch (err) { - const message = err instanceof Error ? err.message : 'Failed to connect to VNC server'; - console.error('[desktop] VNC connect failed:', message); - sessions.delete(ws); - ws.close(4004, 'VNC server unavailable'); - } - }, - - message(ws: ServerWebSocket, raw: string | Buffer) { - const session = sessions.get(ws); - if (!session) return; - - const data = typeof raw === 'string' ? Buffer.from(raw) : Buffer.from(raw); - - if (!session.tcpSocket) { - session.pendingMessages.push(data); - return; - } - - try { - session.tcpSocket.write(data); - } catch { - // ignore - } - }, - - close(ws: ServerWebSocket) { - const session = sessions.get(ws); - if (session?.tcpSocket) { - try { - session.tcpSocket.end(); - } catch { - // ignore - } - } - sessions.delete(ws); - }, - - drain() {}, -}; diff --git a/src/servers/app-store/catalogue.test.ts b/src/servers/app-store/catalogue.test.ts index adc13e74..2abdf7db 100644 --- a/src/servers/app-store/catalogue.test.ts +++ b/src/servers/app-store/catalogue.test.ts @@ -111,7 +111,7 @@ describe('member provisioning is declared for every entry', () => { // against an API that does not exist. Transmission was an example until 2026-08-18 and slskd until // 2026-08-19, when they became `plugins/transmission/` and `plugins/soulseek/` — hence the list // rather than a single id: it must stay non-empty, or this assertion passes by iterating nothing. - for (const id of ['vnc', 'notify']) expect(byId(id)!.members).toBe('none'); + for (const id of ['notify']) expect(byId(id)!.members).toBe('none'); }); // A test asserting the vault is invite-only lived here until 2026-08-18, when it left to become diff --git a/src/servers/app-store/catalogue.ts b/src/servers/app-store/catalogue.ts index f1138ca5..1f8d7ccd 100644 --- a/src/servers/app-store/catalogue.ts +++ b/src/servers/app-store/catalogue.ts @@ -239,20 +239,9 @@ export const CATALOGUE: CatalogueEntry[] = [ permission: 'notify', configFields: [], }, - { - id: 'vnc', - ui: { name: 'Desktop', icon: 'MonitorSmartphone', color: '#ec4899', rootRoute: '/desktop', routes: ['/desktop'] }, - process: 'officer-vnc', - label: 'Desktop', - summary: 'Mirror this machine’s display in the browser', - members: 'none', - modes: ['config'], - permission: 'desktop', - // x11vnc against an Xorg display. There is nothing to mirror on a headless box or on macOS, so the - // store should say so rather than install something that starts and immediately fails. - requires: 'linux-display', - configFields: [], - }, + // The `vnc` entry left with the desktop plugin on 2026-08-21. The app store predates the marketplace, + // and a plugin is installed through that now — an entry here would offer a second, older way to install + // the same thing, against a process PM2 no longer has. ]; export const byId = (id: string): CatalogueEntry | undefined => CATALOGUE.find((e) => e.id === id); diff --git a/src/servers/app-store/members.test.ts b/src/servers/app-store/members.test.ts index fbfb200f..e0985b7f 100644 --- a/src/servers/app-store/members.test.ts +++ b/src/servers/app-store/members.test.ts @@ -12,7 +12,7 @@ describe('what adding a member would do', () => { it('predicts the outcome per service without contacting anything', () => { expect(plannedOutcome(byId('memos')!)).toBe('provisioned'); expect(plannedOutcome(inviteOnly)).toBe('invited'); - expect(plannedOutcome(byId('vnc')!)).toBe('not-applicable'); + expect(plannedOutcome(byId('notify')!)).toBe('not-applicable'); }); }); @@ -24,7 +24,7 @@ describe('which installed services have work to do', () => { // two. It has been re-pointed five times as services left — jellyfin, invoiceshelf and slskd to // `plugins/`, caldav to CORE — and memos is the last one standing. If memos ever leaves too, this // wants a literal fixture like `installer.test.ts` has, not a sixth re-point. - const ids = servicesNeedingMemberProvision(['memos', 'vnc']).map((e) => e.id); + const ids = servicesNeedingMemberProvision(['memos', 'notify']).map((e) => e.id); expect(ids.sort()).toEqual(['memos']); }); diff --git a/src/servers/hono.ts b/src/servers/hono.ts index 45a52106..b20a823e 100644 --- a/src/servers/hono.ts +++ b/src/servers/hono.ts @@ -40,7 +40,6 @@ import { queueRouter } from './api/queue/queue'; // The browser relay is switched off — see server.tsx. Restoring this mount means restoring the // registry's claim on '/browser' in the same commit, or assertPermissionTotality refuses to boot. // import { browserRouter } from './api/browser/router'; -// import { desktopRouter } from './api/desktop/rest'; import { bugReportRouter } from './api/bug-report/bug-report'; import { agentStatusRouter } from './api/agent-status/router'; import { chatRouter } from './api/chat/chat'; @@ -156,7 +155,6 @@ const PROTECTED_MOUNTS: [prefix: string, router: ReturnType ['/command-hq', commandHqRouter], ['/pipeline-jobs', pipelineJobsRouter], ['/jobs', pipelineJobsRouter], // unified jobs API (script + pipeline); /pipeline-jobs kept for the existing UI - // ['/desktop', desktopRouter], // plugin — switched off 2026-08-13 ]; /** Every prefix served behind the account gate. Read by the permission totality check at boot. */ diff --git a/src/servers/permissions/registry.test.ts b/src/servers/permissions/registry.test.ts index 47326fcf..79b614dd 100644 --- a/src/servers/permissions/registry.test.ts +++ b/src/servers/permissions/registry.test.ts @@ -212,10 +212,12 @@ describe('self-service routes', () => { describe('kinds', () => { test('execution permissions are never grantable', () => { const grantable = new Set(GRANTABLE_PERMISSIONS.map((c) => c.key)); - // `files` left this list on 2026-08-11, then `terminal` and `chat` the same day — see the tests below and - // docs/per-user-linux-accounts.md. Everything still here runs as the OWNER in the owner's home with no - // per-caller resolution at all. - for (const key of ['tasks', 'desktop', 'browser', 'items']) { + // `files` left this list on 2026-08-11, then `terminal` and `chat` the same day — see the tests below + // and docs/per-user-linux-accounts.md. `desktop` left on 2026-08-21, differently: it became a plugin, + // and a plugin permission has no kind at all. What made it execution — one physical screen, the + // owner's — is now `ownerOnly: ['/']` in that plugin's manifest, which is the same refusal reached by + // a different route. Everything still here runs as the OWNER in the owner's home. + for (const key of ['tasks', 'browser', 'items']) { expect(PERMISSION_BY_KEY.get(key)?.kind).toBe('execution'); expect(grantable.has(key)).toBe(false); } diff --git a/src/servers/permissions/registry.ts b/src/servers/permissions/registry.ts index 4e857014..77d74621 100644 --- a/src/servers/permissions/registry.ts +++ b/src/servers/permissions/registry.ts @@ -322,16 +322,10 @@ const CORE_REGISTRY: Permission[] = [ kind: 'execution', api: ['/skills', '/tools', '/agents', '/processes', '/rescan'], }, - { - key: 'desktop', - label: 'Desktop', - description: "The server owner's physical screen", - kind: 'execution', - // api: ['/desktop'], // plugin — switched off 2026-08-13 - // ws: ['desktop'], - api: [], - routes: ['/desktop'], - }, + // `desktop` left with the plugin on 2026-08-21. It is the first permission to make the round trip from + // `kind: 'execution'` to a plugin's own, and the property survived the move: a plugin permission has no + // kind, so "runs as the owner and cannot be shared" is expressed as `ownerOnly: ['/']` in the plugin's + // manifest instead. Same answer, stated where the paths are. // `/browser` — the Chrome-extension relay — is unmounted as of 2026-08-13 and dropped from this claim, // because check 2 of assertPermissionTotality refuses to boot on a permission claiming a prefix nothing // serves. Put both back together or neither. diff --git a/src/servers/sidecar-registry.test.ts b/src/servers/sidecar-registry.test.ts index 1353af73..eaecc038 100644 --- a/src/servers/sidecar-registry.test.ts +++ b/src/servers/sidecar-registry.test.ts @@ -6,7 +6,7 @@ // direction. Sidecars restart independently by design; that is the whole point of them being PM2 peers. import { afterEach, describe, expect, test } from 'bun:test'; -import { registerSidecar, unregisterSidecar, spawnClaude, startVnc } from './sidecar-registry'; +import { registerSidecar, unregisterSidecar, spawnClaude, sendSidecarCommand } from './sidecar-registry'; /** A socket that records what was written and never answers, so requests stay pending. */ function silentSocket() { @@ -55,7 +55,10 @@ const CLAUDE_PARAMS = { sessionKey: 'test-session', }; -const VNC_PARAMS = { email: 'owner@example.com', username: 'owner' }; +// A stand-in for "some other sidecar's long-running request". This was `startVnc` until the desktop +// plugin took it on 2026-08-21; the generic door is the better instrument anyway, since what this file +// tests is the SCOPE of a disconnect and never had anything to do with VNC. +const otherRequest = (id: string) => sendSidecarCommand('vnc', { type: 'vnc:start', id }); describe('unregisterSidecar', () => { test('an unrelated sidecar disconnecting leaves other sidecars’ requests in flight', async () => { @@ -80,7 +83,7 @@ describe('unregisterSidecar', () => { test('a sidecar’s own disconnect still rejects its own request', async () => { const vnc = register('vnc', ['vnc']); - const start = startVnc(VNC_PARAMS); + const start = otherRequest('test-start'); const state = settleState(start); await awaitSend(vnc); @@ -94,7 +97,7 @@ describe('unregisterSidecar', () => { register('agent', ['claude']); const vnc = register('vnc', ['vnc']); - const desktop = startVnc(VNC_PARAMS); + const desktop = otherRequest('test-desktop'); const desktopState = settleState(desktop); await awaitSend(vnc); diff --git a/src/servers/sidecar-registry.ts b/src/servers/sidecar-registry.ts index ab5600c4..6b8e0491 100644 --- a/src/servers/sidecar-registry.ts +++ b/src/servers/sidecar-registry.ts @@ -9,7 +9,6 @@ import type { LiveClaudeSession, LiveOpenCodeSession, OpenCodeRunParams, - VncStartParams, } from './sidecar/protocol'; import type { SidecarRegistration } from './sidecar/registration-protocol'; import type { TurnMessage } from './api/chat/types'; @@ -170,10 +169,43 @@ export function on(eventType: string, handler: EventHandler): () => void { } // ── Command sending ── +// +// ── The generic door, for plugins ── +// +// `sendCommand` below is private and its `SidecarCommand` union names every message the PLATFORM knows +// about. That was fine while every sidecar was the platform's. A plugin's sidecar speaks a vocabulary +// this file must not learn — the desktop extraction is what made that concrete: `startVnc`, +// `ensureVncPassword` and a dozen `vnc:*` variants lived here, in the platform, describing a plugin. +// +// So a plugin drives its own sidecar through `sendSidecarCommand`, which knows only that a command has a +// type and an id and that a reply carries the same id. The vocabulary stays in the plugin, and nothing +// here has to be edited when a plugin adds a message. const DEFAULT_TIMEOUT_MS = 30_000; const LONG_TIMEOUT_MS = 6 * 60 * 1000; +/** A message to a sidecar. `id` is what the reply is matched on; everything else is the plugin's. */ +export type PluginSidecarCommand = { type: string; id: string } & Record; + +/** + * Send a command to whichever sidecar handles `kind`, and wait for the reply with the same id. + * + * The plugin-facing half of `sendCommand`. Rejects when no sidecar handles that kind — which for a + * plugin means its own process is not running, and is a better error than a timeout. + */ +export function sendSidecarCommand( + kind: string, + cmd: PluginSidecarCommand, + timeoutMs = DEFAULT_TIMEOUT_MS, +): Promise { + return sendCommand(kind, cmd as unknown as SidecarCommand, timeoutMs) as Promise; +} + +/** Is a sidecar handling `kind` connected right now? */ +export function isSidecarConnected(kind: string): boolean { + return !!findSidecarHandling(kind); +} + function sendCommand(kind: string, cmd: SidecarCommand, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { return new Promise((resolve, reject) => { const sc = findSidecarHandling(kind); @@ -482,28 +514,7 @@ export function onOpenCodeSession(handler: (sessionKey: string, sessionId: strin // Nothing here any more. The pty sidecar serves its own listener; `/api/terminal/*` is a proxy and // `/api/terminal/ws` a byte relay, both keyed off the `pty:server` port like every other HTTP sidecar. -// ── VNC ── - -export async function startVnc(params: VncStartParams): Promise<{ port: number; display: number }> { - const res = await sendCommand('vnc', { type: 'vnc:start', id: nextId(), params }); - if (res.type === 'vnc:started') return { port: res.port, display: res.display }; - if (res.type === 'vnc:error') throw new Error(res.error); - throw new Error('Unexpected response'); -} - -// Provision the VNC password without starting a server. The desktop UI needs it before it can open -// the WebSocket that would start one, so asking vnc:start here would be circular. -export async function ensureVncPassword(email: string): Promise { - const res = await sendCommand('vnc', { type: 'vnc:ensure-password', id: nextId(), email }); - if (res.type === 'vnc:password') return res.password; - if (res.type === 'vnc:error') throw new Error(res.error); - throw new Error('Unexpected response from VNC sidecar'); -} - -export function stopVnc(email: string): void { - sendFire('vnc', { type: 'vnc:stop', id: nextId(), email }); -} - -export function isVncConnected(): boolean { - return findSidecarHandling('vnc') !== undefined; -} +// The VNC helpers that were here — startVnc, ensureVncPassword, stopVnc, isVncConnected — left with the +// desktop plugin on 2026-08-21. They were the clearest example of the platform knowing what a plugin was +// about: four functions and a dozen `vnc:*` message types describing a feature that is not the +// platform's. What replaced them is `sendSidecarCommand` above, which is generic. diff --git a/src/servers/sidecar/protocol.ts b/src/servers/sidecar/protocol.ts index 1f0190e2..58f9c136 100644 --- a/src/servers/sidecar/protocol.ts +++ b/src/servers/sidecar/protocol.ts @@ -118,12 +118,7 @@ export type SidecarCommand = // Which OpenCode turns are running right now. The counterpart of `claude:list`, and thinner for a // reason: OpenCode has no warm session between turns, so there is nothing to report but the running // ones — see `listRunningServeTurns`. - | { type: 'opencode:list'; id: string } - // VNC - | { type: 'vnc:start'; id: string; params: VncStartParams } - // Provision the VNC password without starting a server — the UI needs it before it can connect - | { type: 'vnc:ensure-password'; id: string; email: string } - | { type: 'vnc:stop'; id: string; email: string }; + | { type: 'opencode:list'; id: string }; // ── Responses/Events (sidecar → API server) ── @@ -158,11 +153,7 @@ export type SidecarEvent = | { type: 'claude:session-key'; id: string; sessionKey: string | null } | { type: 'claude:sessions'; id: string; sessions: LiveClaudeSession[] } | { type: 'opencode:sessions'; id: string; sessions: LiveOpenCodeSession[] } - // VNC - | { type: 'vnc:started'; id: string; port: number; display: number } - | { type: 'vnc:password'; id: string; password: string } - | { type: 'vnc:stopped'; id: string } - | { type: 'vnc:error'; id: string; error: string } + // OpenCode — the sidecar reports where its `opencode serve` is listening (random port) on connect | { type: 'opencode:server'; port: number } // OpenCode turn streaming (analog of claude:*): spawned ack, per-message stream, session id report @@ -290,21 +281,9 @@ export type OpenCodeRunParams = { durable?: boolean; // commit turn output to chat_session_events (default true) — see ClaudeSpawnStreamingParams }; -// ── VNC types ── - -export type VncStartParams = { - email: string; - username: string; - resolution?: string; -}; - -export type VncSessionInfo = { - email: string; - display: number; - port: number; - pid: number; - alive: boolean; -}; +// The VNC command variants and their types left with the desktop plugin on 2026-08-21. A plugin's +// sidecar speaks its own vocabulary through `sendSidecarCommand`, which knows only that a command has a +// type and an id — see the note above it in sidecar-registry.ts. // ── PTY ── // diff --git a/src/servers/sidecar/vnc/index.ts b/src/servers/sidecar/vnc/index.ts deleted file mode 100644 index 6e946826..00000000 --- a/src/servers/sidecar/vnc/index.ts +++ /dev/null @@ -1,72 +0,0 @@ -import type { SidecarCommand, SidecarEvent } from '../protocol'; -import * as vncManager from './vnc-manager'; -import { createSidecarConnector } from '../connect'; -import { getOwnerHomeDir } from '@@/data-path'; -import { API_URL } from '../../officer-url.mjs'; - -// ── Command handlers ── - -type ReplyFn = (msg: SidecarEvent) => void; - -async function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { - switch (cmd.type) { - case 'ping': - reply({ type: 'pong', id: cmd.id }); - break; - - case 'vnc:start': { - try { - const { port, display } = await vncManager.startSession(cmd.params); - reply({ type: 'vnc:started', id: cmd.id, port, display }); - } catch (err) { - reply({ type: 'vnc:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) }); - } - break; - } - - case 'vnc:ensure-password': { - try { - const { password } = await vncManager.ensureVncPassword(getOwnerHomeDir(cmd.email)); - reply({ type: 'vnc:password', id: cmd.id, password }); - } catch (err) { - reply({ type: 'vnc:error', id: cmd.id, error: err instanceof Error ? err.message : String(err) }); - } - break; - } - - case 'vnc:stop': - vncManager.stopSession(cmd.email); - reply({ type: 'vnc:stopped', id: cmd.id }); - break; - - default: - reply({ - type: 'error', - id: (cmd as SidecarCommand).id, - error: `Unknown command type: ${(cmd as Record).type}`, - }); - } -} - -// ── Connect to API server ── - -const connection = createSidecarConnector({ - apiUrl: `${API_URL}/api/sidecar/register`, - name: 'vnc', - handles: ['vnc'], - onCommand(cmd, reply) { - handleCommand(cmd as SidecarCommand, reply as ReplyFn); - }, -}); - -// ── Graceful shutdown ── - -function shutdown(signal: string) { - console.log(`[vnc] ${signal} received, stopping all sessions...`); - vncManager.stopAll(); - connection.destroy(); - process.exit(0); -} - -process.on('SIGTERM', () => shutdown('SIGTERM')); -process.on('SIGINT', () => shutdown('SIGINT')); diff --git a/src/servers/sidecar/vnc/vnc-manager.ts b/src/servers/sidecar/vnc/vnc-manager.ts deleted file mode 100644 index d4f8b458..00000000 --- a/src/servers/sidecar/vnc/vnc-manager.ts +++ /dev/null @@ -1,346 +0,0 @@ -import { existsSync, mkdirSync, readdirSync, statSync } from 'node:fs'; -import { join } from 'node:path'; -import type { VncStartParams, VncSessionInfo } from '../protocol'; -import { getOwnerHomeDir } from '@@/data-path'; - -// Mirrors the physical display instead of spawning a virtual desktop per user, so the -// browser shows the same session as the screen. There is one seat, hence one shared -// mirror — the first caller's VNC password is the one it answers with. -const MIRROR_PORT = 5900; -const READY_TIMEOUT_MS = 5000; -// Above this framebuffer width the stream is halved; at or below it, pixels are served 1:1. -const SCALE_ABOVE_WIDTH = 2560; - -// The display number is NOT fixed. Under LightDM the user session got :0, so this was hardcoded to it. -// GDM gives :0 to its own greeter and starts the user's Xorg with -displayfd, letting the kernel pick — -// in practice :1. Hardcoding :0 made the mirror fail with "Can't open display" on every GNOME machine. -// -// Find it by socket ownership instead: /tmp/.X11-unix/X is owned by whoever runs that X server, so -// the one owned by us is the owner's session and any other is the greeter's. Fall back to the lowest -// socket (covers a root-run Xorg, as LightDM did) and finally to :0 so behaviour never gets worse than -// the constant it replaced. -function resolveDisplayNum(): number { - const uid = typeof process.getuid === 'function' ? process.getuid() : null; - let sockets: number[] = []; - try { - sockets = readdirSync('/tmp/.X11-unix') - .filter((n) => /^X\d+$/.test(n)) - .map((n) => Number(n.slice(1))) - .sort((a, b) => a - b); - } catch { - return 0; - } - if (uid != null) { - const own = sockets.find((n) => { - try { - return statSync(`/tmp/.X11-unix/X${n}`).uid === uid; - } catch { - return false; - } - }); - if (own !== undefined) return own; - } - return sockets[0] ?? 0; -} - -// x11vnc reads the display's cookie from the logged-in user's X authority, so no root is needed. Where -// that cookie lives depends on the display manager: GDM (Ubuntu GNOME on Xorg) keeps it in the -// per-session dir /run/user//gdm/Xauthority; LightDM (or a manual startx) uses the classic -// ~/.Xauthority. Try the GDM path first, then fall back. While the greeter owns the seat the user's -// cookie doesn't exist yet and mirroring fails until someone is logged in. -function resolveXauthority(): string | null { - const uid = typeof process.getuid === 'function' ? process.getuid() : null; - const candidates = [ - uid != null ? `/run/user/${uid}/gdm/Xauthority` : null, - join(process.env.HOME ?? '', '.Xauthority'), - ].filter((p): p is string => Boolean(p)); - return candidates.find((p) => existsSync(p)) ?? null; -} - -type MirrorSession = { - email: string; - username: string; - display: number; - port: number; - pid: number; -}; - -let mirror: MirrorSession | null = null; - -function isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } -} - -// Provision the pair of files a VNC session needs: `password` in plaintext, which the API hands to -// the browser, and `passwd` in rfbauth format, which x11vnc authenticates against. Idempotent — -// returns the existing password when both are already present. -// -// x11vnc writes the rfbauth file itself via -storepasswd. This used to shell out to tigervnc's -// `vncpasswd -f`, which is not installed by default (and is not in tigervnc-common, despite what -// setup-desktop.sh claimed), so the rfbauth file silently never appeared — the failure was swallowed -// because only a zero exit code triggered the write. -export async function ensureVncPassword(homeDir: string): Promise<{ password: string; passwdFile: string }> { - const vncDir = join(homeDir, '.vnc'); - const passwdFile = join(vncDir, 'passwd'); - const plainFile = join(vncDir, 'password'); - - if (existsSync(passwdFile) && existsSync(plainFile)) { - const password = (await Bun.file(plainFile).text()).trim(); - if (password) return { password, passwdFile }; - } - - mkdirSync(vncDir, { recursive: true }); - - // 8 printable ASCII characters. VNC truncates to 8, so there is no point generating more. - const password = Array.from(crypto.getRandomValues(new Uint8Array(8))) - .map((b) => String.fromCharCode(33 + (b % 94))) - .join(''); - - await Bun.write(plainFile, password); - - const proc = Bun.spawnSync({ - cmd: ['x11vnc', '-storepasswd', password, passwdFile], - stdout: 'ignore', - stderr: 'pipe', - }); - if (proc.exitCode !== 0 || !existsSync(passwdFile)) { - const stderr = proc.stderr.toString().trim(); - throw new Error(`Could not write the VNC password file: ${stderr || 'x11vnc -storepasswd failed'}`); - } - - Bun.spawnSync({ cmd: ['chmod', '600', passwdFile], stdout: 'ignore', stderr: 'ignore' }); - Bun.spawnSync({ cmd: ['chmod', '600', plainFile], stdout: 'ignore', stderr: 'ignore' }); - - console.log(`[vnc] provisioned VNC password at ${vncDir}`); - return { password, passwdFile }; -} - -type Geometry = { w: number; h: number; x: number; y: number }; -type DisplayGeometry = { framebufferWidth: number | null; primary: Geometry | null; connected: number }; - -// X composes every attached output into ONE framebuffer, so with two monitors side by side the -// framebuffer is their combined width and mirroring it whole sends both screens squashed together. -// Read the layout so the caller can clip to a single output. -// -// Returns nulls when xrandr is missing or X is not up; callers treat that as "don't scale, don't clip", -// because serving too many pixels beats serving nothing. -function readDisplayGeometry(xauthority: string, display: string): DisplayGeometry { - const proc = Bun.spawnSync({ - cmd: ['xrandr', '--current'], - env: { ...process.env, DISPLAY: display, XAUTHORITY: xauthority }, - stdout: 'pipe', - stderr: 'ignore', - }); - if (proc.exitCode !== 0) return { framebufferWidth: null, primary: null, connected: 0 }; - - const out = proc.stdout.toString(); - const fb = out.match(/current\s+(\d+)\s*x\s*(\d+)/); - // Count ENABLED outputs, not merely connected ones: a screen that is plugged in but switched off - // still reports "connected" and contributes nothing to the framebuffer. Only an enabled output - // carries a WxH+X+Y geometry, so requiring that is what distinguishes the two. - const connected = (out.match(/^\S+ connected(?: primary)? \d+x\d+\+\d+\+\d+/gm) ?? []).length; - - // "HDMI-A-0 connected primary 3840x2160+0+0 (normal left ..." — the geometry only appears on an - // output that is actually enabled, so a connected-but-off output correctly yields no match. - const p = out.match(/^\S+ connected primary (\d+)x(\d+)\+(\d+)\+(\d+)/m); - const primary = p ? { w: Number(p[1]), h: Number(p[2]), x: Number(p[3]), y: Number(p[4]) } : null; - - return { framebufferWidth: fb ? Number(fb[1]) : null, primary, connected }; -} - -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; - } -} - -// A listener on the port is NOT proof our server started. The running mirror lives in module state, so -// a sidecar restart forgets it while the process keeps running orphaned — and then this check sees the -// ORPHAN listening and reports success, leaving the platform convinced it started something the browser -// is not looking at. That is not hypothetical: on 2026-08-01 an Xvnc left over from the virtual-desktop -// experiment held 5900, and the mirror reported healthy while the browser was served a stale XFCE -// session. So reclaim the port first, and treat our own process dying as failure. -async function reclaimPort(port: number): Promise { - if (!(await isPortOpen(port))) return; - - console.log(`[vnc] port ${port} already held — killing the orphan before starting`); - Bun.spawnSync({ cmd: ['fuser', '-k', '-TERM', `${port}/tcp`], stdout: 'ignore', stderr: 'ignore' }); - - for (let i = 0; i < 20; i++) { - await Bun.sleep(100); - if (!(await isPortOpen(port))) return; - } - - Bun.spawnSync({ cmd: ['fuser', '-k', '-KILL', `${port}/tcp`], stdout: 'ignore', stderr: 'ignore' }); - await Bun.sleep(300); -} - -// x11vnc stays in the foreground, so readiness is the listening port rather than exit code — but only -// once we know the process we spawned is the one alive to own it. -async function waitForPort(port: number, proc: { exitCode: number | null }): Promise { - const deadline = Date.now() + READY_TIMEOUT_MS; - while (Date.now() < deadline) { - if (proc.exitCode !== null) return false; - if (await isPortOpen(port)) return true; - await Bun.sleep(100); - } - return false; -} - -export async function startSession(params: VncStartParams): Promise<{ port: number; display: number }> { - if (mirror && isProcessAlive(mirror.pid)) { - return { port: mirror.port, display: mirror.display }; - } - mirror = null; - - const homeDir = getOwnerHomeDir(params.email); - const { passwdFile } = await ensureVncPassword(homeDir); - - // An orphan from a previous sidecar life would still hold the port and be mistaken for a healthy start. - await reclaimPort(MIRROR_PORT); - - const displayNum = resolveDisplayNum(); - const display = `:${displayNum}`; - - const xauthority = resolveXauthority(); - if (!xauthority) { - throw new Error(`No X authority found (GDM or ~/.Xauthority) — nobody is logged in on ${display}`); - } - - // With more than one output attached the framebuffer spans them all, and mirroring it whole shows - // every monitor side by side, squashed. Clip to the primary so the remote view is one screen at the - // right proportions. Only when a primary is actually marked and there is more than one output — - // otherwise the framebuffer already IS the single screen and clipping would just add a failure mode. - const geo = readDisplayGeometry(xauthority, display); - const clipTo = geo.connected > 1 && geo.primary ? geo.primary : null; - const clip = clipTo ? ['-clip', `${clipTo.w}x${clipTo.h}+${clipTo.x}+${clipTo.y}`] : []; - if (clipTo) { - console.log(`[vnc] ${geo.connected} outputs attached — clipping to the primary ${clip[1]}`); - } - - // Halving a 4K screen keeps the stream sane over the tailnet, but the same 0.5 applied to a small - // screen is just lost detail — and with no monitor plugged in, X falls back to something tiny - // (800x480 here), which halves to an unreadable 400x240. Scale only when there is genuinely too much - // to send, and judge that on what is actually being served: the clip if there is one, else the - // whole framebuffer. - const width = clipTo ? clipTo.w : geo.framebufferWidth; - const scale = width !== null && width > SCALE_ABOVE_WIDTH ? ['-scale', '0.5'] : []; - if (width !== null) { - console.log(`[vnc] serving ${width}px wide — ${scale.length ? 'scaling to 50%' : '1:1'}`); - } - - const proc = Bun.spawn({ - cmd: [ - 'x11vnc', - '-display', - display, - '-auth', - xauthority, - '-rfbport', - String(MIRROR_PORT), - '-rfbauth', - passwdFile, - '-localhost', - '-forever', - '-shared', - ...clip, - ...scale, - // No -noxdamage. It was set when this was written, with no recorded reason, and it is expensive: - // without the DAMAGE extension x11vnc cannot be told which rectangles changed, so it polls the - // WHOLE framebuffer continuously to find out. That makes cost scale with screen area rather than - // with what actually moved — on a 4K mirror it is the single biggest source of latency, and it is - // why a smaller machine felt instant by comparison. This X server reports DAMAGE, MIT-SHM and - // XFIXES, so the optimisation is genuinely available. - // - // If stale patches ever appear on screen (some drivers under-report damage), putting -noxdamage - // back is the fix — at the cost of the polling above. - '-quiet', - ], - stdout: 'ignore', - stderr: 'pipe', - }); - - // 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, proc))) { - try { - proc.kill(); - } catch { - // already dead - } - throw new Error(`x11vnc failed to listen on ${MIRROR_PORT}: ${stderrTail.trim() || 'timed out'}`); - } - - mirror = { - email: params.email, - username: params.username ?? '', - display: displayNum, - port: MIRROR_PORT, - pid: proc.pid, - }; - console.log(`[vnc] mirroring ${display} on port ${MIRROR_PORT} (pid ${proc.pid})`); - - return { port: MIRROR_PORT, display: displayNum }; -} - -export function stopSession(_email: string): void { - if (!mirror) return; - - try { - process.kill(mirror.pid); - } catch { - // already dead - } - - console.log(`[vnc] stopped mirror of :${mirror.display} (pid ${mirror.pid})`); - mirror = null; -} - -export function getSession(_email: string): VncSessionInfo | null { - if (!mirror) return null; - - const alive = mirror.pid > 0 && isProcessAlive(mirror.pid); - if (!alive) { - mirror = null; - return null; - } - - return { - email: mirror.email, - display: mirror.display, - port: mirror.port, - pid: mirror.pid, - alive, - }; -} - -export function stopAll(): void { - stopSession(''); -} diff --git a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx index 82c13537..2c4cb15e 100644 --- a/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx +++ b/src/workspaces/officerdev/src/AppRegistry/AppRegistry.tsx @@ -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, diff --git a/src/workspaces/officerdev/src/apps/Desktop/DesktopHeader.tsx b/src/workspaces/officerdev/src/apps/Desktop/DesktopHeader.tsx deleted file mode 100644 index 742bf4df..00000000 --- a/src/workspaces/officerdev/src/apps/Desktop/DesktopHeader.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { MonitorSmartphone } from 'lucide-react'; - -export const DesktopHeader = () => { - return ( - <> - - Remote Desktop - - ); -}; diff --git a/src/workspaces/officerdev/src/apps/Desktop/DesktopView.tsx b/src/workspaces/officerdev/src/apps/Desktop/DesktopView.tsx deleted file mode 100644 index 787e1e5e..00000000 --- a/src/workspaces/officerdev/src/apps/Desktop/DesktopView.tsx +++ /dev/null @@ -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; - } - 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'} -
- )} -
-
- ); -}; diff --git a/src/workspaces/officerdev/src/apps/Desktop/DesktopWrapper.tsx b/src/workspaces/officerdev/src/apps/Desktop/DesktopWrapper.tsx deleted file mode 100644 index 26e6583a..00000000 --- a/src/workspaces/officerdev/src/apps/Desktop/DesktopWrapper.tsx +++ /dev/null @@ -1,3 +0,0 @@ -import { DesktopView } from './DesktopView'; - -export const DesktopWrapper = () => ; diff --git a/src/workspaces/officerdev/src/apps/Desktop/index.ts b/src/workspaces/officerdev/src/apps/Desktop/index.ts deleted file mode 100644 index b344a9f3..00000000 --- a/src/workspaces/officerdev/src/apps/Desktop/index.ts +++ /dev/null @@ -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, - }, -]; diff --git a/src/workspaces/officerdev/src/index.ts b/src/workspaces/officerdev/src/index.ts index 4c43e0f6..1f72c34c 100644 --- a/src/workspaces/officerdev/src/index.ts +++ b/src/workspaces/officerdev/src/index.ts @@ -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,