chrome.runtime.sendMessage serializes with a JSON-like algorithm that drops ArrayBuffer/TypedArray payloads — they arrive as empty objects, so a carrier JPEG reached the service worker as 0 bytes and failed with the opaque WASM error "no SOF marker found in JPEG". This broke extension vault creation. - shared/message-binary.ts: encodeBinary/decodeBinary deep-walk a message tree and base64-envelope every binary buffer so it survives the channel (chunked base64 to avoid stack overflow on multi-MB buffers); plain number[] untouched. - Wire encode/decode at all four message boundaries: popup, service-worker onMessage listener (decode request, encode response), setup swSend, vault postToServiceWorker. - handleCreateVault: magic-byte guard classifies empty / non-JPEG carriers into actionable errors (carrier_image_empty / carrier_image_not_jpeg) instead of the opaque WASM error; error-copy.ts adds user-facing text; setup errors now route through lookupErrorCopy. - router SETUP_ALLOWED: add create_vault / attach_vault / generate_recovery_qr — the Phase-3 setup wizard sends these from the setup page and they were being rejected as unauthorized_sender. - Tests: message-binary round-trip suite (7) + carrier-guard (2) + setup allowlist (3). 435/435 vitest, build:all clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Pe8qw5KePDqAEBsAxnVQuJ
112 lines
4.2 KiB
TypeScript
112 lines
4.2 KiB
TypeScript
/// Thin service-worker entry: loads WASM, constructs the router state, and
|
|
/// forwards every message into router/index.route().
|
|
|
|
import type { Request, Response, SessionTimeoutConfig } from '../shared/messages';
|
|
import type { RouterState } from './router/index';
|
|
import { route } from './router/index';
|
|
import { encodeBinary, decodeBinary } from '../shared/message-binary';
|
|
import * as vault from './vault';
|
|
import { clearCurrent } from './session';
|
|
import * as sessionTimer from './session-timer';
|
|
import { READ_ONLY_CONTENT_CALLABLE } from './session-timer';
|
|
|
|
// @ts-ignore TS2307 — resolved by webpack alias / copy
|
|
import initDefault, { initSync } from '../../wasm/relicario_wasm.js';
|
|
// @ts-ignore TS2307
|
|
import * as wasmBindings from '../../wasm/relicario_wasm.js';
|
|
|
|
type WasmModule = typeof wasmBindings;
|
|
let wasm: WasmModule | null = null;
|
|
|
|
async function initWasm(): Promise<WasmModule> {
|
|
if (wasm) return wasm;
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const SWGlobalScope = (globalThis as any).ServiceWorkerGlobalScope as (new () => ServiceWorker) | undefined;
|
|
const isServiceWorker = typeof SWGlobalScope !== 'undefined'
|
|
&& self instanceof (SWGlobalScope as unknown as typeof EventTarget);
|
|
|
|
if (isServiceWorker) {
|
|
const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
|
|
const wasmBytes = await wasmResponse.arrayBuffer();
|
|
initSync({ module: new WebAssembly.Module(wasmBytes) });
|
|
} else {
|
|
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
|
|
await initDefault(wasmUrl);
|
|
}
|
|
|
|
vault.setWasm(wasmBindings);
|
|
wasm = wasmBindings;
|
|
return wasm;
|
|
}
|
|
|
|
// Single router-state object shared by all messages for this SW instance.
|
|
const state: RouterState = {
|
|
manifest: null,
|
|
gitHost: null,
|
|
wasm: null,
|
|
};
|
|
|
|
// --- Session timer wiring ---
|
|
|
|
sessionTimer.onExpired(() => {
|
|
// eslint-disable-next-line no-console
|
|
console.log('[relicario sw] session expired — locking vault');
|
|
clearCurrent();
|
|
state.manifest = null;
|
|
// Plan C Phase 5: don't leak the cached git-host client across a lock.
|
|
// The initializer rebuilds gitHost on demand, so clearing here is safe.
|
|
state.gitHost = null;
|
|
// Best-effort broadcast — receiver may not exist yet.
|
|
chrome.runtime.sendMessage({ type: 'session_expired' }).catch(() => {});
|
|
});
|
|
|
|
// Restore saved session config from chrome.storage.local on SW startup.
|
|
chrome.storage.local.get('session_timeout').then((r) => {
|
|
if (r.session_timeout) {
|
|
sessionTimer.setConfig(r.session_timeout as SessionTimeoutConfig);
|
|
}
|
|
}).catch(() => {});
|
|
|
|
chrome.commands.onCommand.addListener((command) => {
|
|
if (command === 'open-vault') {
|
|
chrome.tabs.create({ url: chrome.runtime.getURL('vault.html') });
|
|
}
|
|
});
|
|
|
|
chrome.runtime.onMessage.addListener(
|
|
(rawRequest: Request, sender: chrome.runtime.MessageSender, sendResponse: (r: Response) => void) => {
|
|
// Binary payloads (carrier/reference images, attachments, backups) cross
|
|
// the channel as base64 envelopes — decode before routing. See message-binary.ts.
|
|
const request = decodeBinary(rawRequest);
|
|
(async () => {
|
|
// Plan C Phase 5: invert the reset rule. Reset on every message
|
|
// except a documented passive-read exclusion set, so an active
|
|
// autofiller / content-driven flow keeps the vault alive.
|
|
if (!READ_ONLY_CONTENT_CALLABLE.has(request.type)) {
|
|
sessionTimer.resetTimer();
|
|
}
|
|
if (!state.wasm) {
|
|
// eslint-disable-next-line no-console
|
|
console.log('[relicario sw] initializing WASM on first message');
|
|
state.wasm = await initWasm();
|
|
}
|
|
return route(request, state, sender);
|
|
})()
|
|
.then((r) => {
|
|
if (!r.ok) {
|
|
// eslint-disable-next-line no-console
|
|
console.warn(`[relicario sw] ${request.type} -> error:`, r.error);
|
|
}
|
|
// Re-encode binary in the response so it survives the channel back.
|
|
sendResponse(encodeBinary(r));
|
|
})
|
|
.catch((err: Error) => {
|
|
// eslint-disable-next-line no-console
|
|
console.error(`[relicario sw] ${request.type} threw:`, err);
|
|
sendResponse({ ok: false, error: err.message });
|
|
});
|
|
return true; // async response
|
|
},
|
|
);
|