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,56 @@
|
||||
body {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 16px;
|
||||
font: 14px -apple-system, system-ui, sans-serif;
|
||||
color: #1a1a1a;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
h1 { margin: 0 0 16px; }
|
||||
|
||||
section {
|
||||
margin-bottom: 28px;
|
||||
padding-bottom: 20px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
section:last-of-type { border-bottom: none; }
|
||||
|
||||
h2 {
|
||||
font-size: 15px;
|
||||
margin: 0 0 10px;
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin: 8px 0;
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
select, input, textarea {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font: inherit;
|
||||
padding: 5px 7px;
|
||||
width: 100%;
|
||||
max-width: 320px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
button {
|
||||
font: inherit;
|
||||
padding: 6px 12px;
|
||||
margin-top: 8px;
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
#rules-locked {
|
||||
color: #888;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#pair-message {
|
||||
min-height: 1.2em;
|
||||
color: #555;
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Velox Options</title>
|
||||
<link rel="stylesheet" href="options.css" />
|
||||
</head>
|
||||
<body>
|
||||
<h1>Velox</h1>
|
||||
|
||||
<section>
|
||||
<h2>Transport</h2>
|
||||
<label>Connection
|
||||
<select id="transport-override">
|
||||
<option value="auto">Automatic (recommended)</option>
|
||||
<option value="ws">WebSocket only</option>
|
||||
<option value="uds">Native messaging only</option>
|
||||
</select>
|
||||
</label>
|
||||
<p id="transport-status">Connecting…</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Pairing</h2>
|
||||
<p>If the Velox app isn't running to show an Allow prompt, type the 4-digit code it displayed:</p>
|
||||
<input id="pair-code" type="text" inputmode="numeric" maxlength="8" placeholder="Pairing code" />
|
||||
<button id="pair-button" type="button">Pair</button>
|
||||
<button id="unpair-button" type="button">Unpair</button>
|
||||
<p id="pair-message"></p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Capture policy</h2>
|
||||
<p id="rules-summary">Loading…</p>
|
||||
<p id="rules-locked">These are set by the Velox app or CLI. Connect via native messaging (or edit them there) to change them from here.</p>
|
||||
<form id="rules-form" hidden>
|
||||
<label>Minimum size to capture (bytes)
|
||||
<input id="min-size" type="number" min="0" />
|
||||
</label>
|
||||
<label>Excluded hosts (one per line)
|
||||
<textarea id="excluded-hosts" rows="4"></textarea>
|
||||
</label>
|
||||
<label>Bypass modifier
|
||||
<select id="bypass-modifier">
|
||||
<option value="alt">Alt</option>
|
||||
<option value="ctrl">Ctrl</option>
|
||||
<option value="shift">Shift</option>
|
||||
<option value="none">None</option>
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2>Default category</h2>
|
||||
<p>Applied to downloads sent from the right-click menu and the keyboard shortcut.</p>
|
||||
<select id="default-category"><option value="">Loading…</option></select>
|
||||
</section>
|
||||
|
||||
<script type="module" src="options.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,133 @@
|
||||
// Options page: transport & pairing, the daemon's capture policy (mirrored, editable
|
||||
// only over the privileged uds transport), min size / exclusions / bypass modifier
|
||||
// (same privileged path), and the one setting that is genuinely the extension's own —
|
||||
// the default category for extension-initiated downloads (prefs.ts). docs/05 §3.
|
||||
|
||||
import { PanelClient } from '../shared/panel-client.js';
|
||||
import type { PanelStatus } from '../background/bridge.js';
|
||||
import type { Category, CaptureRules } from '../shared/protocol/index.js';
|
||||
import { getDefaultCategoryId, setDefaultCategoryId } from './prefs.js';
|
||||
import { captureRulesEditable, pairingAvailable, statusLine, unpairAvailable } from './view.js';
|
||||
|
||||
const client = new PanelClient();
|
||||
|
||||
const $transportSelect = document.querySelector<HTMLSelectElement>('#transport-override')!;
|
||||
const $statusText = document.querySelector<HTMLElement>('#transport-status')!;
|
||||
const $pairCode = document.querySelector<HTMLInputElement>('#pair-code')!;
|
||||
const $pairButton = document.querySelector<HTMLButtonElement>('#pair-button')!;
|
||||
const $unpairButton = document.querySelector<HTMLButtonElement>('#unpair-button')!;
|
||||
const $pairMessage = document.querySelector<HTMLElement>('#pair-message')!;
|
||||
const $rulesSummary = document.querySelector<HTMLElement>('#rules-summary')!;
|
||||
const $rulesForm = document.querySelector<HTMLFormElement>('#rules-form')!;
|
||||
const $rulesLocked = document.querySelector<HTMLElement>('#rules-locked')!;
|
||||
const $minSize = document.querySelector<HTMLInputElement>('#min-size')!;
|
||||
const $excludedHosts = document.querySelector<HTMLTextAreaElement>('#excluded-hosts')!;
|
||||
const $bypassModifier = document.querySelector<HTMLSelectElement>('#bypass-modifier')!;
|
||||
const $defaultCategory = document.querySelector<HTMLSelectElement>('#default-category')!;
|
||||
|
||||
let latestRules: CaptureRules | null = null;
|
||||
|
||||
function renderStatus(status: PanelStatus): void {
|
||||
$transportSelect.value = status.kind ?? 'auto';
|
||||
$statusText.textContent = statusLine(status);
|
||||
|
||||
$pairButton.disabled = !pairingAvailable(status);
|
||||
$pairCode.disabled = !pairingAvailable(status);
|
||||
$unpairButton.disabled = !unpairAvailable(status);
|
||||
|
||||
const canEdit = captureRulesEditable(status);
|
||||
$rulesForm.hidden = !canEdit;
|
||||
$rulesLocked.hidden = canEdit;
|
||||
}
|
||||
|
||||
function renderRulesSummary(rules: CaptureRules): void {
|
||||
latestRules = rules;
|
||||
$rulesSummary.textContent = rules.enabled
|
||||
? `Capturing ${rules.monitoredExtensions.length} extension(s) and ${rules.monitoredMimeTypes.length} MIME type(s), min size ${rules.minSizeBytes} bytes.`
|
||||
: 'Capture is disabled on the daemon.';
|
||||
$minSize.value = String(rules.minSizeBytes);
|
||||
$excludedHosts.value = rules.excludedHosts.join('\n');
|
||||
$bypassModifier.value = rules.bypassModifier ?? 'alt';
|
||||
}
|
||||
|
||||
async function refreshRules(): Promise<void> {
|
||||
try {
|
||||
const rules = await client.call('capture.getRules', {});
|
||||
renderRulesSummary(rules);
|
||||
} catch {
|
||||
$rulesSummary.textContent = 'Could not read the daemon’s capture policy.';
|
||||
}
|
||||
}
|
||||
|
||||
function makeOption(value: string, label: string): HTMLOptionElement {
|
||||
const opt = document.createElement('option');
|
||||
opt.value = value;
|
||||
opt.textContent = label;
|
||||
return opt;
|
||||
}
|
||||
|
||||
async function refreshCategories(): Promise<void> {
|
||||
try {
|
||||
const { items } = await client.call('category.list', {});
|
||||
const current = await getDefaultCategoryId();
|
||||
$defaultCategory.replaceChildren(
|
||||
makeOption('', '(none — daemon default)'),
|
||||
...items.map((c: Category) => makeOption(c.categoryId, c.name)),
|
||||
);
|
||||
$defaultCategory.value = current ?? '';
|
||||
} catch {
|
||||
$defaultCategory.replaceChildren(makeOption('', '(unavailable — not connected)'));
|
||||
}
|
||||
}
|
||||
|
||||
$transportSelect.addEventListener('change', () => {
|
||||
client.setOverride($transportSelect.value as 'auto' | 'ws' | 'uds');
|
||||
});
|
||||
|
||||
$pairButton.addEventListener('click', () => {
|
||||
const code = $pairCode.value.trim();
|
||||
if (!code) return;
|
||||
$pairMessage.textContent = 'Pairing…';
|
||||
client.pair(code);
|
||||
});
|
||||
|
||||
$unpairButton.addEventListener('click', () => {
|
||||
client.unpair();
|
||||
$pairMessage.textContent = 'Unpaired.';
|
||||
});
|
||||
|
||||
$defaultCategory.addEventListener('change', () => {
|
||||
void setDefaultCategoryId($defaultCategory.value || null);
|
||||
});
|
||||
|
||||
$rulesForm.addEventListener('submit', (e) => {
|
||||
e.preventDefault();
|
||||
if (!latestRules) return;
|
||||
const values = {
|
||||
'capture.minSizeBytes': Number($minSize.value) || 0,
|
||||
'capture.excludedHosts': $excludedHosts.value
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
'capture.bypassModifier': $bypassModifier.value as CaptureRules['bypassModifier'],
|
||||
};
|
||||
client
|
||||
.call('settings.set', { values })
|
||||
.then(() => refreshRules())
|
||||
.catch(() => {
|
||||
$rulesSummary.textContent = 'Failed to save — is Velox still connected via native messaging?';
|
||||
});
|
||||
});
|
||||
|
||||
async function start(): Promise<void> {
|
||||
client.onStatus(renderStatus);
|
||||
client.onPairError((err) => {
|
||||
$pairMessage.textContent = `Pairing failed: ${err.message}`;
|
||||
});
|
||||
client.on('event.settings.changed', (payload) => {
|
||||
if (payload.keys.some((k) => k.startsWith('capture.'))) void refreshRules();
|
||||
});
|
||||
await Promise.all([refreshRules(), refreshCategories()]);
|
||||
}
|
||||
|
||||
void start();
|
||||
@@ -0,0 +1,25 @@
|
||||
// Extension-local preferences: state that belongs to this browser install, not to the
|
||||
// daemon's Settings bag. The protocol draws a hard line here — settings.set and
|
||||
// rules.upsert are privileged, uds-only methods (METHODS in shared/protocol) — so an
|
||||
// Options page reachable only over the WebSocket transport cannot write the daemon's
|
||||
// capture policy no matter what the UI looks like. Rather than inventing a protocol
|
||||
// field to work around that (CLAUDE.md §2 forbids exactly this), the extension:
|
||||
// - mirrors the daemon's capture policy read-only via capture.getRules (works on
|
||||
// both transports, and is what shouldCapture() itself already trusts), and
|
||||
// - keeps the one piece of "options" state that genuinely is the extension's own —
|
||||
// which category new captures/context-menu downloads default to — in
|
||||
// browser.storage.local, exactly like the pairing token and transport override.
|
||||
// Editing the mirrored capture policy is only offered when connected via NativeTransport,
|
||||
// where settings.set is allowed; see options.ts.
|
||||
|
||||
const KEY = { defaultCategoryId: 'velox.defaultCategoryId' } as const;
|
||||
|
||||
export async function getDefaultCategoryId(): Promise<string | null> {
|
||||
const bag = await browser.storage.local.get(KEY.defaultCategoryId);
|
||||
return (bag[KEY.defaultCategoryId] as string | undefined) ?? null;
|
||||
}
|
||||
|
||||
export async function setDefaultCategoryId(categoryId: string | null): Promise<void> {
|
||||
if (categoryId === null) await browser.storage.local.remove(KEY.defaultCategoryId);
|
||||
else await browser.storage.local.set({ [KEY.defaultCategoryId]: categoryId });
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
// Pure view-model helpers for options.ts, kept separate from the DOM so the decisions
|
||||
// that matter (when pairing controls are enabled, when the capture-policy form is
|
||||
// editable, what the status line says) are unit-testable without a document.
|
||||
|
||||
import type { PanelStatus } from '../background/bridge.js';
|
||||
|
||||
export function statusLine(status: PanelStatus): string {
|
||||
const parts: string[] = [status.state];
|
||||
if (status.daemonVersion) parts.push(`v${status.daemonVersion}`);
|
||||
if (status.kind) parts.push(`via ${status.kind === 'uds' ? 'native messaging' : 'WebSocket'}`);
|
||||
if (status.needsPairing) {
|
||||
parts.push(status.retryAfterSec ? `pairing locked (${status.retryAfterSec}s)` : 'needs pairing');
|
||||
}
|
||||
if (status.fatal) parts.push(status.fatal);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** Pairing is a WebSocket-transport concept; native messaging has no token. Also true
|
||||
* before the transport kind is known yet, so the control isn't stuck disabled forever
|
||||
* on first paint. */
|
||||
export function pairingAvailable(status: PanelStatus): boolean {
|
||||
return status.kind === 'ws' || status.kind === null;
|
||||
}
|
||||
|
||||
export function unpairAvailable(status: PanelStatus): boolean {
|
||||
return status.kind === 'ws';
|
||||
}
|
||||
|
||||
/**
|
||||
* settings.set and rules.upsert are privileged, uds-only methods (shared/protocol
|
||||
* METHODS) — the daemon refuses them over WebSocket with -32003 regardless of what this
|
||||
* page renders. So the capture-policy form is only ever offered as editable when the
|
||||
* active transport is native messaging; on WebSocket it is a read-only mirror, per
|
||||
* CLAUDE.md §2 ("working around a wrong contract locally" is not an option here — this
|
||||
* boundary is deliberate, not wrong).
|
||||
*/
|
||||
export function captureRulesEditable(status: PanelStatus): boolean {
|
||||
return status.kind === 'uds';
|
||||
}
|
||||
Reference in New Issue
Block a user