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:
co-authored by
Claude Opus 5
parent
0041fcbd47
commit
0d67e2af26
@@ -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>
|
||||||
);
|
);
|
||||||
|
|||||||
+14
-8
@@ -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
@@ -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',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 });
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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...
|
||||||
|
|||||||
Reference in New Issue
Block a user