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:
2026-09-11 12:26:25 +04:00
co-authored by Claude Sonnet 5
parent 25c171f742
commit 55932a2e11
18 changed files with 1485 additions and 14 deletions
+117
View File
@@ -0,0 +1,117 @@
// Client side of background/bridge.ts, used by popup/ and options/ — the two documents
// that cannot import the background page's live VeloxTransport directly and instead
// talk to it over a `browser.runtime.connect` port.
import type { EventName, EventPayload, MethodName, Params, Result } from '../shared/protocol/index.js';
import type { PanelStatus } from '../background/bridge.js';
const PORT_NAME = 'velox-panel';
export class RpcCallError extends Error {
constructor(
readonly code: number | undefined,
message: string,
) {
super(message);
this.name = 'RpcCallError';
}
}
type Pending = { resolve: (v: unknown) => void; reject: (e: unknown) => void };
/** Thin promise-based RPC client plus event fan-out, over one long-lived port. */
export class PanelClient {
private port: browser.runtime.Port;
private nextId = 1;
private pending = new Map<number, Pending>();
private eventListeners = new Map<string, Set<(payload: unknown) => void>>();
private statusListeners = new Set<(status: PanelStatus) => void>();
private pairErrorListeners = new Set<(error: { code?: number; message: string }) => void>();
private subscribed = new Set<EventName>();
constructor(connect: () => browser.runtime.Port = () => browser.runtime.connect({ name: PORT_NAME })) {
this.port = connect();
this.port.onMessage.addListener((raw) => this.onMessage(raw as Record<string, unknown>));
}
private onMessage(msg: Record<string, unknown>): void {
if (msg['type'] === 'result') {
const id = msg['id'] as number;
const p = this.pending.get(id);
if (!p) return;
this.pending.delete(id);
if (msg['ok']) p.resolve(msg['result']);
else {
const err = msg['error'] as { code?: number; message: string };
p.reject(new RpcCallError(err.code, err.message));
}
} else if (msg['type'] === 'event') {
const listeners = this.eventListeners.get(msg['event'] as string);
if (listeners) for (const cb of listeners) cb(msg['payload']);
} else if (msg['type'] === 'status') {
for (const cb of this.statusListeners) cb(msg['status'] as PanelStatus);
} else if (msg['type'] === 'pairError') {
for (const cb of this.pairErrorListeners) cb(msg['error'] as { code?: number; message: string });
}
}
call<M extends MethodName>(method: M, params: Params<M>): Promise<Result<M>> {
const id = this.nextId++;
return new Promise<Result<M>>((resolve, reject) => {
this.pending.set(id, { resolve: resolve as (v: unknown) => void, reject });
this.port.postMessage({ type: 'call', id, method, params });
});
}
/** Adds `event` to the set this panel receives. Safe to call repeatedly. */
private ensureSubscribed(event: EventName): void {
if (this.subscribed.has(event)) return;
this.subscribed.add(event);
this.port.postMessage({ type: 'subscribe', events: [...this.subscribed] });
}
on<E extends EventName>(event: E, cb: (payload: EventPayload<E>) => void): () => void {
let set = this.eventListeners.get(event);
if (!set) {
set = new Set();
this.eventListeners.set(event, set);
}
set.add(cb as (payload: unknown) => void);
this.ensureSubscribed(event);
return () => set!.delete(cb as (payload: unknown) => void);
}
/** Asks the background page's transport to retry now, instead of waiting on backoff. */
reconnect(): void {
this.port.postMessage({ type: 'reconnect' });
}
onStatus(cb: (status: PanelStatus) => void): () => void {
this.statusListeners.add(cb);
this.port.postMessage({ type: 'getStatus' });
return () => this.statusListeners.delete(cb);
}
onPairError(cb: (error: { code?: number; message: string }) => void): () => void {
this.pairErrorListeners.add(cb);
return () => this.pairErrorListeners.delete(cb);
}
/** Options → "Pair" with a code typed from the daemon's dialog. */
pair(code: string): void {
this.port.postMessage({ type: 'pair', code });
}
/** Options → "Unpair": revoke the locally stored token. */
unpair(): void {
this.port.postMessage({ type: 'unpair' });
}
setOverride(override: 'auto' | 'ws' | 'uds'): void {
this.port.postMessage({ type: 'setOverride', override });
}
dispose(): void {
this.port.disconnect();
}
}