7 tasks: session timer, popup navigation, vault scaffold, shared state host, device settings, router fix, manual testing. Co-Authored-By: Claude <noreply@anthropic.com>
42 KiB
Vault Tab UI + Session Timeout — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add a full "desktop-like" vault UI in a browser tab with sidebar+detail layout, plus configurable session timeout shared between popup and vault tab.
Architecture: New vault.html entry point with its own state management and hash-based routing, rendered as a sidebar+pane layout. Session timeout lives in the service worker via a new session-timer.ts module that resets on every message and broadcasts session_expired to all views. Both popup and vault tab import the same form/detail component renderers — vault passes its right pane element, popup passes its app element.
Tech Stack: TypeScript, webpack (new entry point), Chrome MV3 APIs (chrome.commands, chrome.runtime.sendMessage, chrome.storage.local)
Task 1: Session Timer — Service Worker
Files:
-
Create:
src/service-worker/session-timer.ts -
Modify:
src/service-worker/index.ts -
Modify:
src/service-worker/session.ts -
Modify:
src/shared/messages.ts -
Test:
src/service-worker/__tests__/session-timer.test.ts -
Step 1: Define session config types in shared/messages.ts
Add the new message types and session config type. In src/shared/messages.ts, add to the PopupMessage union:
| { type: 'get_session_config' }
| { type: 'update_session_config'; config: SessionTimeoutConfig }
Add the config type at the top of the file:
export type SessionTimeoutConfig =
| { mode: 'inactivity'; minutes: number }
| { mode: 'every_time' };
Add both new types to the POPUP_ONLY_TYPES set.
- Step 2: Write the failing test for session-timer.ts
Create src/service-worker/__tests__/session-timer.test.ts:
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { resetTimer, stopTimer, getConfig, setConfig, onExpired } from '../session-timer';
describe('session-timer', () => {
beforeEach(() => {
vi.useFakeTimers();
stopTimer();
// Reset to default
setConfig({ mode: 'inactivity', minutes: 15 });
});
afterEach(() => {
vi.useRealTimers();
stopTimer();
});
it('fires callback after inactivity timeout', () => {
const cb = vi.fn();
onExpired(cb);
resetTimer();
vi.advanceTimersByTime(15 * 60 * 1000);
expect(cb).toHaveBeenCalledOnce();
});
it('resets the timer on each call to resetTimer', () => {
const cb = vi.fn();
onExpired(cb);
resetTimer();
vi.advanceTimersByTime(14 * 60 * 1000);
resetTimer(); // reset before it fires
vi.advanceTimersByTime(14 * 60 * 1000);
expect(cb).not.toHaveBeenCalled();
vi.advanceTimersByTime(1 * 60 * 1000);
expect(cb).toHaveBeenCalledOnce();
});
it('does not fire when mode is every_time', () => {
const cb = vi.fn();
onExpired(cb);
setConfig({ mode: 'every_time' });
resetTimer();
vi.advanceTimersByTime(60 * 60 * 1000);
expect(cb).not.toHaveBeenCalled();
});
it('respects updated minutes', () => {
const cb = vi.fn();
onExpired(cb);
setConfig({ mode: 'inactivity', minutes: 5 });
resetTimer();
vi.advanceTimersByTime(5 * 60 * 1000);
expect(cb).toHaveBeenCalledOnce();
});
it('getConfig returns current config', () => {
setConfig({ mode: 'inactivity', minutes: 30 });
expect(getConfig()).toEqual({ mode: 'inactivity', minutes: 30 });
});
it('stopTimer prevents firing', () => {
const cb = vi.fn();
onExpired(cb);
resetTimer();
stopTimer();
vi.advanceTimersByTime(60 * 60 * 1000);
expect(cb).not.toHaveBeenCalled();
});
});
- Step 3: Run test to verify it fails
Run: cd /home/alee/Sources/relicario/extension && bun test session-timer
Expected: FAIL — module not found
- Step 4: Implement session-timer.ts
Create src/service-worker/session-timer.ts:
import type { SessionTimeoutConfig } from '../shared/messages';
let config: SessionTimeoutConfig = { mode: 'inactivity', minutes: 15 };
let timerId: ReturnType<typeof setTimeout> | null = null;
let expiredCallback: (() => void) | null = null;
export function onExpired(cb: () => void): void {
expiredCallback = cb;
}
export function getConfig(): SessionTimeoutConfig {
return config;
}
export function setConfig(c: SessionTimeoutConfig): void {
config = c;
stopTimer();
}
export function resetTimer(): void {
stopTimer();
if (config.mode === 'every_time') return;
timerId = setTimeout(() => {
timerId = null;
expiredCallback?.();
}, config.minutes * 60 * 1000);
}
export function stopTimer(): void {
if (timerId !== null) {
clearTimeout(timerId);
timerId = null;
}
}
- Step 5: Run test to verify it passes
Run: cd /home/alee/Sources/relicario/extension && bun test session-timer
Expected: all 6 tests PASS
- Step 6: Wire timer into service worker index.ts
In src/service-worker/index.ts, import the timer and wire it up:
import { resetTimer, onExpired, setConfig, getConfig } from './session-timer';
import { clearCurrent } from './session';
In the message listener, after state.wasm is initialized and before route(), add timer reset:
resetTimer();
Add the expiration handler at module scope (after state is defined):
onExpired(() => {
clearCurrent();
state.manifest = null;
chrome.runtime.sendMessage({ type: 'session_expired' }).catch(() => {});
});
Load saved config from chrome.storage.local during init:
chrome.storage.local.get('session_timeout').then((result) => {
if (result.session_timeout) setConfig(result.session_timeout);
});
- Step 7: Add session config handlers to router/popup-only.ts
In src/service-worker/router/popup-only.ts, add two new cases:
case 'get_session_config': {
const { getConfig } = await import('../session-timer');
return { ok: true, data: { config: getConfig() } };
}
case 'update_session_config': {
const { setConfig, resetTimer } = await import('../session-timer');
const config = (msg as { config: SessionTimeoutConfig }).config;
setConfig(config);
resetTimer();
await chrome.storage.local.set({ session_timeout: config });
return { ok: true };
}
Import SessionTimeoutConfig from ../../shared/messages.
- Step 8: Build and run tests
Run: cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5 && bun test session-timer
Expected: Build succeeds, tests pass.
- Step 9: Commit
git add src/service-worker/session-timer.ts src/service-worker/__tests__/session-timer.test.ts src/service-worker/index.ts src/service-worker/router/popup-only.ts src/shared/messages.ts
git commit -m "feat(ext/sw): add session inactivity timeout with configurable timer"
Task 2: Popup Listens for Session Expiry + "Open Vault" Links
Files:
-
Modify:
src/popup/popup.ts -
Modify:
src/popup/components/unlock.ts -
Modify:
src/popup/components/item-list.ts -
Step 1: Add session_expired listener to popup.ts
In src/popup/popup.ts, in the DOMContentLoaded handler (after sendMessage({ type: 'is_unlocked' })), add a listener:
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'session_expired') {
currentState.view = 'locked';
currentState.error = null;
currentState.selectedItem = null;
currentState.selectedId = null;
render();
}
});
- Step 2: Add openVaultTab helper to popup.ts
Export a helper function in src/popup/popup.ts:
export function openVaultTab(hash?: string): void {
const url = chrome.runtime.getURL('vault.html') + (hash ? `#${hash}` : '');
chrome.tabs.create({ url });
}
- Step 3: Add "Open vault" link to unlock screen
In src/popup/components/unlock.ts, find the settings button at the bottom and add an "open vault" button next to it:
<button class="btn" id="open-vault-btn">open vault</button>
Wire the listener:
document.getElementById('open-vault-btn')?.addEventListener('click', () => openVaultTab());
Import openVaultTab from ../popup.
- Step 4: Add "Open vault" button and Shift+F to item list toolbar
In src/popup/components/item-list.ts, add a small button to the toolbar row (after lock button):
<button class="btn" id="vault-btn" style="font-size:11px;" title="Open vault (Shift+F)">⤴</button>
Wire the listener:
document.getElementById('vault-btn')?.addEventListener('click', () => openVaultTab());
In handleListKeydown, add Shift+F handler before the existing key checks:
if (e.key === 'F' && e.shiftKey) {
e.preventDefault();
openVaultTab();
return;
}
Import openVaultTab from ../popup.
- Step 5: Update popOutToTab to use vault.html
In src/popup/popup.ts, change popOutToTab() to open vault.html with a hash:
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();
}
- Step 6: Build and smoke test
Run: cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5
Expected: Build succeeds.
- Step 7: Commit
git add src/popup/popup.ts src/popup/components/unlock.ts src/popup/components/item-list.ts
git commit -m "feat(ext/popup): session expiry listener, open-vault links, Shift+F shortcut"
Task 3: Webpack + Manifest + vault.html Scaffold
Files:
-
Create:
src/vault/vault.html -
Create:
src/vault/vault.css -
Create:
src/vault/vault.ts -
Modify:
webpack.config.js -
Modify:
manifest.json -
Step 1: Create vault.html
Create src/vault/vault.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>relicario — vault</title>
<link rel="stylesheet" href="vault.css">
</head>
<body>
<div id="vault-app"></div>
<script src="vault.js"></script>
</body>
</html>
- Step 2: Create vault.css with shared base styles and layout
Create src/vault/vault.css. Copy the base styles (reset, colors, typography, scrollbar, buttons, inputs, form elements) from src/popup/styles.css — everything up through the component styles but NOT the popup-specific layout (body width/max-height). Then add the vault layout:
/* ---- Vault layout ---- */
body {
background: #0d1117;
color: #c9d1d9;
font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', Menlo, monospace;
font-size: 13px;
line-height: 1.5;
margin: 0;
height: 100vh;
overflow: hidden;
}
#vault-app {
display: flex;
height: 100vh;
}
.vault-sidebar {
width: 260px;
min-width: 260px;
border-right: 1px solid #21262d;
display: flex;
flex-direction: column;
height: 100vh;
overflow: hidden;
}
.vault-sidebar__header {
padding: 12px 16px;
border-bottom: 1px solid #21262d;
display: flex;
align-items: center;
gap: 8px;
}
.vault-sidebar__search {
padding: 8px 12px;
border-bottom: 1px solid #21262d;
}
.vault-sidebar__search input {
width: 100%;
background: #161b22;
border: 1px solid #30363d;
border-radius: 4px;
color: #c9d1d9;
padding: 6px 10px;
font-size: 12px;
font-family: inherit;
}
.vault-sidebar__list {
flex: 1;
overflow-y: auto;
}
.vault-sidebar__nav {
border-top: 1px solid #21262d;
padding: 8px 0;
}
.vault-sidebar__nav-item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 16px;
color: #8b949e;
font-size: 12px;
cursor: pointer;
border: none;
background: none;
width: 100%;
text-align: left;
font-family: inherit;
}
.vault-sidebar__nav-item:hover {
color: #c9d1d9;
background: #161b22;
}
.vault-pane {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
}
.vault-pane--empty {
display: flex;
align-items: center;
justify-content: center;
color: #484f58;
font-size: 14px;
}
/* Sidebar item rows */
.vault-entry {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
cursor: pointer;
border-left: 2px solid transparent;
font-size: 12px;
}
.vault-entry:hover {
background: #161b22;
}
.vault-entry.selected {
background: #161b22;
border-left-color: #d2ab43;
}
.vault-entry__title {
color: #c9d1d9;
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.vault-entry__meta {
color: #484f58;
font-size: 11px;
}
/* Type group headers in sidebar */
.vault-group-header {
padding: 12px 16px 4px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #484f58;
}
Include the shared component styles the pane needs (form groups, buttons, field rows, detail view, generator panel, attachments, etc.) — copy these from src/popup/styles.css so the pane renders correctly. Omit the popup-specific .search-bar, .keyhints, .entry-list, .entry-row classes (the vault sidebar uses its own classes above).
- Step 3: Create vault.ts scaffold with state management and hash routing
Create src/vault/vault.ts:
import type { Item, ItemId, ItemType, ManifestEntry, VaultSettings } from '../shared/types';
import type { Request, Response } from '../shared/messages';
// --- Messaging ---
function sendMessage(request: Request): Promise<Response> {
return new Promise((resolve) => {
chrome.runtime.sendMessage(request, (response: Response) => {
resolve(response);
});
});
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
}
// --- State ---
type VaultView = 'locked' | 'list' | 'detail' | 'add' | 'edit' | 'trash' | 'devices' | 'settings' | 'settings-vault' | 'field-history';
interface VaultState {
view: VaultView;
entries: Array<[ItemId, ManifestEntry]>;
selectedId: ItemId | null;
selectedItem: Item | null;
searchQuery: string;
newType: ItemType | null;
error: string | null;
loading: boolean;
vaultSettings: VaultSettings | null;
historyItemId: ItemId | null;
}
const state: VaultState = {
view: 'locked',
entries: [],
selectedId: null,
selectedItem: null,
searchQuery: '',
newType: null,
error: null,
loading: false,
vaultSettings: null,
historyItemId: null,
};
// --- Hash routing ---
function parseHash(): { view: string; param?: string } {
const hash = window.location.hash.slice(1); // remove '#'
if (!hash) return { view: 'list' };
const [view, param] = hash.split('/');
return { view, param };
}
function setHash(view: string, param?: string): void {
window.location.hash = param ? `${view}/${param}` : view;
}
// --- Render ---
function render(): void {
const app = document.getElementById('vault-app');
if (!app) return;
if (state.view === 'locked') {
renderLockScreen(app);
return;
}
renderShell(app);
}
function renderLockScreen(app: HTMLElement): void {
app.innerHTML = `
<div style="display:flex; align-items:center; justify-content:center; height:100vh; width:100%;">
<div style="text-align:center; width:320px;">
<img class="brand-logo" src="icons/icon128.png" alt="relicario">
<div class="brand" style="margin-bottom:4px;">relicario</div>
<div class="muted" style="margin-bottom:20px;">two-factor vault</div>
${state.error ? `<div class="error" style="margin-bottom:12px;">${escapeHtml(state.error)}</div>` : ''}
<input type="password" id="passphrase" placeholder="passphrase"
style="width:100%; margin-bottom:12px;">
<button class="btn btn-primary" id="unlock-btn" style="width:100%;">unlock</button>
</div>
</div>
`;
const input = document.getElementById('passphrase') as HTMLInputElement;
input?.focus();
const doUnlock = async (): Promise<void> => {
const passphrase = input.value;
if (!passphrase) return;
state.loading = true;
state.error = null;
const resp = await sendMessage({ type: 'unlock', passphrase });
state.loading = false;
if (resp.ok) {
await loadManifest();
} else {
state.error = resp.error ?? 'Unlock failed';
render();
}
};
document.getElementById('unlock-btn')?.addEventListener('click', doUnlock);
input?.addEventListener('keydown', (e) => {
if (e.key === 'Enter') doUnlock();
});
}
function renderShell(app: HTMLElement): void {
// Preserve sidebar if already rendered, only update pane
if (!app.querySelector('.vault-sidebar')) {
app.innerHTML = `
<div class="vault-sidebar">
<div class="vault-sidebar__header">
<img src="icons/icon48.png" alt="" style="width:24px; height:24px;">
<span class="brand" style="font-size:13px;">relicario</span>
<span style="flex:1;"></span>
<button class="btn" id="vault-lock-btn" style="font-size:11px;">lock</button>
</div>
<div class="vault-sidebar__search">
<input type="text" id="vault-search" placeholder="/ search...">
</div>
<div class="vault-sidebar__list" id="vault-item-list"></div>
<div class="vault-sidebar__nav">
<button class="vault-sidebar__nav-item" data-nav="trash">🗑 trash</button>
<button class="vault-sidebar__nav-item" data-nav="devices">📱 devices</button>
<button class="vault-sidebar__nav-item" data-nav="settings">⚙ settings</button>
</div>
</div>
<div class="vault-pane vault-pane--empty" id="vault-pane">
select an item
</div>
`;
wireSidebar();
}
renderSidebarList();
renderPane();
}
function wireSidebar(): void {
const searchInput = document.getElementById('vault-search') as HTMLInputElement | null;
searchInput?.addEventListener('input', () => {
state.searchQuery = searchInput.value;
renderSidebarList();
});
document.getElementById('vault-lock-btn')?.addEventListener('click', async () => {
await sendMessage({ type: 'lock' });
state.view = 'locked';
state.selectedItem = null;
state.selectedId = null;
state.entries = [];
render();
});
document.querySelectorAll<HTMLButtonElement>('[data-nav]').forEach((btn) => {
btn.addEventListener('click', () => {
const nav = btn.dataset.nav as VaultView;
state.view = nav;
state.selectedId = null;
state.selectedItem = null;
setHash(nav);
renderPane();
});
});
document.addEventListener('keydown', (e) => {
if (e.key === '/' && !(e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement)) {
e.preventDefault();
searchInput?.focus();
}
});
}
function renderSidebarList(): void {
const listEl = document.getElementById('vault-item-list');
if (!listEl) return;
let filtered = state.entries;
if (state.searchQuery) {
const q = state.searchQuery.toLowerCase();
filtered = filtered.filter(([, e]) =>
e.title.toLowerCase().includes(q) ||
(e.icon_hint ?? '').toLowerCase().includes(q) ||
e.tags.some(t => t.toLowerCase().includes(q))
);
}
// Group by type
const groups = new Map<string, Array<[ItemId, ManifestEntry]>>();
for (const entry of filtered) {
const type = entry[1].type;
if (!groups.has(type)) groups.set(type, []);
groups.get(type)!.push(entry);
}
const typeOrder: string[] = ['login', 'secure_note', 'identity', 'card', 'key', 'totp', 'document'];
const typeLabel: Record<string, string> = {
login: 'logins', secure_note: 'notes', identity: 'identities',
card: 'cards', key: 'keys', totp: 'totp', document: 'documents',
};
const typeIcon: Record<string, string> = {
login: '🔑', secure_note: '📝', identity: '🪪',
card: '💳', key: '🗝', totp: '⏱', document: '📄',
};
let html = '';
for (const type of typeOrder) {
const items = groups.get(type);
if (!items || items.length === 0) continue;
html += `<div class="vault-group-header">${typeIcon[type] ?? ''} ${typeLabel[type] ?? type}</div>`;
for (const [id, e] of items) {
const sel = id === state.selectedId ? ' selected' : '';
html += `
<div class="vault-entry${sel}" data-id="${escapeHtml(id)}">
<span class="vault-entry__title">${escapeHtml(e.title)}</span>
<span class="vault-entry__meta">${escapeHtml(e.icon_hint ?? '')}</span>
</div>`;
}
}
if (!html) html = '<div style="padding:24px 16px; color:#484f58; text-align:center;">no items</div>';
listEl.innerHTML = html;
listEl.querySelectorAll<HTMLElement>('.vault-entry').forEach((el) => {
el.addEventListener('click', async () => {
const id = el.dataset.id!;
state.selectedId = id;
state.loading = true;
renderSidebarList(); // update selection highlight
const resp = await sendMessage({ type: 'get_item', id });
state.loading = false;
if (resp.ok) {
const data = resp.data as { item: Item };
state.selectedItem = data.item;
state.view = 'detail';
setHash('item', id);
renderPane();
}
});
});
}
function renderPane(): void {
const pane = document.getElementById('vault-pane');
if (!pane) return;
pane.className = 'vault-pane';
switch (state.view) {
case 'detail':
if (state.selectedItem) {
renderPaneDetail(pane, state.selectedItem);
} else {
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';
}
break;
case 'add':
renderPaneAdd(pane);
break;
case 'edit':
renderPaneEdit(pane);
break;
case 'trash':
renderPaneSection(pane, 'trash');
break;
case 'devices':
renderPaneSection(pane, 'devices');
break;
case 'settings':
renderPaneSection(pane, 'settings');
break;
case 'settings-vault':
renderPaneSection(pane, 'settings-vault');
break;
case 'field-history':
renderPaneSection(pane, 'field-history');
break;
default:
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';
}
}
// --- Pane renderers ---
// These import the existing popup component renderers and pass the pane element.
// The popup renderers write to whatever element they receive via `app`.
async function renderPaneDetail(pane: HTMLElement, item: Item): Promise<void> {
// Dynamic import to reuse popup detail renderers
const { renderItemDetail } = await import('../popup/components/item-detail');
renderItemDetail(pane);
}
function renderPaneAdd(pane: HTMLElement): void {
import('../popup/components/item-form').then(({ renderItemForm }) => {
renderItemForm(pane, 'add');
});
}
function renderPaneEdit(pane: HTMLElement): void {
import('../popup/components/item-form').then(({ renderItemForm }) => {
renderItemForm(pane, 'edit');
});
}
async function renderPaneSection(pane: HTMLElement, section: string): Promise<void> {
switch (section) {
case 'trash': {
const { renderTrash } = await import('../popup/components/trash');
renderTrash(pane);
break;
}
case 'devices': {
const { renderDevices } = await import('../popup/components/devices');
renderDevices(pane);
break;
}
case 'settings': {
const { renderSettings } = await import('../popup/components/settings');
renderSettings(pane);
break;
}
case 'settings-vault': {
const { renderVaultSettings } = await import('../popup/components/settings-vault');
renderVaultSettings(pane);
break;
}
case 'field-history': {
const { renderFieldHistory } = await import('../popup/components/field-history');
renderFieldHistory(pane);
break;
}
}
}
// --- Init ---
async function loadManifest(): Promise<void> {
const listResp = await sendMessage({ type: 'list_items' });
if (listResp.ok) {
const data = listResp.data as { items: Array<[ItemId, ManifestEntry]> };
state.entries = data.items;
}
const vsResp = await sendMessage({ type: 'get_vault_settings' });
if (vsResp.ok) {
state.vaultSettings = (vsResp.data as { settings: VaultSettings }).settings;
}
state.view = 'list';
render();
// Handle deep link from hash
const { view, param } = parseHash();
if (view === 'item' && param) {
const resp = await sendMessage({ type: 'get_item', id: param });
if (resp.ok) {
state.selectedId = param;
state.selectedItem = (resp.data as { item: Item }).item;
state.view = 'detail';
renderSidebarList();
renderPane();
}
} else if (view === 'add' && param) {
state.newType = param as ItemType;
state.view = 'add';
renderPane();
} else if (view === 'trash' || view === 'devices' || view === 'settings') {
state.view = view as VaultView;
renderPane();
}
}
document.addEventListener('DOMContentLoaded', async () => {
const resp = await sendMessage({ type: 'is_unlocked' });
if (resp.ok && (resp.data as { unlocked: boolean }).unlocked) {
await loadManifest();
} else {
state.view = 'locked';
render();
}
});
// Listen for session expiry
chrome.runtime.onMessage.addListener((msg) => {
if (msg.type === 'session_expired') {
state.view = 'locked';
state.error = null;
state.selectedItem = null;
state.selectedId = null;
state.entries = [];
render();
}
});
// Hash change navigation
window.addEventListener('hashchange', () => {
const { view, param } = parseHash();
if (view === 'trash' || view === 'devices' || view === 'settings') {
state.view = view as VaultView;
state.selectedId = null;
state.selectedItem = null;
renderPane();
}
});
- Step 4: Add vault entry point to webpack.config.js
In webpack.config.js, add to the entry object:
vault: './src/vault/vault.ts',
In the CopyPlugin patterns array, add:
{ from: 'src/vault/vault.html', to: 'vault.html' },
{ from: 'src/vault/vault.css', to: 'vault.css' },
- Step 5: Update manifest.json with commands and vault.html access
In manifest.json, add a commands section:
"commands": {
"open-vault": {
"description": "Open relicario vault"
}
}
Note: no suggested_key — the user configures it in chrome://extensions/shortcuts.
Add the commands listener in src/service-worker/index.ts:
chrome.commands.onCommand.addListener((command) => {
if (command === 'open-vault') {
chrome.tabs.create({ url: chrome.runtime.getURL('vault.html') });
}
});
- Step 6: Build and verify
Run: cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -10
Expected: Build succeeds with vault.js in output, vault.html and vault.css copied to dist/.
Verify files exist:
ls dist/vault.html dist/vault.css dist/vault.js
- Step 7: Commit
git add src/vault/ webpack.config.js manifest.json src/service-worker/index.ts
git commit -m "feat(ext/vault): scaffold vault.html tab with sidebar+pane layout and hash routing"
Task 4: Resolve Shared State — Vault Tab Uses Popup's State Functions
Files:
- Modify:
src/popup/popup.ts - Modify:
src/vault/vault.ts - Modify:
src/popup/components/item-detail.ts(if it calls navigate/setState) - Modify: any popup component that calls
navigate,setState,getState,sendMessage
The popup components (item-detail, item-form, trash, devices, settings, etc.) all import from ../popup (i.e., src/popup/popup.ts) for navigate, setState, getState, sendMessage, escapeHtml. When the vault tab dynamically imports these components, those imports resolve to the popup's module — but in the vault's webpack bundle, popup.ts won't be initialized (no DOMContentLoaded, wrong state).
The fix: extract the shared functions into a new src/shared/state.ts that both popup and vault initialize with their own state/render callbacks.
- Step 1: Create src/shared/state.ts
import type { Request, Response } from './messages';
export interface StateHost {
getState(): any;
setState(partial: any): void;
navigate(view: string, extras?: any): void;
sendMessage(request: Request): Promise<Response>;
escapeHtml(s: string): string;
popOutToTab(): void;
isInTab(): boolean;
openVaultTab(hash?: string): void;
}
let host: StateHost | null = null;
export function registerHost(h: StateHost): void { host = h; }
export function getState(): any {
if (!host) throw new Error('No state host registered');
return host.getState();
}
export function setState(partial: any): void {
if (!host) throw new Error('No state host registered');
host.setState(partial);
}
export function navigate(view: string, extras?: any): void {
if (!host) throw new Error('No state host registered');
host.navigate(view, extras);
}
export function sendMessage(request: Request): Promise<Response> {
if (!host) throw new Error('No state host registered');
return host.sendMessage(request);
}
export function escapeHtml(s: string): string {
if (!host) throw new Error('No state host registered');
return host.escapeHtml(s);
}
export function popOutToTab(): void {
if (!host) throw new Error('No state host registered');
host.popOutToTab();
}
export function isInTab(): boolean {
if (!host) return false;
return host.isInTab();
}
export function openVaultTab(hash?: string): void {
if (!host) throw new Error('No state host registered');
host.openVaultTab(hash);
}
- Step 2: Update popup.ts to register as host
In src/popup/popup.ts, at the end of the existing exports section (before the DOMContentLoaded listener), call registerHost:
import { registerHost } from '../shared/state';
registerHost({
getState: () => currentState,
setState,
navigate,
sendMessage,
escapeHtml,
popOutToTab,
isInTab,
openVaultTab,
});
Keep the existing exports — the popup's own code can still import directly from ./popup. The state host is for cross-bundle component access.
- Step 3: Update vault.ts to register as host
In src/vault/vault.ts, register its own state host before calling render():
import { registerHost } from '../shared/state';
registerHost({
getState: () => state,
setState: (partial: Partial<VaultState>) => {
Object.assign(state, partial);
render();
},
navigate: (view: string, extras?: Partial<VaultState>) => {
Object.assign(state, { view, error: null, loading: false, ...extras });
if (view === 'list') {
state.selectedId = null;
state.selectedItem = null;
}
setHash(view);
render();
},
sendMessage,
escapeHtml,
popOutToTab: () => {}, // no-op, already in tab
isInTab: () => true,
openVaultTab: () => {}, // no-op, already in vault
});
- Step 4: Update all popup components to import from shared/state
In every file under src/popup/components/ that imports from ../popup or ../../popup, change the import to use ../../shared/state (or ../shared/state depending on depth).
Files to update (all imports of navigate, setState, getState, sendMessage, escapeHtml, popOutToTab, isInTab, openVaultTab):
src/popup/components/item-list.tssrc/popup/components/item-detail.tssrc/popup/components/item-form.tssrc/popup/components/unlock.tssrc/popup/components/settings.tssrc/popup/components/settings-vault.tssrc/popup/components/trash.tssrc/popup/components/devices.tssrc/popup/components/field-history.tssrc/popup/components/fields.tssrc/popup/components/generator-panel.tssrc/popup/components/attachments-disclosure.tssrc/popup/components/types/login.tssrc/popup/components/types/secure-note.tssrc/popup/components/types/identity.tssrc/popup/components/types/card.tssrc/popup/components/types/key.tssrc/popup/components/types/totp.tssrc/popup/components/types/document.ts
For each file, replace:
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../popup';
with:
import { getState, setState, sendMessage, navigate, escapeHtml, popOutToTab, isInTab } from '../../shared/state';
Adjust relative path depth accordingly (components in types/ need ../../../shared/state; components directly in components/ need ../../shared/state).
Note: popup.ts itself should NOT change its own internal function definitions — it still defines setState, navigate, etc. locally. It just also calls registerHost to expose them to the shared state module.
- Step 4b: Update test mocks to match new import paths
Test files under src/popup/components/__tests__/ and src/popup/components/types/__tests__/ mock ../../popup or ../../../popup. Update all vi.mock('../../popup', ...) calls to vi.mock('../../shared/state', ...) (and similarly for ../../../popup → ../../../shared/state). The mock shape stays the same — just the path changes.
Test files to update:
-
src/popup/components/__tests__/attachments-disclosure.test.ts -
src/popup/components/__tests__/devices.test.ts -
src/popup/components/__tests__/field-history.test.ts -
src/popup/components/__tests__/fields.test.ts -
src/popup/components/__tests__/generator-panel.test.ts -
src/popup/components/__tests__/sections-editor.test.ts -
src/popup/components/__tests__/sections-render.test.ts -
src/popup/components/__tests__/settings-vault.test.ts -
src/popup/components/__tests__/trash.test.ts -
src/popup/components/types/__tests__/card.save.test.ts -
src/popup/components/types/__tests__/document.save.test.ts -
src/popup/components/types/__tests__/identity.save.test.ts -
src/popup/components/types/__tests__/key.save.test.ts -
src/popup/components/types/__tests__/sections-save.test.ts -
src/popup/components/types/__tests__/secure-note.save.test.ts -
src/popup/components/types/__tests__/totp.save.test.ts -
Step 5: Update vault.ts pane renderers to drop dynamic import wrappers
Since the components now import from shared/state (which vault.ts has registered as host), the vault can import them directly. Simplify the pane render functions in vault.ts:
import { renderItemDetail } from '../popup/components/item-detail';
import { renderItemForm } from '../popup/components/item-form';
import { renderTrash } from '../popup/components/trash';
import { renderDevices } from '../popup/components/devices';
import { renderSettings } from '../popup/components/settings';
import { renderVaultSettings as renderVaultSettingsView } from '../popup/components/settings-vault';
import { renderFieldHistory } from '../popup/components/field-history';
Replace the async pane renderers with direct calls:
function renderPane(): void {
const pane = document.getElementById('vault-pane');
if (!pane) return;
pane.className = 'vault-pane';
switch (state.view) {
case 'detail':
if (state.selectedItem) {
renderItemDetail(pane);
} else {
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';
}
break;
case 'add':
renderItemForm(pane, 'add');
break;
case 'edit':
renderItemForm(pane, 'edit');
break;
case 'trash':
renderTrash(pane);
break;
case 'devices':
renderDevices(pane);
break;
case 'settings':
renderSettings(pane);
break;
case 'settings-vault':
renderVaultSettingsView(pane);
break;
case 'field-history':
renderFieldHistory(pane);
break;
default:
pane.className = 'vault-pane vault-pane--empty';
pane.innerHTML = 'select an item';
}
}
Remove the old renderPaneDetail, renderPaneAdd, renderPaneEdit, renderPaneSection functions.
- Step 6: Add "+ new" button to sidebar header
In the sidebar header of vault.ts, add a new button:
<button class="btn" id="vault-new-btn" style="font-size:11px;">+ new</button>
Wire it:
document.getElementById('vault-new-btn')?.addEventListener('click', () => {
state.newType = null;
state.view = 'add';
setHash('add');
renderPane();
});
- Step 7: Build and test
Run: cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -10
Expected: Build succeeds. Both popup.js and vault.js compile.
Run: bun test 2>&1 | tail -20
Expected: Existing tests still pass (components import from shared/state, which delegates to registered host — tests mock the functions they need).
- Step 8: Commit
git add src/shared/state.ts src/vault/vault.ts src/popup/popup.ts src/popup/components/
git commit -m "feat(ext): shared state host so vault tab reuses popup components"
Task 5: Device Settings — Session Timeout Config UI
Files:
-
Modify:
src/popup/components/settings.ts -
Modify:
src/vault/vault.ts(settings nav already wired) -
Step 1: Read current settings.ts to understand structure
Read src/popup/components/settings.ts to see how the existing settings view is structured and where to add device settings.
- Step 2: Add session timeout UI to settings view
In src/popup/components/settings.ts, in the renderSettings function, add a "device settings" section at the top (before vault settings link):
<div class="settings-section">
<h4 class="settings-heading">device</h4>
<div class="form-group">
<label class="label">auto-lock</label>
<div class="inline-row" style="gap:8px;">
<select id="timeout-mode" style="flex:1;">
<option value="inactivity"${config.mode === 'inactivity' ? ' selected' : ''}>after inactivity</option>
<option value="every_time"${config.mode === 'every_time' ? ' selected' : ''}>every time</option>
</select>
<select id="timeout-minutes" style="width:80px;"${config.mode === 'every_time' ? ' disabled' : ''}>
${[5, 15, 30, 60].map(m => `<option value="${m}"${config.mode === 'inactivity' && config.minutes === m ? ' selected' : ''}>${m} min</option>`).join('')}
</select>
</div>
</div>
</div>
Fetch the current config on render:
const configResp = await sendMessage({ type: 'get_session_config' });
const config = configResp.ok
? (configResp.data as { config: SessionTimeoutConfig }).config
: { mode: 'inactivity' as const, minutes: 15 };
Wire the change handlers:
document.getElementById('timeout-mode')?.addEventListener('change', async (e) => {
const mode = (e.target as HTMLSelectElement).value;
const minutesSelect = document.getElementById('timeout-minutes') as HTMLSelectElement;
if (mode === 'every_time') {
minutesSelect.disabled = true;
await sendMessage({ type: 'update_session_config', config: { mode: 'every_time' } });
} else {
minutesSelect.disabled = false;
const minutes = parseInt(minutesSelect.value, 10);
await sendMessage({ type: 'update_session_config', config: { mode: 'inactivity', minutes } });
}
});
document.getElementById('timeout-minutes')?.addEventListener('change', async (e) => {
const minutes = parseInt((e.target as HTMLSelectElement).value, 10);
await sendMessage({ type: 'update_session_config', config: { mode: 'inactivity', minutes } });
});
Import SessionTimeoutConfig from ../../shared/messages.
Note: renderSettings needs to become async for the sendMessage call. Update the function signature and the caller in popup.ts render switch.
- Step 3: Build and test
Run: cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5
Expected: Build succeeds.
- Step 4: Commit
git add src/popup/components/settings.ts src/popup/popup.ts
git commit -m "feat(ext/settings): add session timeout config to device settings"
Task 6: Router — Allow vault.html as Trusted Sender
Files:
-
Modify:
src/service-worker/router/index.ts -
Step 1: Update router to recognize vault.html
In src/service-worker/router/index.ts, add vault URL detection:
const vaultUrl = chrome.runtime.getURL('vault.html');
Update isPopup to include vault:
const isPopup = senderUrl.startsWith(popupUrl) || senderUrl.startsWith(vaultUrl);
- Step 2: Build
Run: cd /home/alee/Sources/relicario/extension && bun run build 2>&1 | tail -5
Expected: Build succeeds.
- Step 3: Commit
git add src/service-worker/router/index.ts
git commit -m "fix(ext/router): allow vault.html as trusted sender for popup-only messages"
Task 7: Manual Browser Testing
- Step 1: Build the extension
cd /home/alee/Sources/relicario/extension && bun run build
- Step 2: Reload extension in Chrome
Go to chrome://extensions, click reload on relicario.
-
Step 3: Test popup basics
-
Open popup, verify search doesn't auto-focus
-
Type
/to focus search, verify text goes forward -
Use arrow keys and Enter to open an item
-
Verify Shift+F opens vault tab
-
Verify "⤴" button in toolbar opens vault tab
-
Step 4: Test vault tab
-
Verify vault tab shows lock screen if locked
-
Unlock with passphrase
-
Verify sidebar shows items grouped by type
-
Click an item — detail appears in right pane
-
Click "+ new" — type selection appears in pane
-
Select a type — form appears in pane
-
Click trash/devices/settings in sidebar nav
-
Use
/to focus sidebar search -
Verify URL hash updates as you navigate
-
Step 5: Test session timeout
-
Go to settings in either popup or vault tab
-
Set timeout to 5 minutes
-
Wait 5 minutes (or temporarily set to a short value for testing)
-
Verify both popup and vault tab show lock screen
-
Step 6: Test popup → vault navigation
-
Open popup, view an item, click popout button → verify vault tab opens with that item selected
-
Open popup, click "+ new", select card → verify vault tab opens with card form
-
Open popup lock screen, click "open vault" → verify vault tab opens
-
Step 7: Fix any issues found, rebuild, retest, commit