clear the remaining type errors

- DiscordAccount seeded DiscordStatus without its two nullable fields.
- bug-report typed reporter.name as string, but users.name is nullable; and the
  Discord upload wrapped a Buffer directly in a Blob.
- Lucide icons take no `title` prop, so the sync spinner's tooltip moved to a
  wrapping span.
- DesktopView cast its dynamic import to a type that included `| null`.
- dock PUT cast the request body straight to string[]; it now rejects anything
  that is not an array of strings instead of writing it to the database.
- buildZodSchema assembles a mutable record, since z.ZodRawShape is readonly in
  zod v4.
- The dev-server proxy forwards Bun's `string | Buffer` frames through a helper
  that satisfies WebSocket.send without copying.

bunx tsgo is now clean.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
brunorezio
2026-07-25 23:30:20 +01:00
co-authored by Claude Opus 5
parent 0041fcbd47
commit 0d67e2af26
7 changed files with 197 additions and 79 deletions
@@ -1,7 +1,21 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Link } from 'react-router'; import { Link } from 'react-router';
import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query';
import { ChevronLeft, ChevronRight, Inbox, Loader2, Mail, Paperclip, RefreshCw, Search, Send, ShieldAlert, SquarePen, Trash2, X } from 'lucide-react'; import {
ChevronLeft,
ChevronRight,
Inbox,
Loader2,
Mail,
Paperclip,
RefreshCw,
Search,
Send,
ShieldAlert,
SquarePen,
Trash2,
X,
} from 'lucide-react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Button } from '@/components/ui/button'; import { Button } from '@/components/ui/button';
import { useClient } from 'hooks/useClient'; import { useClient } from 'hooks/useClient';
@@ -66,8 +80,12 @@ export const EmailList = () => {
queryKey: isSearching ? ['email-search', debouncedSearch, page] : ['email-messages', page, folder], queryKey: isSearching ? ['email-search', debouncedSearch, page] : ['email-messages', page, folder],
queryFn: () => queryFn: () =>
isSearching isSearching
? client.get<{ messages: EmailSummary[]; total: number }>(`/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}`) ? client.get<{ messages: EmailSummary[]; total: number }>(
: client.get<{ messages: EmailSummary[]; total: number }>(`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`), `/email/search?q=${encodeURIComponent(debouncedSearch)}&page=${page}&limit=${LIMIT}`,
)
: client.get<{ messages: EmailSummary[]; total: number }>(
`/email/messages?page=${page}&limit=${LIMIT}&folder=${folder}`,
),
// Keep the previous results visible while the next query loads, so switching search terms/pages // Keep the previous results visible while the next query loads, so switching search terms/pages
// never bails to the full-panel loading view (which would unmount the search input and drop focus). // never bails to the full-panel loading view (which would unmount the search input and drop focus).
placeholderData: keepPreviousData, placeholderData: keepPreviousData,
@@ -117,7 +135,10 @@ export const EmailList = () => {
if (!syncableAccount) return; if (!syncableAccount) return;
setSyncing(true); setSyncing(true);
try { try {
const result = await client.post<{ ok: boolean; saved?: number }>(`/email/accounts/${syncableAccount.id}/sync`, {}); const result = await client.post<{ ok: boolean; saved?: number }>(
`/email/accounts/${syncableAccount.id}/sync`,
{},
);
if (result.saved !== undefined) { if (result.saved !== undefined) {
toast.success(result.saved > 0 ? `${result.saved} new emails` : 'No new emails'); toast.success(result.saved > 0 ? `${result.saved} new emails` : 'No new emails');
queryClient.invalidateQueries({ queryKey: ['email-messages'] }); queryClient.invalidateQueries({ queryKey: ['email-messages'] });
@@ -174,11 +195,7 @@ export const EmailList = () => {
}, [messages, selectedId]); }, [messages, selectedId]);
if (isLoading) { if (isLoading) {
return ( return <div className="flex h-full items-center justify-center text-sm opacity-50">Loading emails...</div>;
<div className="flex h-full items-center justify-center text-sm opacity-50">
Loading emails...
</div>
);
} }
// Show onboarding empty state only when no emails exist at all (never while searching) // Show onboarding empty state only when no emails exist at all (never while searching)
@@ -198,7 +215,11 @@ export const EmailList = () => {
<div className="flex flex-col items-center gap-2"> <div className="flex flex-col items-center gap-2">
<span>No emails synced yet</span> <span>No emails synced yet</span>
<Button variant="outline" size="sm" onClick={handleSync} disabled={syncing || isSyncing}> <Button variant="outline" size="sm" onClick={handleSync} disabled={syncing || isSyncing}>
{syncing || isSyncing ? <Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="mr-2 h-3.5 w-3.5" />} {syncing || isSyncing ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<RefreshCw className="mr-2 h-3.5 w-3.5" />
)}
Sync Now Sync Now
</Button> </Button>
</div> </div>
@@ -238,7 +259,9 @@ export const EmailList = () => {
</div> </div>
<span className="text-xs opacity-50">{total}</span> <span className="text-xs opacity-50">{total}</span>
{syncing || isSyncing ? ( {syncing || isSyncing ? (
<Loader2 className="h-3.5 w-3.5 animate-spin shrink-0 opacity-50" title="Syncing..." /> <span title="Syncing..." className="flex shrink-0">
<Loader2 className="h-3.5 w-3.5 animate-spin opacity-50" />
</span>
) : syncableAccount ? ( ) : syncableAccount ? (
<button <button
onClick={handleSync} onClick={handleSync}
@@ -286,9 +309,17 @@ export const EmailList = () => {
placeholder="Search — try from:, subject:, has:attachment…" placeholder="Search — try from:, subject:, has:attachment…"
className="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40" className="flex-1 bg-transparent text-sm outline-none placeholder:opacity-40"
/> />
{isSearching && <span className="text-xs opacity-50 shrink-0">{total} result{total === 1 ? '' : 's'}</span>} {isSearching && (
<span className="text-xs opacity-50 shrink-0">
{total} result{total === 1 ? '' : 's'}
</span>
)}
{search && ( {search && (
<button onClick={() => setSearch('')} className="shrink-0 opacity-40 hover:opacity-100 cursor-pointer" title="Clear search"> <button
onClick={() => setSearch('')}
className="shrink-0 opacity-40 hover:opacity-100 cursor-pointer"
title="Clear search"
>
<X className="h-3.5 w-3.5" /> <X className="h-3.5 w-3.5" />
</button> </button>
)} )}
@@ -298,43 +329,48 @@ export const EmailList = () => {
{isSearching ? `No results for “${debouncedSearch}` : 'No emails in this folder'} {isSearching ? `No results for “${debouncedSearch}` : 'No emails in this folder'}
</div> </div>
) : ( ) : (
<div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto"> <div className="flex flex-1 flex-col divide-y divide-white/10 overflow-y-auto">
{messages.map((msg: EmailSummary) => { {messages.map((msg: EmailSummary) => {
const unread = msg.threadUnread !== undefined ? msg.threadUnread > 0 : !msg.read; const unread = msg.threadUnread !== undefined ? msg.threadUnread > 0 : !msg.read;
return ( return (
<button <button
key={msg.id} key={msg.id}
data-email-id={msg.id} data-email-id={msg.id}
onClick={() => setSelectedId(msg.id)} onClick={() => setSelectedId(msg.id)}
className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer shrink-0 ${ className={`flex flex-col gap-0.5 px-3 py-2.5 text-left transition-colors cursor-pointer shrink-0 ${
selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50' selectedId === msg.id ? 'bg-accent' : 'hover:bg-accent/50'
}`} }`}
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-1.5"> <div className="flex min-w-0 items-center gap-1.5">
<span className={`truncate text-sm ${unread ? 'font-semibold' : 'font-medium opacity-70'}`}>{msg.from}</span> <span className={`truncate text-sm ${unread ? 'font-semibold' : 'font-medium opacity-70'}`}>
{!!msg.threadCount && ( {msg.from}
<span className="shrink-0 rounded-full bg-muted px-1.5 text-xs tabular-nums opacity-60" title={`${msg.threadCount} messages`}> </span>
{msg.threadCount} {!!msg.threadCount && (
</span> <span
)} className="shrink-0 rounded-full bg-muted px-1.5 text-xs tabular-nums opacity-60"
</div> title={`${msg.threadCount} messages`}
<span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span> >
</div> {msg.threadCount}
<span className={`truncate text-sm ${unread ? 'font-medium' : 'opacity-70'}`}>{msg.subject}</span> </span>
<div className="flex items-center gap-1.5 text-xs"> )}
{!!msg.attachmentCount && ( </div>
<span className="flex shrink-0 items-center gap-0.5 text-muted-foreground"> <span className="shrink-0 text-xs opacity-50">{formatDate(msg.date)}</span>
<Paperclip className="h-3 w-3" /> </div>
{msg.attachmentCount} <span className={`truncate text-sm ${unread ? 'font-medium' : 'opacity-70'}`}>{msg.subject}</span>
</span> <div className="flex items-center gap-1.5 text-xs">
)} {!!msg.attachmentCount && (
<span className="truncate opacity-50">{msg.snippet}</span> <span className="flex shrink-0 items-center gap-0.5 text-muted-foreground">
</div> <Paperclip className="h-3 w-3" />
</button> {msg.attachmentCount}
); </span>
})} )}
</div> <span className="truncate opacity-50">{msg.snippet}</span>
</div>
</button>
);
})}
</div>
)} )}
</div> </div>
); );
@@ -20,7 +20,12 @@ export const DiscordAccount = () => {
const client = useClient(); const client = useClient();
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
const [connection, setConnection] = useState<DiscordConnection>({ linked: false }); const [connection, setConnection] = useState<DiscordConnection>({ linked: false });
const [botStatus, setBotStatus] = useState<DiscordStatus>({ configured: false, running: false }); const [botStatus, setBotStatus] = useState<DiscordStatus>({
configured: false,
running: false,
serverInvite: null,
botHandle: null,
});
const [pairingCode, setPairingCode] = useState<string | null>(null); const [pairingCode, setPairingCode] = useState<string | null>(null);
const [isGenerating, setIsGenerating] = useState(false); const [isGenerating, setIsGenerating] = useState(false);
@@ -116,12 +121,7 @@ export const DiscordAccount = () => {
<p className="text-xs text-duck-dark/40 dark:text-foreground/40"> <p className="text-xs text-duck-dark/40 dark:text-foreground/40">
You can send direct messages to the bot on Discord and they will be handled by your PI agent. You can send direct messages to the bot on Discord and they will be handled by your PI agent.
</p> </p>
<Button <Button type="button" variant="outline" onClick={handleDisconnect} className="w-full h-11 cursor-pointer">
type="button"
variant="outline"
onClick={handleDisconnect}
className="w-full h-11 cursor-pointer"
>
Unlink Discord Unlink Discord
</Button> </Button>
</div> </div>
@@ -142,7 +142,13 @@ export const DiscordAccount = () => {
<code className="text-2xl font-mono font-bold tracking-[0.3em] text-duck-dark dark:text-foreground"> <code className="text-2xl font-mono font-bold tracking-[0.3em] text-duck-dark dark:text-foreground">
{pairingCode} {pairingCode}
</code> </code>
<Button type="button" variant="ghost" size="icon" onClick={handleCopyCode} className="cursor-pointer shrink-0"> <Button
type="button"
variant="ghost"
size="icon"
onClick={handleCopyCode}
className="cursor-pointer shrink-0"
>
<Copy className="h-4 w-4" /> <Copy className="h-4 w-4" />
</Button> </Button>
</div> </div>
+57 -9
View File
@@ -33,7 +33,16 @@ type WSData = {
userId: number; userId: number;
email: string; email: string;
username: string; username: string;
provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'dev-server' | 'cliamp' | 'cliamp-audio' | 'desktop' | 'sidecar'; provider:
| 'terminal'
| 'chat'
| 'task-runner'
| 'pipeline'
| 'dev-server'
| 'cliamp'
| 'cliamp-audio'
| 'desktop'
| 'sidecar';
sessionId?: string; sessionId?: string;
cwd?: string; cwd?: string;
command?: string; command?: string;
@@ -141,6 +150,12 @@ const handlers: Record<string, any> = {
// Dev-server WebSocket proxy: bridges client WS ↔ upstream dev server WS (for HMR etc.) // Dev-server WebSocket proxy: bridges client WS ↔ upstream dev server WS (for HMR etc.)
type UpstreamState = { ws: WebSocket; queue: (string | Buffer)[]; ready: boolean }; type UpstreamState = { ws: WebSocket; queue: (string | Buffer)[]; ready: boolean };
// Bun hands WS frames over as `string | Buffer`, but the DOM WebSocket.send signature won't accept a
// Buffer<ArrayBufferLike> (it can't rule out a SharedArrayBuffer backing). A Buffer is a Uint8Array
// at runtime, so this forwards as-is rather than paying for a copy on every proxied frame.
const asWsPayload = (raw: string | Buffer): string | Uint8Array<ArrayBuffer> =>
typeof raw === 'string' ? raw : (raw as Uint8Array<ArrayBuffer>);
const devServerUpstreams = new Map<ServerWebSocket<WSData>, UpstreamState>(); const devServerUpstreams = new Map<ServerWebSocket<WSData>, UpstreamState>();
const devServerWebsocket = { const devServerWebsocket = {
@@ -154,8 +169,14 @@ const devServerWebsocket = {
} }
try { try {
const payload = await verify(wsToken); const payload = await verify(wsToken);
if (!payload) { ws.close(4001, 'Unauthorized'); return; } if (!payload) {
if (payload.jti && await isTokenBlacklisted(payload.jti)) { ws.close(4001, 'Unauthorized'); return; } ws.close(4001, 'Unauthorized');
return;
}
if (payload.jti && (await isTokenBlacklisted(payload.jti))) {
ws.close(4001, 'Unauthorized');
return;
}
} catch { } catch {
ws.close(4001, 'Unauthorized'); ws.close(4001, 'Unauthorized');
return; return;
@@ -167,7 +188,7 @@ const devServerWebsocket = {
upstream.addEventListener('open', () => { upstream.addEventListener('open', () => {
state.ready = true; state.ready = true;
for (const msg of state.queue) upstream.send(msg); for (const msg of state.queue) upstream.send(asWsPayload(msg));
state.queue.length = 0; state.queue.length = 0;
}); });
@@ -189,7 +210,7 @@ const devServerWebsocket = {
const state = devServerUpstreams.get(ws); const state = devServerUpstreams.get(ws);
if (!state) return; if (!state) return;
if (state.ready) { if (state.ready) {
state.ws.send(raw); state.ws.send(asWsPayload(raw));
} else { } else {
state.queue.push(raw); state.queue.push(raw);
} }
@@ -204,7 +225,11 @@ const devServerWebsocket = {
}; };
handlers['dev-server'] = devServerWebsocket; handlers['dev-server'] = devServerWebsocket;
async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'cliamp' | 'cliamp-audio' | 'desktop') { async function upgradeWs(
req: Request,
server: any,
provider: 'terminal' | 'chat' | 'task-runner' | 'pipeline' | 'cliamp' | 'cliamp-audio' | 'desktop',
) {
const token = new URL(req.url).searchParams.get('token'); const token = new URL(req.url).searchParams.get('token');
if (!token) return new Response('Unauthorized', { status: 401 }); if (!token) return new Response('Unauthorized', { status: 401 });
@@ -224,7 +249,18 @@ async function upgradeWs(req: Request, server: any, provider: 'terminal' | 'chat
const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined; const rows = url.searchParams.get('rows') ? Number(url.searchParams.get('rows')) : undefined;
const files = url.searchParams.get('files') ?? undefined; const files = url.searchParams.get('files') ?? undefined;
const ok = server.upgrade(req, { const ok = server.upgrade(req, {
data: { userId: user.id, email: user.email, username: toShellUsername(user.username ?? '', user.email), provider, sessionId, cwd, command, cols, rows, files }, data: {
userId: user.id,
email: user.email,
username: toShellUsername(user.username ?? '', user.email),
provider,
sessionId,
cwd,
command,
cols,
rows,
files,
},
}); });
if (!ok) return new Response('Upgrade failed', { status: 500 }); if (!ok) return new Response('Upgrade failed', { status: 500 });
} catch { } catch {
@@ -330,7 +366,13 @@ try {
} }
// Initialize queue engine in API server process // Initialize queue engine in API server process
import { initQueue, enqueueJob as queueEnqueue, cancelJob as queueCancel, listAllJobs as queueList, readJob as queueGet } from './servers/queue/init'; import {
initQueue,
enqueueJob as queueEnqueue,
cancelJob as queueCancel,
listAllJobs as queueList,
readJob as queueGet,
} from './servers/queue/init';
initQueue().catch((err) => console.error('[queue] failed to initialize:', err)); initQueue().catch((err) => console.error('[queue] failed to initialize:', err));
// Mark any orphaned pipeline jobs from previous server run // Mark any orphaned pipeline jobs from previous server run
@@ -364,7 +406,13 @@ cleanupOnStartup().catch((err) => console.error('[pipeline-jobs] startup cleanup
const sinkList = sinks.stdout.toString(); const sinkList = sinks.stdout.toString();
if (!sinkList.includes('virtual_out')) { if (!sinkList.includes('virtual_out')) {
const load = Bun.spawnSync({ const load = Bun.spawnSync({
cmd: [pactl, 'load-module', 'module-null-sink', 'sink_name=virtual_out', 'sink_properties=device.description=Virtual_Output'], cmd: [
pactl,
'load-module',
'module-null-sink',
'sink_name=virtual_out',
'sink_properties=device.description=Virtual_Output',
],
stdout: 'pipe', stdout: 'pipe',
stderr: 'pipe', stderr: 'pipe',
}); });
+18 -5
View File
@@ -51,8 +51,13 @@ bugReportRouter.post('/', async (ctx) => {
type BugReport = { type BugReport = {
description: string; description: string;
context: { url?: string; userAgent?: string; viewport?: { width: number; height: number }; apiError?: { status: number; message: string } | null } | null; context: {
reporter: { id: number; email: string; name: string }; url?: string;
userAgent?: string;
viewport?: { width: number; height: number };
apiError?: { status: number; message: string } | null;
} | null;
reporter: { id: number; email: string; name: string | null };
createdAt: string; createdAt: string;
}; };
@@ -64,21 +69,29 @@ async function sendToDiscord(report: BugReport, screenshot: Buffer | null) {
fields: [ fields: [
{ name: 'Reporter', value: `${report.reporter.name} (${report.reporter.email})`, inline: true }, { name: 'Reporter', value: `${report.reporter.name} (${report.reporter.email})`, inline: true },
{ name: 'URL', value: report.context?.url ?? 'N/A', inline: false }, { name: 'URL', value: report.context?.url ?? 'N/A', inline: false },
{ name: 'Viewport', value: report.context?.viewport ? `${report.context.viewport.width}x${report.context.viewport.height}` : 'N/A', inline: true }, {
name: 'Viewport',
value: report.context?.viewport ? `${report.context.viewport.width}x${report.context.viewport.height}` : 'N/A',
inline: true,
},
{ name: 'Browser', value: shortenUA(report.context?.userAgent), inline: true }, { name: 'Browser', value: shortenUA(report.context?.userAgent), inline: true },
], ],
timestamp: report.createdAt, timestamp: report.createdAt,
}; };
if (report.context?.apiError) { if (report.context?.apiError) {
embed.fields.push({ name: 'Last API Error', value: `${report.context.apiError.status}: ${report.context.apiError.message}`, inline: false }); embed.fields.push({
name: 'Last API Error',
value: `${report.context.apiError.status}: ${report.context.apiError.message}`,
inline: false,
});
} }
const form = new FormData(); const form = new FormData();
form.append('payload_json', JSON.stringify({ embeds: [embed] })); form.append('payload_json', JSON.stringify({ embeds: [embed] }));
if (screenshot) { if (screenshot) {
form.append('files[0]', new Blob([screenshot], { type: 'image/png' }), 'screenshot.png'); form.append('files[0]', new Blob([new Uint8Array(screenshot)], { type: 'image/png' }), 'screenshot.png');
} }
const res = await fetch(DISCORD_WEBHOOK_URL!, { method: 'POST', body: form }); const res = await fetch(DISCORD_WEBHOOK_URL!, { method: 'POST', body: form });
+6 -1
View File
@@ -1,5 +1,6 @@
import { createRouter } from '../../create-router'; import { createRouter } from '../../create-router';
import { getDockPaths, setDockPaths } from 'officerdb'; import { getDockPaths, setDockPaths } from 'officerdb';
import * as errors from '@@/custom-errors';
export const dockRouter = createRouter(); export const dockRouter = createRouter();
@@ -13,7 +14,11 @@ dockRouter.get('/', async (ctx) => {
// PUT / — full replacement of dock paths array // PUT / — full replacement of dock paths array
dockRouter.put('/', async (ctx) => { dockRouter.put('/', async (ctx) => {
const userId = ctx.get('user').id; const userId = ctx.get('user').id;
const paths = ctx.get('body') as string[]; const body = ctx.get('body') as unknown;
if (!Array.isArray(body) || body.some((p) => typeof p !== 'string')) {
throw errors.BAD_REQUEST('Expected an array of dock paths');
}
const paths = body as string[];
await setDockPaths(userId, paths); await setDockPaths(userId, paths);
return ctx.json(paths); return ctx.json(paths);
}); });
+3 -2
View File
@@ -139,7 +139,7 @@ function discoverTools(dirs: string[]): DiscoveredTool[] {
const { meta } = parseFrontmatter(content); const { meta } = parseFrontmatter(content);
if (!meta.name || !meta.description) continue; if (!meta.name || !meta.description) continue;
if ((meta.targets as string ?? 'all') === 'pi') continue; if (((meta.targets as string) ?? 'all') === 'pi') continue;
if (seen.has(meta.name)) continue; if (seen.has(meta.name)) continue;
seen.add(meta.name); seen.add(meta.name);
@@ -153,7 +153,8 @@ function discoverTools(dirs: string[]): DiscoveredTool[] {
// ── Schema building (frontmatter inputs → zod) ── // ── Schema building (frontmatter inputs → zod) ──
function buildZodSchema(inputs: Record<string, ToolParam>): z.ZodRawShape { function buildZodSchema(inputs: Record<string, ToolParam>): z.ZodRawShape {
const shape: z.ZodRawShape = {}; // z.ZodRawShape is readonly in zod v4, so build it mutably and widen on return.
const shape: Record<string, z.ZodTypeAny> = {};
for (const [name, param] of Object.entries(inputs)) { for (const [name, param] of Object.entries(inputs)) {
let field: z.ZodTypeAny; let field: z.ZodTypeAny;
@@ -25,11 +25,13 @@ type RFBInstance = {
addEventListener: (type: string, listener: (ev: CustomEvent) => void) => 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; let rfbModulePromise: Promise<{
default: new (target: HTMLElement, url: string, options?: { credentials?: { password?: string } }) => RFBInstance;
}> | null = null;
const loadRFB = () => { const loadRFB = () => {
if (!rfbModulePromise) { if (!rfbModulePromise) {
rfbModulePromise = import(/* @vite-ignore */ NOVNC_URL) as typeof rfbModulePromise; rfbModulePromise = import(/* @vite-ignore */ NOVNC_URL) as NonNullable<typeof rfbModulePromise>;
} }
return rfbModulePromise!; return rfbModulePromise!;
}; };
@@ -116,14 +118,21 @@ export const DesktopView = ({ className, style }: DesktopViewProps) => {
return () => { return () => {
disposed = true; disposed = true;
if (rfbRef.current) { if (rfbRef.current) {
try { rfbRef.current.disconnect(); } catch { /* ignore */ } try {
rfbRef.current.disconnect();
} catch {
/* ignore */
}
rfbRef.current = null; rfbRef.current = null;
} }
}; };
}, [isMounted, client]); }, [isMounted, client]);
return ( return (
<div className={className} style={{ backgroundColor: '#1a1a2e', overflow: 'hidden', position: 'relative', ...style }}> <div
className={className}
style={{ backgroundColor: '#1a1a2e', overflow: 'hidden', position: 'relative', ...style }}
>
{status === 'connecting' && ( {status === 'connecting' && (
<div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground"> <div className="absolute inset-0 flex items-center justify-center text-sm text-muted-foreground">
Connecting to desktop... Connecting to desktop...