The session-lost intercept lived in vault.ts's local sendMessage; both surfaces
now consume it through the shared sendMessage() wrapper. On a vault_locked
response to any non-bypassed request, the wrapper calls host.navigate('locked').
The vault host's navigate gains a 'locked' branch (it shows its lock screen off
state.unlocked); the popup's navigate already handles 'locked'. vault.ts routes
ctx.sendMessage through the shared wrapper and registers a plain transport as
host.sendMessage, so internal RPCs keep the intercept without recursion.
grep -c vault_locked vault.ts == 0. New state-vault-locked.test.ts (TDD, 6 cases).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
195 lines
6.1 KiB
TypeScript
195 lines
6.1 KiB
TypeScript
/// Vault tab entry point — full "desktop-like" sidebar + pane layout.
|
|
///
|
|
/// Registers as the shared state host so popup components (item-detail,
|
|
/// item-form, trash, devices, settings, etc.) render natively in the
|
|
/// vault tab's pane area.
|
|
|
|
import type { Request, Response } from '../shared/messages';
|
|
import { registerHost, sendMessage } from '../shared/state';
|
|
import { type ErrorCta } from '../shared/error-copy';
|
|
import {
|
|
type VaultController, type VaultState, type VaultView,
|
|
escapeHtml,
|
|
} from './vault-context';
|
|
import {
|
|
render, applyShellViewClass,
|
|
openTypePanel, closeTypePanel, applyVaultColorScheme,
|
|
wireSessionExpiredListener,
|
|
} from './vault-shell';
|
|
import { wireSidebar, renderSidebarCategories } from './vault-sidebar';
|
|
import { renderListPane } from './vault-list';
|
|
import {
|
|
openDrawer, closeDrawer, renderDrawer, selectItemForDrawer,
|
|
} from './vault-drawer';
|
|
import { parseHash, setHash, renderPane, loadManifest, selectItem } from './vault-router';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Plain transport to the service worker, registered as the host's sendMessage.
|
|
// The shared sendMessage() wrapper (shared/state.ts) layers the session-lost
|
|
// → lock-screen intercept on top of this for every UI RPC.
|
|
function postToServiceWorker(request: Request): Promise<Response> {
|
|
return new Promise((resolve) => {
|
|
chrome.runtime.sendMessage(request, (response: Response) => resolve(response));
|
|
});
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// State
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const state: VaultState = {
|
|
unlocked: false,
|
|
view: 'list',
|
|
entries: [],
|
|
selectedId: null,
|
|
selectedItem: null,
|
|
selectedIndex: 0,
|
|
searchQuery: '',
|
|
activeGroup: null,
|
|
drawerOpen: false,
|
|
typePanelOpen: false,
|
|
vaultSettings: null,
|
|
generatorDefaults: null,
|
|
error: null,
|
|
loading: false,
|
|
newType: null,
|
|
capturedTabId: null,
|
|
capturedUrl: '',
|
|
historyItemId: null,
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Controller — carries state + cross-module re-render hooks
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const ctx: VaultController = {
|
|
state,
|
|
sendMessage,
|
|
render: () => render(ctx),
|
|
renderPane: () => renderPane(ctx),
|
|
renderListPane: () => renderListPane(ctx),
|
|
renderSidebarCategories: () => renderSidebarCategories(ctx),
|
|
renderDrawer: (item) => renderDrawer(ctx, item),
|
|
applyShellViewClass: () => applyShellViewClass(ctx),
|
|
setHash,
|
|
openDrawer: () => openDrawer(),
|
|
closeDrawer: () => closeDrawer(ctx),
|
|
selectItemForDrawer: (id) => selectItemForDrawer(ctx, id),
|
|
openTypePanel: () => openTypePanel(ctx),
|
|
closeTypePanel: () => closeTypePanel(ctx),
|
|
wireSidebar: () => wireSidebar(ctx),
|
|
loadManifest: () => loadManifest(ctx),
|
|
};
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Register as shared state host
|
|
// ---------------------------------------------------------------------------
|
|
|
|
registerHost({
|
|
getState: () => state,
|
|
setState: (partial) => {
|
|
Object.assign(state, partial);
|
|
renderPane(ctx);
|
|
},
|
|
navigate: (view, extras) => {
|
|
if (view === 'locked') {
|
|
// Session lost (SW evicted mid-session). The vault shows its lock
|
|
// screen off state.unlocked, so flip it and drop the in-memory data.
|
|
state.unlocked = false;
|
|
state.selectedId = null;
|
|
state.selectedItem = null;
|
|
state.entries = [];
|
|
state.error = (extras?.error as string | undefined) ?? null;
|
|
render(ctx);
|
|
return;
|
|
}
|
|
Object.assign(state, { view, error: null, loading: false, ...extras });
|
|
setHash(view as VaultView);
|
|
applyShellViewClass(ctx);
|
|
renderSidebarCategories(ctx);
|
|
if (state.view === 'list') renderListPane(ctx);
|
|
renderPane(ctx);
|
|
},
|
|
sendMessage: postToServiceWorker,
|
|
escapeHtml,
|
|
popOutToTab: () => {},
|
|
isInTab: () => true,
|
|
openVaultTab: () => {},
|
|
});
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Init
|
|
// ---------------------------------------------------------------------------
|
|
|
|
document.addEventListener('DOMContentLoaded', async () => {
|
|
await applyVaultColorScheme();
|
|
|
|
// Delegated handler for .error-cta buttons — set up once on the stable root.
|
|
const app = document.getElementById('vault-app')!;
|
|
app.addEventListener('click', (e) => {
|
|
const btn = (e.target as HTMLElement).closest<HTMLButtonElement>('.error-cta');
|
|
if (!btn) return;
|
|
const cta = btn.dataset.cta as ErrorCta['action'];
|
|
switch (cta) {
|
|
case 'unlock': {
|
|
document.getElementById('vault-passphrase')?.focus();
|
|
break;
|
|
}
|
|
case 'open_setup': {
|
|
void chrome.tabs.create({ url: chrome.runtime.getURL('setup.html') });
|
|
break;
|
|
}
|
|
case 'reload_extension': {
|
|
chrome.runtime.reload();
|
|
break;
|
|
}
|
|
}
|
|
});
|
|
|
|
// Check if already unlocked
|
|
const resp = await sendMessage({ type: 'is_unlocked' });
|
|
if (resp.ok) {
|
|
const data = resp.data as { unlocked: boolean };
|
|
if (data.unlocked) {
|
|
state.unlocked = true;
|
|
await loadManifest(ctx);
|
|
}
|
|
}
|
|
|
|
render(ctx);
|
|
|
|
wireSessionExpiredListener(ctx);
|
|
|
|
// Hash change listener
|
|
window.addEventListener('hashchange', () => {
|
|
if (!state.unlocked) return;
|
|
|
|
const route = parseHash();
|
|
state.view = route.view;
|
|
applyShellViewClass(ctx);
|
|
|
|
// If navigating to a detail/edit view for an item we already have loaded
|
|
if ((route.view === 'detail' || route.view === 'edit') && route.id) {
|
|
if (state.selectedId === route.id && state.selectedItem) {
|
|
renderPane(ctx);
|
|
renderSidebarCategories(ctx);
|
|
if (state.view === 'list') renderListPane(ctx);
|
|
return;
|
|
}
|
|
// Need to fetch the item
|
|
selectItem(ctx, route.id);
|
|
return;
|
|
}
|
|
|
|
// For non-item views, just re-render the pane
|
|
state.selectedId = null;
|
|
state.selectedItem = null;
|
|
renderSidebarCategories(ctx);
|
|
if (state.view === 'list') renderListPane(ctx);
|
|
renderPane(ctx);
|
|
});
|
|
});
|