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>
101 lines
3.3 KiB
TypeScript
101 lines
3.3 KiB
TypeScript
// extension/src/shared/state.ts
|
|
//
|
|
// Single channel for popup and vault-tab UI to read/write app state and
|
|
// dispatch messages to the service worker. Two registered hosts (popup,
|
|
// vault tab) implement StateHost; each surface calls registerHost(this) at
|
|
// boot.
|
|
//
|
|
// The vault_locked intercept (lines 47-74 in vault.ts pre-Phase-4) lifts
|
|
// into sendMessage() here in Phase 4. Phase 1 lays the wrapper signature;
|
|
// the body is a thin pass-through until Phase 4.
|
|
|
|
import type { Request, Response } from './messages';
|
|
import type { PopupState, View } from './popup-state';
|
|
|
|
export interface StateHost {
|
|
getState(): PopupState;
|
|
setState(partial: Partial<PopupState>): void;
|
|
navigate(view: View, extras?: Partial<PopupState>): 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 {
|
|
if (host) throw new Error('state host already registered');
|
|
host = h;
|
|
}
|
|
|
|
/** Test-only — vitest beforeEach() calls this to break inter-test leakage. */
|
|
export function __resetHostForTests(): void {
|
|
host = null;
|
|
}
|
|
|
|
export function getState(): PopupState {
|
|
if (!host) throw new Error('No state host registered');
|
|
return host.getState();
|
|
}
|
|
|
|
export function setState(partial: Partial<PopupState>): void {
|
|
if (!host) throw new Error('No state host registered');
|
|
host.setState(partial);
|
|
}
|
|
|
|
export function navigate(view: View, extras?: Partial<PopupState>): void {
|
|
if (!host) throw new Error('No state host registered');
|
|
host.navigate(view, extras);
|
|
}
|
|
|
|
// Requests that must NOT trigger the lock screen on a vault_locked response:
|
|
// they run during cold start / unlock, before a session exists, so a
|
|
// vault_locked here is expected rather than a lost session.
|
|
const VAULT_LOCKED_BYPASS: ReadonlySet<Request['type']> = new Set([
|
|
'unlock', 'is_unlocked',
|
|
]);
|
|
|
|
/**
|
|
* Dispatches a request to the service worker and intercepts the `vault_locked`
|
|
* response. MV3 evicts the service worker after ~30s idle, wiping the in-memory
|
|
* session/manifest; the next RPC comes back `vault_locked`. Any surface (popup
|
|
* or vault tab) that gets that on a non-bypassed request treats it as "session
|
|
* lost" and navigates to the lock screen so the user can re-enter their
|
|
* passphrase. Lifted here from vault.ts's local sendMessage in Plan C Phase 4
|
|
* so both surfaces share one channel.
|
|
*/
|
|
export async function sendMessage(request: Request): Promise<Response> {
|
|
if (!host) throw new Error('No state host registered');
|
|
const response = await host.sendMessage(request);
|
|
if (
|
|
!response.ok &&
|
|
response.error === 'vault_locked' &&
|
|
!VAULT_LOCKED_BYPASS.has(request.type)
|
|
) {
|
|
host.navigate('locked', { error: 'Session expired — please unlock again.' });
|
|
}
|
|
return response;
|
|
}
|
|
|
|
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) throw new Error('No state host registered');
|
|
return host.isInTab();
|
|
}
|
|
|
|
export function openVaultTab(hash?: string): void {
|
|
if (!host) throw new Error('No state host registered');
|
|
host.openVaultTab(hash);
|
|
}
|