Files
relicario/extension/src/popup/popup.ts
adlee-was-taken ce59223fc0 feat(ext): shared state host — decouple components from popup.ts
Introduce shared/state.ts as a service-locator so popup components
(item-detail, item-form, trash, devices, settings, etc.) work in both
the popup and vault tab bundles. Both entry points register themselves
as the host; components import from shared/state instead of popup.ts.
Vault.ts now delegates to the real popup components, removing ~300 lines
of placeholder renderers.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-27 16:38:06 -04:00

309 lines
9.5 KiB
TypeScript

/// Popup entry point — state machine with view routing.
///
/// Views: setup | locked | list | detail | add | edit | settings | settings-vault
/// Navigation works by updating `currentState` and calling `render()`.
import type { Request, Response } from '../shared/messages';
import type { ItemId, ManifestEntry, Item } from '../shared/types';
import { registerHost } from '../shared/state';
import { renderUnlock } from './components/unlock';
import { renderItemList } from './components/item-list';
import { renderItemDetail } from './components/item-detail';
import { renderItemForm } from './components/item-form';
import { renderSettings } from './components/settings';
import { renderVaultSettings } from './components/settings-vault';
import { renderTrash } from './components/trash';
import { renderDevices } from './components/devices';
import { renderFieldHistory } from './components/field-history';
import { teardown as teardownTrash } from './components/trash';
import { teardown as teardownDevices } from './components/devices';
import { teardown as teardownFieldHistory } from './components/field-history';
// --- Escape HTML to prevent XSS ---
export function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// --- Pop out to tab ---
export function isInTab(): boolean {
return window.location.search.length > 0;
}
export function openVaultTab(hash?: string): void {
const url = chrome.runtime.getURL('vault.html') + (hash ? `#${hash}` : '');
chrome.tabs.create({ url });
}
export function popOutToTab(): void {
const state = getState();
if (state.newType) {
openVaultTab(`add/${state.newType}`);
} else if (state.selectedId) {
openVaultTab(`${state.view}/${state.selectedId}`);
} else {
openVaultTab();
}
window.close();
}
function parseUrlParams(): { view?: View; type?: string; id?: string } | null {
const params = new URLSearchParams(window.location.search);
const view = params.get('view');
if (!view) return null;
return {
view: view as View,
type: params.get('type') ?? undefined,
id: params.get('id') ?? undefined,
};
}
// --- State ---
export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings' | 'settings-vault' | 'trash' | 'devices' | 'field-history';
export interface PopupState {
view: View;
entries: Array<[ItemId, ManifestEntry]>;
selectedId: ItemId | null;
selectedItem: Item | null;
selectedIndex: number;
searchQuery: string;
activeGroup: string | null;
error: string | null;
loading: boolean;
// Captured tab snapshot taken at popup-open. Used by fill_credentials
// to guard against TOCTOU navigation — the SW re-checks this URL's
// hostname against the tab's live URL before forwarding fill_credentials
// to the content script. See router/popup-only.ts#handleFillCredentials.
capturedTabId: number | null;
capturedUrl: string;
newType: import('../shared/types').ItemType | null;
vaultSettings: import('../shared/types').VaultSettings | null;
generatorDefaults: import('../shared/types').GeneratorRequest | null;
historyItemId: import('../shared/types').ItemId | null;
}
let currentState: PopupState = {
view: 'locked',
entries: [],
selectedId: null,
selectedItem: null,
selectedIndex: 0,
searchQuery: '',
activeGroup: null,
error: null,
loading: false,
capturedTabId: null,
capturedUrl: '',
newType: null,
vaultSettings: null,
generatorDefaults: null,
historyItemId: null,
};
export function getState(): PopupState {
return currentState;
}
export function setState(partial: Partial<PopupState>): void {
currentState = { ...currentState, ...partial };
render();
}
// --- Messaging ---
export function sendMessage(request: Request): Promise<Response> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(request, (response: Response) => {
if (response && !response.ok && response.error) {
// Replace cryptic low-level errors with user-readable messages.
response = { ok: false, error: humanizeError(response.error) };
}
resolve(response);
});
});
}
/// Translate cryptic Rust/serde/WASM error strings into messages a user
/// can act on. Unknown errors pass through unchanged.
export function humanizeError(err: string): string {
// URL parse failures (Rust `url::Url::parse`) bubble up through serde
// as `item json: ...`. Match the core phrasing.
if (/relative URL without a base/i.test(err)) {
return 'URL must start with https:// or http:// (e.g. https://example.com)';
}
if (/item json:/i.test(err)) {
return 'Could not save item — one of the fields is in an invalid format.';
}
if (/settings json:/i.test(err)) {
return 'Settings are in an invalid format — try reloading the extension.';
}
if (/vault_locked/i.test(err)) {
return 'Vault is locked. Unlock and try again.';
}
if (/origin_mismatch/i.test(err)) {
return 'This login belongs to a different site — refusing to leak credentials cross-origin.';
}
if (/unauthorized_sender/i.test(err)) {
return 'This action is not allowed from here.';
}
if (/tab_navigated|captured_tab_gone/i.test(err)) {
return 'The browser tab changed before the fill could complete — try again.';
}
return err;
}
// --- Navigation ---
export function navigate(view: View, extras?: Partial<PopupState>): void {
setState({ view, error: null, loading: false, ...extras });
}
// --- Register as state host so shared components can call back ---
registerHost({
getState: () => currentState,
setState,
navigate,
sendMessage,
escapeHtml,
popOutToTab,
isInTab,
openVaultTab,
});
// --- Render ---
function render(): void {
const app = document.getElementById('app');
if (!app) return;
teardownTrash();
teardownDevices();
teardownFieldHistory();
switch (currentState.view) {
case 'locked':
renderUnlock(app);
break;
case 'list':
renderItemList(app);
break;
case 'detail':
renderItemDetail(app);
break;
case 'add':
renderItemForm(app, 'add');
break;
case 'edit':
renderItemForm(app, 'edit');
break;
case 'settings':
renderSettings(app);
break;
case 'settings-vault':
renderVaultSettings(app);
break;
case 'trash':
renderTrash(app);
break;
case 'devices':
renderDevices(app);
break;
case 'field-history':
renderFieldHistory(app);
break;
}
}
// --- Init ---
async function init(): Promise<void> {
// Snapshot the active tab at popup-open — the fill path uses this
// tabId/url pair so the SW can verify the tab hasn't navigated before
// forwarding credentials (audit M5 + TOCTOU close via expectedHost).
const [tab] = await chrome.tabs.query({ active: true, currentWindow: true });
currentState.capturedTabId = tab?.id ?? null;
currentState.capturedUrl = tab?.url ?? '';
// Check if extension is configured.
const setupResp = await sendMessage({ type: 'get_setup_state' });
if (setupResp.ok) {
const data = setupResp.data as { isConfigured: boolean };
if (!data.isConfigured) {
await chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
window.close();
return;
}
}
// Check if vault is unlocked.
const unlockResp = await sendMessage({ type: 'is_unlocked' });
if (unlockResp.ok) {
const data = unlockResp.data as { unlocked: boolean };
if (data.unlocked) {
// Load entries and go to list.
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const listData = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
// Fetch vault settings so subsequent screens (generator popover,
// settings-vault) can show current values without a round-trip.
// Failures swallow silently — list view still renders; consumers
// can show "settings not loaded" if needed.
const vsResp = await sendMessage({ type: 'get_vault_settings' });
if (vsResp.ok) {
const vs = (vsResp.data as { settings: import('../shared/types').VaultSettings }).settings;
currentState.vaultSettings = vs;
currentState.generatorDefaults = vs.generator_defaults;
}
// Check URL params for deep linking (when opened in tab)
const urlParams = parseUrlParams();
if (urlParams) {
currentState.entries = listData.items;
if (urlParams.view === 'add' && urlParams.type) {
currentState.newType = urlParams.type as import('../shared/types').ItemType;
navigate('add');
return;
}
if ((urlParams.view === 'edit' || urlParams.view === 'detail') && urlParams.id) {
// Fetch the item
const itemResp = await sendMessage({ type: 'get_item', id: urlParams.id });
if (itemResp.ok) {
currentState.selectedId = urlParams.id;
currentState.selectedItem = (itemResp.data as { item: Item }).item;
navigate(urlParams.view);
return;
}
}
}
navigate('list', { entries: listData.items });
return;
}
}
}
navigate('locked');
}
document.addEventListener('DOMContentLoaded', () => {
init();
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'session_expired') {
currentState.view = 'locked';
currentState.error = null;
currentState.selectedItem = null;
currentState.selectedId = null;
render();
}
});
});