refactor(ext/state): lift vault_locked intercept into shared/state.ts (Plan C Phase 4)

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>
This commit is contained in:
adlee-was-taken
2026-05-31 20:20:52 -04:00
parent 31913b8648
commit 0c722b3a9d
3 changed files with 107 additions and 30 deletions

View File

@@ -5,7 +5,7 @@
/// vault tab's pane area.
import type { Request, Response } from '../shared/messages';
import { registerHost } from '../shared/state';
import { registerHost, sendMessage } from '../shared/state';
import { type ErrorCta } from '../shared/error-copy';
import {
type VaultController, type VaultState, type VaultView,
@@ -27,32 +27,12 @@ import { parseHash, setHash, renderPane, loadManifest, selectItem } from './vaul
// Helpers
// ---------------------------------------------------------------------------
function sendMessage(request: Request): Promise<Response> {
// 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) => {
// MV3 service workers are evicted after ~30s idle, which wipes the
// in-memory session/manifest/gitHost. The fullscreen tab stays open
// and has no signal that the SW restarted — the next RPC just comes
// back `vault_locked`. Treat that as "session lost" and force the
// lock screen so the user can re-enter their passphrase. Skip for
// is_unlocked / unlock themselves to avoid loops on cold start.
if (
response &&
!response.ok &&
response.error === 'vault_locked' &&
request.type !== 'is_unlocked' &&
request.type !== 'unlock' &&
state.unlocked
) {
state.unlocked = false;
state.selectedId = null;
state.selectedItem = null;
state.entries = [];
state.error = 'Session expired — please unlock again.';
render(ctx);
}
resolve(response);
});
chrome.runtime.sendMessage(request, (response: Response) => resolve(response));
});
}
@@ -115,6 +95,17 @@ registerHost({
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);
@@ -122,7 +113,7 @@ registerHost({
if (state.view === 'list') renderListPane(ctx);
renderPane(ctx);
},
sendMessage,
sendMessage: postToServiceWorker,
escapeHtml,
popOutToTab: () => {},
isInTab: () => true,