/// Popup entry point — state machine with view routing. /// /// Views: setup | locked | list | detail | add | edit /// Navigation works by updating `currentState` and calling `render()`. import type { Request, Response } from '../shared/messages'; import type { ItemId, ManifestEntry, Item } from '../shared/types'; 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'; // --- Escape HTML to prevent XSS --- export function escapeHtml(str: string): string { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // --- State --- export type View = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'settings'; 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; } let currentState: PopupState = { view: 'locked', entries: [], selectedId: null, selectedItem: null, selectedIndex: 0, searchQuery: '', activeGroup: null, error: null, loading: false, capturedTabId: null, capturedUrl: '', }; export function getState(): PopupState { return currentState; } export function setState(partial: Partial): void { currentState = { ...currentState, ...partial }; render(); } // --- Messaging --- export function sendMessage(request: Request): Promise { return new Promise((resolve) => { chrome.runtime.sendMessage(request, (response: Response) => { resolve(response); }); }); } // --- Navigation --- export function navigate(view: View, extras?: Partial): void { setState({ view, error: null, loading: false, ...extras }); } // --- Render --- function render(): void { const app = document.getElementById('app'); if (!app) return; 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; } } // --- Init --- async function init(): Promise { // 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]> }; navigate('list', { entries: listData.items }); return; } } } navigate('locked'); } document.addEventListener('DOMContentLoaded', init);