diff --git a/api/router.ts b/api/router.ts index b28972e..b0cf459 100644 --- a/api/router.ts +++ b/api/router.ts @@ -1,4 +1,6 @@ import { randomUUID } from 'node:crypto'; +import { join } from 'node:path'; +import { zipDirectory } from './zip'; import { createRouter } from '@@/create-router'; import * as errors from '@@/custom-errors'; import { sendSidecarCommand } from '@@/sidecar-registry'; @@ -44,8 +46,29 @@ async function getOpts(userId: number) { return { relayPort: RELAY_PORT, userToken: deriveRelayToken(userId, RELAY_PORT, salt) }; } -// GET /extension-download used to zip the extension directory on the fly, shelling out to `zip`. It had -// no caller: BrowserRelay.tsx links straight at /browser-relay-extension.zip, a real file in public/. +// ── The extension, zipped from the directory on every request ── +// +// This route existed once, shelling out to `zip`, and was removed as having no caller: the UI linked at +// `/browser-relay-extension.zip`, a static file in public/. That file was then found to be 58 lines +// behind the source it claimed to be — built from something older and never rebuilt. +// +// So it is back, without the host binary (see zip.ts) and with the caller inside this plugin. There is +// no second copy to fall behind, which is the only durable fix for that. + +router.get('/extension.zip', async (ctx) => { + const dir = join(import.meta.dir, '..', 'extension'); + const bytes = zipDirectory(dir); + // `.buffer` because Bun's BodyInit does not accept a Uint8Array view directly. + return new Response(bytes.buffer as ArrayBuffer, { + headers: { + 'content-type': 'application/zip', + 'content-disposition': 'attachment; filename="officer-browser-relay.zip"', + // Deterministic bytes for an unchanged directory, so a repeat download is a 304 rather than 50 KB. + etag: `"${Bun.hash(bytes).toString(16)}"`, + 'cache-control': 'private, no-cache', + }, + }); +}); router.get('/status', async (ctx) => { const user = ctx.get('user'); diff --git a/api/zip.ts b/api/zip.ts new file mode 100644 index 0000000..28c83eb --- /dev/null +++ b/api/zip.ts @@ -0,0 +1,122 @@ +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { join, relative } from 'node:path'; + +// A ZIP writer, in about seventy lines, so the extension download can never be stale. +// +// ── Why this exists rather than a file in public/ ── +// +// It was a file in public/: `browser-relay-extension.zip`, built by hand and served as a static asset. +// On 2026-08-21 it was found to be 58 lines behind the source it claimed to be — built in August from +// something that predated a February commit, and never rebuilt since. Anyone who downloaded it got an +// extension missing the multi-session recovery path, which is exactly the failure they would then hit. +// +// A snapshot drifts because nothing makes it not drift. Building from the directory at request time +// removes the class: there is no second copy to fall behind. +// +// ── Why hand-rolled, and why STORED rather than deflated ── +// +// The router's own history says the previous version of this shelled out to `zip`. That works and it +// makes the plugin need a host binary for a convenience feature — this manifest's "nothing to declare" +// is worth more than the compression. Bun ships no zip writer, and the stored format is genuinely small: +// a local header per file, a central directory, and an end record. +// +// Chrome accepts stored entries. The extension is ~50 KB of text; deflate would save perhaps 30 KB on a +// download that happens once. + +/** CRC-32, the one thing in the format that is not just bytes in order. */ +function crc32(bytes: Uint8Array): number { + let crc = 0xffffffff; + for (const byte of bytes) { + crc ^= byte; + for (let i = 0; i < 8; i++) crc = crc & 1 ? (crc >>> 1) ^ 0xedb88320 : crc >>> 1; + } + return (crc ^ 0xffffffff) >>> 0; +} + +type Entry = { name: string; data: Uint8Array; crc: number; offset: number }; + +const u16 = (v: number) => [v & 0xff, (v >>> 8) & 0xff]; +const u32 = (v: number) => [v & 0xff, (v >>> 8) & 0xff, (v >>> 16) & 0xff, (v >>> 24) & 0xff]; + +/** Every file under `dir`, depth first, with paths relative to it. */ +function collect(dir: string, root = dir): { name: string; data: Uint8Array }[] { + const out: { name: string; data: Uint8Array }[] = []; + for (const item of readdirSync(dir).sort()) { + const path = join(dir, item); + if (statSync(path).isDirectory()) out.push(...collect(path, root)); + else out.push({ name: relative(root, path), data: new Uint8Array(readFileSync(path)) }); + } + return out; +} + +/** + * Zip a directory, stored, with a fixed timestamp. + * + * The timestamp is fixed (1980-01-01, the format's own zero) so the same directory always produces + * byte-identical output. That makes the download cacheable and, more usefully, makes "did this change" + * answerable by comparing two files rather than unpacking them. + */ +export function zipDirectory(dir: string): Uint8Array { + const files = collect(dir); + const parts: number[] = []; + const entries: Entry[] = []; + + for (const file of files) { + const nameBytes = [...new TextEncoder().encode(file.name)]; + const crc = crc32(file.data); + entries.push({ ...file, crc, offset: parts.length }); + parts.push( + ...u32(0x04034b50), // local file header + ...u16(20), // version needed + ...u16(0), // flags + ...u16(0), // method 0 = stored + ...u16(0), // time + ...u16(0x0021), // date — 1980-01-01 + ...u32(crc), + ...u32(file.data.length), // compressed size == uncompressed, stored + ...u32(file.data.length), + ...u16(nameBytes.length), + ...u16(0), // extra length + ...nameBytes, + ...file.data, + ); + } + + const centralStart = parts.length; + for (const entry of entries) { + const nameBytes = [...new TextEncoder().encode(entry.name)]; + parts.push( + ...u32(0x02014b50), // central directory header + ...u16(20), // version made by + ...u16(20), // version needed + ...u16(0), + ...u16(0), + ...u16(0), + ...u16(0x0021), + ...u32(entry.crc), + ...u32(entry.data.length), + ...u32(entry.data.length), + ...u16(nameBytes.length), + ...u16(0), // extra + ...u16(0), // comment + ...u16(0), // disk + ...u16(0), // internal attrs + ...u32(0), // external attrs + ...u32(entry.offset), + ...nameBytes, + ); + } + + parts.push( + ...u32(0x06054b50), // end of central directory + ...u16(0), + ...u16(0), + ...u16(entries.length), + ...u16(entries.length), + ...u32(parts.length - centralStart), + ...u32(centralStart), + ...u16(0), // comment length + ); + + return new Uint8Array(parts); +} diff --git a/web/PairingPanel.tsx b/web/PairingPanel.tsx new file mode 100644 index 0000000..0ab2669 --- /dev/null +++ b/web/PairingPanel.tsx @@ -0,0 +1,208 @@ +import { useState } from 'react'; +// Pairing lives in the PLUGIN now. +// +// It was `Settings/IntegrationsSettings/BrowserRelay.tsx` — a platform screen driving a plugin's +// pairing, calling /browser/relay-token, which worked only because the plugin happened to serve that +// path. Same coupling the extraction removed everywhere else, and the last piece of it. +// +// The download link changed with it: it pointed at a static zip in the platform's public/ that nothing +// regenerated and that was 58 lines behind the source. It now asks the plugin, which builds the archive +// from the extension directory per request. +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { Copy, Check, Download, ExternalLink, RefreshCw, Trash2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { Button } from '@/components/ui/button'; +import { useClient } from 'hooks/useClient'; +import { copyToClipboard } from 'helpers/clipboard'; + +type RelayToken = { + token: string; + port: number; +}; + +export const PairingPanel = () => { + const client = useClient(); + const queryClient = useQueryClient(); + const [copiedField, setCopiedField] = useState(null); + + const { data: status } = useQuery({ + queryKey: ['browser-status'], + queryFn: () => client.get<{ extensionConnected: boolean; targetCount: number }>('/browser/status'), + refetchInterval: 5000, + }); + + const { data: tokenData } = useQuery({ + queryKey: ['browser-relay-token'], + queryFn: () => client.get('/browser/relay-token'), + }); + + const regenerate = useMutation({ + mutationFn: () => client.post('/browser/relay-token/regenerate'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['browser-relay-token'] }); + toast.success('Token regenerated — update the extension with the new token'); + }, + onError: () => toast.error('Failed to regenerate token'), + }); + + const revoke = useMutation({ + mutationFn: () => client.delete('/browser/relay-token'), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['browser-relay-token'] }); + toast.success('Token revoked'); + }, + onError: () => toast.error('Failed to revoke token'), + }); + + const handleCopy = async (value: string, field: string) => { + try { + await copyToClipboard(value); + setCopiedField(field); + toast.success('Copied to clipboard'); + setTimeout(() => setCopiedField(null), 2000); + } catch { + toast.error('Failed to copy'); + } + }; + + const isConnected = status?.extensionConnected ?? false; + const serverAddress = window.location.hostname; + + return ( +
+ {isConnected ? ( +
+
+
+

Connected

+

+ {status?.targetCount ?? 0} tab{(status?.targetCount ?? 0) !== 1 ? 's' : ''} attached + {' — '} + + view tabs + +

+
+
+ ) : ( +

+ Connect your Chrome browser to Officer so AI agents can view and interact with your tabs. +

+ )} + + {/* Step 1: Install */} +
+

1. Install the extension

+

+ Download and unzip the extension, then load it in Chrome: +

+ + + Download Extension + +
    +
  1. + Open{' '} + + chrome://extensions + {' '} + in Chrome +
  2. +
  3. + Enable Developer mode (top-right toggle) +
  4. +
  5. + Click Load unpacked and select the unzipped folder +
  6. +
+
+ + {/* Step 2: Configure */} + {tokenData && ( +
+

2. Configure the extension

+

+ Open the extension options (right-click icon → Options) and enter these values: +

+ +
+ handleCopy(serverAddress, 'host')} + /> + handleCopy(String(tokenData.port), 'port')} + /> + handleCopy(tokenData.token, 'token')} + /> +
+ +
+ + +
+
+ )} + + {/* Step 3: Attach */} +
+

3. Attach a tab

+

+ Navigate to any webpage and click the Officer extension icon in the toolbar. A cyan ON badge + means the tab is connected. Then go to{' '} + + /browser + {' '} + to view your tabs. +

+
+
+ ); +}; + +type CredentialRowProps = { + label: string; + value: string; + masked?: boolean; + copied: boolean; + onCopy: () => void; +}; + +const CredentialRow = ({ label, value, masked, copied, onCopy }: CredentialRowProps) => ( +
+ {label} + {masked ? `${value.slice(0, 8)}${'•'.repeat(16)}` : value} + +
+); diff --git a/web/layout.ts b/web/layout.ts index cf62e84..24f08cd 100644 --- a/web/layout.ts +++ b/web/layout.ts @@ -10,7 +10,20 @@ export const defaultLayout: LayoutNode = { id: 'browser-root', direction: 'horizontal', children: [ - { node: { type: 'panel', id: 'browser-tabs', appType: 'officerdev/browser-tabs' }, size: 25 }, + { + node: { + type: 'group', + id: 'browser-left', + direction: 'vertical', + children: [ + { node: { type: 'panel', id: 'browser-tabs', appType: 'officerdev/browser-tabs' }, size: 55 }, + // Pairing under the tab list: on a server with nothing paired the tab list is empty, and the + // panel that explains why should be the one next to it. + { node: { type: 'panel', id: 'browser-pairing', appType: 'officerdev/browser-pairing' }, size: 45 }, + ], + }, + size: 25, + }, { node: { type: 'group', diff --git a/web/panels.ts b/web/panels.ts index 03ca82b..0d4602d 100644 --- a/web/panels.ts +++ b/web/panels.ts @@ -1,8 +1,9 @@ // `panels.ts`, not `.tsx` — discovery checks for that exact name. import type { AppRegistryMeta } from 'officerdev'; -import { Globe, LayoutList } from 'lucide-react'; +import { Globe, LayoutList, Plug } from 'lucide-react'; import { TabList } from './TabList'; import { TabPreview } from './TabPreview'; +import { PairingPanel } from './PairingPanel'; // Two panels, both self-contained — they read their own data and take no props, which is why the screen // that used to render them (`BrowserScreen.tsx`, with `appType: null` placeholders) had nothing to pass @@ -15,6 +16,16 @@ export const appRegistryMetas: AppRegistryMeta[] = [ component: TabList, availableOnPanel: false, }, + { + // Pairing is a panel rather than a settings page, because it is not configuration you set once — it + // shows whether the extension is connected right now and how many tabs it holds, which is the first + // thing anyone looks at when a capture does not work. + key: 'officerdev/browser-pairing', + name: 'Pairing', + icon: Plug, + component: PairingPanel, + availableOnPanel: true, + }, { key: 'officerdev/browser-preview', name: 'Preview',