Files
relicario/extension/src/popup/popup.ts
adlee-was-taken 3238ef4dd4 refactor(ext/popup): remove last @ts-nocheck, align to typed-item types
Clears the final four transitional @ts-nocheck shields:
- popup.ts (already mostly updated in Slice 6 prior tasks; nocheck just
  removed and the init fallback switched to list_items / ItemId typing)
- unlock.ts (list_entries → list_items; ManifestEntry typing)
- settings.ts (RelicarioSettings → DeviceSettings; pure type rename, UX
  unchanged)

Also drops the stale `idfoto-extension` name in bun.lock (workspace was
renamed; lock file still carried the old name).

Verification:
  git grep -n '@ts-nocheck' extension/src/  → 0 hits
  bun run build + build:firefox             → both green
  bun run test                              → 52/52 passing
  cargo test --workspace                    → green

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-20 21:44:12 -04:00

150 lines
4.1 KiB
TypeScript

/// 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<PopupState>): void {
currentState = { ...currentState, ...partial };
render();
}
// --- Messaging ---
export function sendMessage(request: Request): Promise<Response> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(request, (response: Response) => {
resolve(response);
});
});
}
// --- Navigation ---
export function navigate(view: View, extras?: Partial<PopupState>): 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<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]> };
navigate('list', { entries: listData.items });
return;
}
}
}
navigate('locked');
}
document.addEventListener('DOMContentLoaded', init);