ext: popup, options, and the panel bridge (build order step 6)
Popup and Options are separate documents from the background page and can't reach its live VeloxTransport directly, so background/bridge.ts relays it over one browser.runtime.connect port per document (call/subscribe/getStatus/reconnect/pair/unpair/setOverride in; result/event/status/pairError out). shared/panel-client.ts is the client side both surfaces use. Popup (src/popup/): status dot + text, active-downloads list driven by event.task.progress/added/state/removed (repaints ride the event's own <=4 Hz cap rather than adding a second timer), pause/resume buttons, "Start it" wired to a reconnect request. State lives in a DOM-free store.ts for unit testing; rendering uses createElement, not innerHTML (web-ext lint flags the latter). Options (src/options/): transport override select, pairing (code entry + pair/unpair, backed by two new WebSocketTransport methods, pairWithCode/unpair), and the daemon's capture policy mirrored via capture.getRules. The capture-policy form is editable only when the active transport is native messaging (uds) -- settings.set and rules.upsert are privileged, uds-only methods per shared/protocol METHODS, so WebSocket can't write them no matter what the page shows; CLAUDE.md section 2 rules out working around that locally. The bridge's status payload adds a kind field (which transport is live) for this to key off. Default category is the one piece of state that's genuinely the extension's own, not the daemon's, and lives in browser.storage.local via options/prefs.ts. Pure decisions (statusLine, pairingAvailable, captureRulesEditable) are split into view.ts for unit testing without a DOM. manifest.json registers the popup action and options_ui page (and, in the same edit, the content_scripts entry the next commit's media detection needs -- split by file, not by manifest line). build.mjs gains popup/options as further esbuild entry points, plus copying their static HTML/CSS into dist/. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01Ed8KEmAW48v4YHdxLtqsMB
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
// Relays the background page's one VeloxTransport to the popup and options documents,
|
||||
// which run as separate contexts and cannot import background/index.ts directly.
|
||||
//
|
||||
// Wire protocol over a `browser.runtime.connect` port (see docs/05 §3: "live progress
|
||||
// via event.task.progress relayed over the transport"):
|
||||
// client -> bg { type: 'call', id, method, params }
|
||||
// client -> bg { type: 'subscribe', events: string[] } (replaces prior selection)
|
||||
// client -> bg { type: 'getStatus' }
|
||||
// bg -> client { type: 'result', id, ok: true, result } | { type: 'result', id, ok: false, error }
|
||||
// bg -> client { type: 'event', event, payload }
|
||||
// bg -> client { type: 'status', status }
|
||||
//
|
||||
// This is plain message relaying, not download logic: no bytes, no URLs fetched here,
|
||||
// just RPC forwarding to the transport the background page already owns.
|
||||
|
||||
import type { EventName, MethodName } from '../shared/protocol/index.js';
|
||||
import type { TransportKind, TransportStatus, VeloxTransport } from './transport/index.js';
|
||||
|
||||
/** TransportStatus plus which implementation is live — Options needs `kind` to know
|
||||
* whether pairing controls and privileged settings.set are meaningful right now. */
|
||||
export type PanelStatus = TransportStatus & { kind: TransportKind | null };
|
||||
|
||||
export type PanelRequest =
|
||||
| { type: 'call'; id: number; method: MethodName; params: unknown }
|
||||
| { type: 'subscribe'; events: EventName[] }
|
||||
| { type: 'getStatus' }
|
||||
| { type: 'reconnect' }
|
||||
| { type: 'pair'; code: string }
|
||||
| { type: 'unpair' }
|
||||
| { type: 'setOverride'; override: 'auto' | 'ws' | 'uds' };
|
||||
|
||||
export type PanelResponse =
|
||||
| { type: 'result'; id: number; ok: true; result: unknown }
|
||||
| { type: 'result'; id: number; ok: false; error: { code?: number; message: string } }
|
||||
| { type: 'event'; event: string; payload: unknown }
|
||||
| { type: 'status'; status: PanelStatus }
|
||||
| { type: 'pairError'; error: { code?: number; message: string } };
|
||||
|
||||
export interface PortLike {
|
||||
name: string;
|
||||
postMessage(message: PanelResponse): void;
|
||||
onMessage: { addListener(cb: (msg: PanelRequest) => void): void };
|
||||
onDisconnect: { addListener(cb: () => void): void };
|
||||
}
|
||||
|
||||
export interface RuntimeOnConnectLike {
|
||||
addListener(cb: (port: PortLike) => void): void;
|
||||
}
|
||||
|
||||
const DISCONNECTED_STATUS: PanelStatus = {
|
||||
state: 'disconnected',
|
||||
needsPairing: false,
|
||||
fatal: null,
|
||||
retryAfterSec: null,
|
||||
sessionId: null,
|
||||
daemonVersion: null,
|
||||
capabilities: [],
|
||||
kind: null,
|
||||
};
|
||||
|
||||
function panelStatus(t: VeloxTransport | undefined): PanelStatus {
|
||||
return t ? { ...t.status, kind: t.kind } : DISCONNECTED_STATUS;
|
||||
}
|
||||
|
||||
function errorOf(e: unknown): { code?: number; message: string } {
|
||||
if (e && typeof e === 'object') {
|
||||
const code = 'code' in e && typeof (e as { code: unknown }).code === 'number' ? (e as { code: number }).code : undefined;
|
||||
const message = e instanceof Error ? e.message : String(e);
|
||||
return code === undefined ? { message } : { code, message };
|
||||
}
|
||||
return { message: String(e) };
|
||||
}
|
||||
|
||||
export interface PanelBridgeDeps {
|
||||
getTransport(): VeloxTransport | undefined;
|
||||
/** Rebuilds the transport for a new manual override ('auto' lets the runtime picker
|
||||
* decide again) and swaps it in. Needed because switching kind means constructing a
|
||||
* different Transport implementation, not a method on the existing one. */
|
||||
setOverride(override: 'auto' | 'ws' | 'uds'): Promise<void>;
|
||||
}
|
||||
|
||||
export class PanelBridge {
|
||||
constructor(private readonly deps: PanelBridgeDeps) {}
|
||||
|
||||
private getTransport(): VeloxTransport | undefined {
|
||||
return this.deps.getTransport();
|
||||
}
|
||||
|
||||
attach(onConnect: RuntimeOnConnectLike): void {
|
||||
onConnect.addListener((port) => this.handleConnect(port));
|
||||
}
|
||||
|
||||
private handleConnect(port: PortLike): void {
|
||||
const unsubscribe: Array<() => void> = [];
|
||||
let disposed = false;
|
||||
|
||||
const post = (msg: PanelResponse): void => {
|
||||
if (!disposed) port.postMessage(msg);
|
||||
};
|
||||
|
||||
// The transport may not exist yet (background just woke up). Retry briefly rather
|
||||
// than leaving the panel stuck on "connecting" forever.
|
||||
const attachStatus = (attemptsLeft: number): void => {
|
||||
const t = this.getTransport();
|
||||
if (t) {
|
||||
post({ type: 'status', status: panelStatus(t) });
|
||||
const off = t.onStateChange(() => post({ type: 'status', status: panelStatus(this.getTransport()) }));
|
||||
unsubscribe.push(off);
|
||||
return;
|
||||
}
|
||||
post({ type: 'status', status: DISCONNECTED_STATUS });
|
||||
if (attemptsLeft > 0 && !disposed) {
|
||||
const timer = setTimeout(() => attachStatus(attemptsLeft - 1), 300);
|
||||
unsubscribe.push(() => clearTimeout(timer));
|
||||
}
|
||||
};
|
||||
attachStatus(10);
|
||||
|
||||
port.onMessage.addListener((msg) => void this.handleMessage(msg, post, unsubscribe));
|
||||
port.onDisconnect.addListener(() => {
|
||||
disposed = true;
|
||||
for (const u of unsubscribe) u();
|
||||
unsubscribe.length = 0;
|
||||
});
|
||||
}
|
||||
|
||||
private async handleMessage(
|
||||
msg: PanelRequest,
|
||||
post: (m: PanelResponse) => void,
|
||||
unsubscribe: Array<() => void>,
|
||||
): Promise<void> {
|
||||
if (msg.type === 'getStatus') {
|
||||
post({ type: 'status', status: panelStatus(this.getTransport()) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'reconnect') {
|
||||
// "Start it" in the popup (docs/05 §5) — never a fresh call the panel builds
|
||||
// params for itself; it just asks the transport it already owns to try again.
|
||||
this.getTransport()
|
||||
?.connect()
|
||||
.catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'pair') {
|
||||
const t = this.getTransport();
|
||||
try {
|
||||
if (!t?.pairWithCode) throw new Error('pairing by code is only available on the WebSocket transport');
|
||||
await t.pairWithCode(msg.code);
|
||||
post({ type: 'status', status: panelStatus(t) });
|
||||
} catch (e) {
|
||||
post({ type: 'status', status: panelStatus(t) });
|
||||
post({ type: 'pairError', error: errorOf(e) });
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'unpair') {
|
||||
const t = this.getTransport();
|
||||
await t?.unpair?.();
|
||||
post({ type: 'status', status: panelStatus(this.getTransport()) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'setOverride') {
|
||||
await this.deps.setOverride(msg.override);
|
||||
post({ type: 'status', status: panelStatus(this.getTransport()) });
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'subscribe') {
|
||||
const t = this.getTransport();
|
||||
if (!t) return;
|
||||
for (const event of msg.events) {
|
||||
const cb = (payload: unknown) => post({ type: 'event', event, payload });
|
||||
t.on(event, cb);
|
||||
unsubscribe.push(() => t.off(event, cb));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// msg.type === 'call'
|
||||
const t = this.getTransport();
|
||||
if (!t) {
|
||||
post({ type: 'result', id: msg.id, ok: false, error: { message: 'transport not ready' } });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const result = await t.call(msg.method, msg.params as never);
|
||||
post({ type: 'result', id: msg.id, ok: true, result });
|
||||
} catch (e) {
|
||||
post({ type: 'result', id: msg.id, ok: false, error: errorOf(e) });
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -4,9 +4,11 @@
|
||||
// paths: the blocking onHeadersReceived hook and the downloads.onCreated safety net. The
|
||||
// popup relay and context menus attach here in later steps of the build order.
|
||||
|
||||
import { PanelBridge } from './bridge.js';
|
||||
import { DownloadsSafetyNet, type DownloadsApiLike } from './capture/downloads-api.js';
|
||||
import { HeaderStash, type WebRequestLike } from './capture/headers.js';
|
||||
import { CaptureHook, type HeadersReceivedWebRequest } from './capture/index.js';
|
||||
import { MediaWatcher, type MediaWebRequest } from './capture/media.js';
|
||||
import { OfferedUrls } from './capture/offered-urls.js';
|
||||
import { DEFAULT_CAPTURE_RULES } from './capture/rules.js';
|
||||
import {
|
||||
@@ -15,7 +17,8 @@ import {
|
||||
type MenusLike,
|
||||
type TabsLike,
|
||||
} from './context-menus.js';
|
||||
import { createTransport, type TransportStatus, type VeloxTransport } from './transport/index.js';
|
||||
import { MediaBridge, notifyTab } from './media-bridge.js';
|
||||
import { createTransport, transportStorage, type TransportStatus, type VeloxTransport } from './transport/index.js';
|
||||
import type { CaptureOfferParams, CaptureRules, DownloadSpec } from '../shared/protocol/index.js';
|
||||
|
||||
let transport: VeloxTransport | undefined;
|
||||
@@ -78,18 +81,36 @@ function onTransportState(status: TransportStatus): void {
|
||||
if (status.state === 'connected') void refreshRules();
|
||||
}
|
||||
|
||||
async function setOverride(override: 'auto' | 'ws' | 'uds'): Promise<void> {
|
||||
await transportStorage.setOverride(override);
|
||||
transport?.disconnect();
|
||||
transport = await createTransport({ override });
|
||||
transport.onStateChange(onTransportState);
|
||||
transport.on('event.settings.changed', onSettingsChanged);
|
||||
onTransportState(transport.status);
|
||||
}
|
||||
|
||||
const bridge = new PanelBridge({ getTransport: () => transport, setOverride });
|
||||
const mediaBridge = new MediaBridge(() => transport);
|
||||
const mediaWatcher = new MediaWatcher((detected) => notifyTab(browser.tabs, detected));
|
||||
|
||||
function onSettingsChanged(payload: unknown): void {
|
||||
const keys = (payload as { keys?: string[] }).keys ?? [];
|
||||
if (keys.some((k) => k.startsWith('capture.'))) void refreshRules();
|
||||
}
|
||||
|
||||
async function start(): Promise<void> {
|
||||
stash.attach(browser.webRequest as unknown as WebRequestLike);
|
||||
hook.attach(browser.webRequest as unknown as HeadersReceivedWebRequest);
|
||||
safetyNet.attach(browser.downloads as unknown as DownloadsApiLike);
|
||||
void contextMenus.register();
|
||||
bridge.attach(browser.runtime.onConnect);
|
||||
mediaBridge.attach(browser.runtime.onMessage);
|
||||
mediaWatcher.attach(browser.webRequest as unknown as MediaWebRequest);
|
||||
|
||||
transport = await createTransport();
|
||||
transport.onStateChange(onTransportState);
|
||||
transport.on('event.settings.changed', (payload) => {
|
||||
const keys = (payload as { keys?: string[] }).keys ?? [];
|
||||
if (keys.some((k) => k.startsWith('capture.'))) void refreshRules();
|
||||
});
|
||||
transport.on('event.settings.changed', onSettingsChanged);
|
||||
onTransportState(transport.status);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user