// 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(); private eventListeners = new Map void>>(); private statusListeners = new Set<(status: PanelStatus) => void>(); private pairErrorListeners = new Set<(error: { code?: number; message: string }) => void>(); private subscribed = new Set(); constructor(connect: () => browser.runtime.Port = () => browser.runtime.connect({ name: PORT_NAME })) { this.port = connect(); this.port.onMessage.addListener((raw) => this.onMessage(raw as Record)); } private onMessage(msg: Record): 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(method: M, params: Params): Promise> { const id = this.nextId++; return new Promise>((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(event: E, cb: (payload: EventPayload) => 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(); } }