Merge phase-c-3-setup-wizard: Plan C Phase 3 (setup wizard SW migration + step registry)

Moves all setup-wizard crypto orchestration into the service worker via new
create_vault / attach_vault SW handlers (full Option-A flow: embed/unlock,
encrypt+push, register_device+addDevice, persist config+image, session.setCurrent;
failure path locks+frees the handle, ownership transfers only on success).
setup.ts collapses from ~1230 LOC to a 58-LOC UI-only shell + setup-steps.ts
step registry (one-directional import, no cycle, no relicario-wasm import).
clearWizardState bound to beforeunload + goto(mode). Copy-vault-JSON escape
hatch preserved; redundant register-device button dropped.

Tasks 3.1-3.7. 397/397 vitest green; build:all clean. Unblocks nothing
directly (Phase 6 SW handler is Dev-C) but completes the setup-wizard cliff.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
adlee-was-taken
2026-05-31 20:16:38 -04:00
6 changed files with 1322 additions and 1201 deletions

View File

@@ -0,0 +1,304 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import * as vault from '../vault';
import * as session from '../session';
import type { PopupState } from '../router/popup-only';
import type { GitHost } from '../git-host';
import * as gitHostMod from '../git-host';
// --- Mock git-host module ---
// createGitHost is called internally by handleCreateVault / handleAttachVault;
// we need to intercept it and return a fake GitHost. uint8ArrayToBase64 must
// still work — vault.ts calls it for the imageBase64 storage value.
// Shared factory used both inside vi.mock and in beforeEach re-wire.
function makeHostMock(): GitHost & { _calls: Record<string, unknown[][]> } {
const calls: Record<string, unknown[][]> = {
writeFileCreateOnly: [],
writeFile: [],
readFile: [],
};
return {
_calls: calls,
readFile: vi.fn().mockImplementation(async (path: string) => {
// Serve the vault-meta files needed by fetchVaultMeta + attach flow.
if (path === '.relicario/salt') return new Uint8Array(32);
if (path === '.relicario/params.json') {
return new TextEncoder().encode('{"argon2_m":65536,"argon2_t":3,"argon2_p":4}');
}
if (path === 'manifest.enc') return new Uint8Array([0xab, 0xcd]);
// .relicario/devices.json throws so readDevices falls back to [].
throw new Error(`404: ${path}`);
}),
writeFile: vi.fn().mockImplementation(async (...args: unknown[]) => {
calls.writeFile.push(args);
}),
writeFileCreateOnly: vi.fn().mockImplementation(async (...args: unknown[]) => {
calls.writeFileCreateOnly.push(args);
}),
deleteFile: vi.fn(),
listDir: vi.fn().mockResolvedValue([]),
lastCommit: vi.fn().mockResolvedValue(null),
putBlob: vi.fn(),
getBlob: vi.fn(),
deleteBlob: vi.fn(),
};
}
vi.mock('../git-host', async () => {
const actual = await vi.importActual<typeof import('../git-host')>('../git-host');
// Expose a handle so tests can grab the last-created fake host.
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = null;
return {
...actual,
createGitHost: vi.fn().mockImplementation(() => {
const h = makeHostMock();
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = h;
return h;
}),
};
});
// --- Chrome storage mock ---
function mockChromeStorage(initial: Record<string, unknown> = {}) {
const store: Record<string, unknown> = { ...initial };
(global as { chrome: unknown }).chrome = {
storage: {
local: {
get: vi.fn((keys: string | string[]) => {
const arr = Array.isArray(keys) ? keys : [keys];
const out: Record<string, unknown> = {};
for (const k of arr) if (k in store) out[k] = store[k];
return Promise.resolve(out);
}),
set: vi.fn((kv: Record<string, unknown>) => {
Object.assign(store, kv);
return Promise.resolve();
}),
},
},
} as never;
return store;
}
// --- Helpers ---
function makeFakeHandle() {
return { free: vi.fn() };
}
function makeWasm(overrides: Record<string, unknown> = {}) {
const fakeHandle = makeFakeHandle();
return {
_handle: fakeHandle,
embed_image_secret: vi.fn(() => new Uint8Array([1, 2, 3])),
unlock: vi.fn(() => fakeHandle),
manifest_encrypt: vi.fn(() => new Uint8Array([9])),
manifest_decrypt: vi.fn(() => ({ schema_version: 2, items: {} })),
default_vault_settings_json: vi.fn(() => '{}'),
settings_encrypt: vi.fn(() => new Uint8Array([8])),
register_device: vi.fn(() => ({ signing_public_key: 'pk', deploy_public_key: 'dk' })),
lock: vi.fn(),
...overrides,
};
}
function makeState(wasm: ReturnType<typeof makeWasm>): PopupState {
return {
manifest: null,
gitHost: null,
wasm,
};
}
const BASE_MSG = {
config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' },
passphrase: 'pw',
carrierImageBytes: new Uint8Array([0, 0, 0]).buffer,
deviceName: 'Dev',
};
// --- Tests ---
describe('handleCreateVault', () => {
let setCurrent: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mockChromeStorage();
setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('Test 1 (happy path): returns ok:true with expected data and correct side effects', async () => {
const wasm = makeWasm();
const state = makeState(wasm);
const resp = await vault.handleCreateVault(BASE_MSG, state);
expect(resp.ok).toBe(true);
if (!resp.ok) throw new Error('expected ok:true');
// Response shape
expect(resp.data.referenceImageBytes).toBeInstanceOf(Uint8Array);
expect(resp.data.deviceName).toBe('Dev');
expect(resp.data.recoveryQrAvailable).toBe(true);
// Fake GitHost captures the four writeFileCreateOnly calls
const fakeHost = (globalThis as { __lastFakeGitHost?: { writeFileCreateOnly: ReturnType<typeof vi.fn> } }).__lastFakeGitHost;
expect(fakeHost).not.toBeNull();
const wfco = fakeHost!.writeFileCreateOnly as ReturnType<typeof vi.fn>;
const paths = wfco.mock.calls.map((c: unknown[]) => c[0]);
expect(paths).toContain('.relicario/salt');
expect(paths).toContain('.relicario/params.json');
expect(paths).toContain('manifest.enc');
expect(paths).toContain('settings.enc');
// register_device called with the device name
expect(wasm.register_device).toHaveBeenCalledWith('Dev');
// chrome.storage.local.set called with vaultConfig + imageBase64 + device_name
const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType<typeof vi.fn> } } } })
.chrome.storage.local.set.mock.calls;
const merged: Record<string, unknown> = {};
for (const [kv] of chromeSets) Object.assign(merged, kv);
expect(merged).toHaveProperty('vaultConfig');
expect(merged).toHaveProperty('imageBase64');
expect(merged).toHaveProperty('device_name', 'Dev');
// session.setCurrent was called (ownership transferred — handle NOT freed)
expect(setCurrent).toHaveBeenCalled();
expect(wasm._handle.free).not.toHaveBeenCalled();
});
it('Test 2 (failure path — early throw): ok:false, no writeFileCreateOnly calls', async () => {
const wasm = makeWasm({
embed_image_secret: vi.fn(() => { throw new Error('embed failed'); }),
});
const state = makeState(wasm);
const resp = await vault.handleCreateVault(BASE_MSG, state);
expect(resp.ok).toBe(false);
if (resp.ok) throw new Error('expected ok:false');
expect(resp.error).toBeTruthy();
expect(resp.error.length).toBeGreaterThan(0);
const fakeHost = (globalThis as { __lastFakeGitHost?: { writeFileCreateOnly: ReturnType<typeof vi.fn> } }).__lastFakeGitHost;
// No GitHost was created at all (failed before createGitHost call), OR
// if somehow created, no writeFileCreateOnly calls happened.
if (fakeHost) {
expect((fakeHost.writeFileCreateOnly as ReturnType<typeof vi.fn>).mock.calls).toHaveLength(0);
}
});
it('Test 3 (handle cleanup on mid-flight failure): lock + free called, ok:false', async () => {
const wasm = makeWasm({
manifest_encrypt: vi.fn(() => { throw new Error('encrypt failed'); }),
});
const state = makeState(wasm);
const resp = await vault.handleCreateVault(BASE_MSG, state);
expect(resp.ok).toBe(false);
// unlock succeeded (handle was acquired), manifest_encrypt failed after that.
// Finally block must: lock(handle) then handle.free().
expect(wasm.lock).toHaveBeenCalledWith(wasm._handle);
expect(wasm._handle.free).toHaveBeenCalled();
// Ownership was NOT transferred — setCurrent must NOT have been called.
expect(setCurrent).not.toHaveBeenCalled();
});
});
// --- attach_vault ---
const ATTACH_MSG = {
config: { hostType: 'gitea' as const, hostUrl: 'https://g', repoPath: 'u/v', apiToken: 't' },
passphrase: 'pw',
referenceImageBytes: new Uint8Array([1, 2, 3]).buffer,
deviceName: 'Dev2',
};
describe('handleAttachVault', () => {
let setCurrent: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
mockChromeStorage();
// Re-wire createGitHost: vi.restoreAllMocks() in the create-vault afterEach
// strips the mockImplementation from the vi.fn(), leaving it returning undefined.
// We re-establish it here so each attach test starts with a fresh fake host.
vi.mocked(gitHostMod.createGitHost).mockImplementation(() => {
const h = makeHostMock();
(globalThis as { __lastFakeGitHost?: ReturnType<typeof makeHostMock> | null }).__lastFakeGitHost = h;
return h;
});
setCurrent = vi.spyOn(session, 'setCurrent').mockImplementation(() => {});
});
afterEach(() => {
vi.restoreAllMocks();
});
it('Test 1 (happy path): returns ok:true, state populated, handle ownership transferred', async () => {
const wasm = makeWasm();
const state = makeState(wasm);
const resp = await vault.handleAttachVault(ATTACH_MSG, state);
expect(resp.ok).toBe(true);
if (!resp.ok) throw new Error('expected ok:true');
expect(resp.data.deviceName).toBe('Dev2');
// WASM calls in order: unlock → manifest_decrypt (verification) → register_device
expect(wasm.unlock).toHaveBeenCalled();
expect(wasm.manifest_decrypt).toHaveBeenCalled();
expect(wasm.register_device).toHaveBeenCalledWith('Dev2');
// chrome.storage.local.set received vaultConfig + imageBase64 + device_name
const chromeSets = (global as { chrome: { storage: { local: { set: ReturnType<typeof vi.fn> } } } })
.chrome.storage.local.set.mock.calls;
const merged: Record<string, unknown> = {};
for (const [kv] of chromeSets) Object.assign(merged, kv);
expect(merged).toHaveProperty('vaultConfig');
expect(merged).toHaveProperty('imageBase64');
expect(merged).toHaveProperty('device_name', 'Dev2');
// session.setCurrent called — ownership transferred; handle NOT freed
expect(setCurrent).toHaveBeenCalled();
expect(wasm._handle.free).not.toHaveBeenCalled();
// State wired up
expect(state.manifest).not.toBeNull();
expect(state.gitHost).not.toBeNull();
});
it('Test 2 (wrong credentials — manifest_decrypt throws): ok:false, handle locked+freed, no side effects', async () => {
const wasm = makeWasm({
manifest_decrypt: vi.fn(() => { throw new Error('AEAD verification failed'); }),
});
const state = makeState(wasm);
const resp = await vault.handleAttachVault(ATTACH_MSG, state);
expect(resp.ok).toBe(false);
if (resp.ok) throw new Error('expected ok:false');
expect(resp.error).toBeTruthy();
expect(resp.error.length).toBeGreaterThan(0);
// register_device must NOT be called (we failed before it)
expect(wasm.register_device).not.toHaveBeenCalled();
// Finally block must lock then free the handle we own
expect(wasm.lock).toHaveBeenCalledWith(wasm._handle);
expect(wasm._handle.free).toHaveBeenCalled();
// Session must NOT have been updated
expect(setCurrent).not.toHaveBeenCalled();
});
});

View File

@@ -628,9 +628,15 @@ export async function handle(
} }
} }
// create_vault / attach_vault land in Phase 3 Tasks 3.2-3.3; get_vault_status case 'create_vault':
// in Phase 6 (Dev-C). Until each case lands, an unhandled popup message return vault.handleCreateVault(msg, state);
// returns an explicit error rather than falling through with no return.
case 'attach_vault':
return vault.handleAttachVault(msg, state);
// get_vault_status lands in Phase 6 (Dev-C).
// Until each case lands, an unhandled popup message returns an explicit
// error rather than falling through with no return.
default: default:
return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` }; return { ok: false, error: `unhandled popup message: ${(msg as { type: string }).type}` };
} }

View File

@@ -3,8 +3,12 @@
import type { SessionHandle } from '../../wasm/relicario_wasm'; import type { SessionHandle } from '../../wasm/relicario_wasm';
import type { GitHost } from './git-host'; import type { GitHost } from './git-host';
import { uint8ArrayToBase64 } from './git-host'; import { createGitHost, uint8ArrayToBase64 } from './git-host';
import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings } from '../shared/types'; import type { AttachmentRef, Item, ItemId, Manifest, ManifestEntry, VaultSettings, VaultConfig } from '../shared/types';
import * as session from './session';
import * as devices from './devices';
import type { AttachVaultResponse, CreateVaultResponse } from '../shared/messages';
import type { PopupState } from './router/popup-only';
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
let wasm: any = null; let wasm: any = null;
@@ -17,6 +21,125 @@ function requireWasm(): any {
return wasm; return wasm;
} }
const DEFAULT_PARAMS_JSON = '{"argon2_m":65536,"argon2_t":3,"argon2_p":4}';
/// Register this device on the remote (devices.json) and persist the vault
/// config + reference image locally so future unlocks work. Shared by the
/// create and attach flows — both finish with this identical tail.
async function registerDeviceAndPersistConfig(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
w: any,
git: GitHost,
config: VaultConfig,
referenceImageBytes: Uint8Array,
deviceName: string,
): Promise<void> {
const keys = w.register_device(deviceName) as { signing_public_key: string };
await devices.addDevice(git, {
name: deviceName,
public_key: keys.signing_public_key,
added_at: Math.floor(Date.now() / 1000),
});
await chrome.storage.local.set({
vaultConfig: config,
imageBase64: uint8ArrayToBase64(referenceImageBytes),
device_name: deviceName,
});
}
export async function handleCreateVault(
msg: { config: VaultConfig; passphrase: string; carrierImageBytes: ArrayBuffer; deviceName: string },
state: PopupState,
): Promise<CreateVaultResponse | { ok: false; error: string }> {
const w = state.wasm;
let handle: SessionHandle | null = null;
try {
const carrierBytes = new Uint8Array(msg.carrierImageBytes);
const imageSecret = new Uint8Array(32);
crypto.getRandomValues(imageSecret);
const referenceImageBytes = new Uint8Array(w.embed_image_secret(carrierBytes, imageSecret));
const salt = new Uint8Array(32);
crypto.getRandomValues(salt);
// Capture the unlock result in a non-null binding for the in-scope ops;
// `handle` stays the ownership tracker the finally block cleans up.
const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, salt, DEFAULT_PARAMS_JSON);
handle = h;
const encryptedManifest = new Uint8Array(w.manifest_encrypt(h, '{"schema_version":2,"items":{}}'));
const encryptedSettings = new Uint8Array(w.settings_encrypt(h, w.default_vault_settings_json()));
const { config } = msg;
const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
await git.writeFileCreateOnly('.relicario/salt', salt, 'init: vault salt');
await git.writeFileCreateOnly('.relicario/params.json', new TextEncoder().encode(DEFAULT_PARAMS_JSON), 'init: KDF parameters');
await git.writeFileCreateOnly('manifest.enc', encryptedManifest, 'init: encrypted manifest');
await git.writeFileCreateOnly('settings.enc', encryptedSettings, 'init: encrypted settings');
await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName);
// SW now owns the unlocked session — keeps the handle alive (enables recoveryQrAvailable).
session.setCurrent(h);
state.gitHost = git;
state.manifest = { schema_version: 2, items: {} } as Manifest;
handle = null; // ownership transferred — do NOT lock-and-free in finally
return { ok: true, data: { referenceImageBytes, deviceName: msg.deviceName, recoveryQrAvailable: true } };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
} finally {
// Plan A .free() policy (docs/...extension-restructure-design.md Risks): lock THEN free,
// and only if we still own the handle (success path transfers ownership to session.setCurrent).
if (handle) {
try { w.lock(handle); } catch { /* lock may already have happened */ }
handle.free();
}
}
}
export async function handleAttachVault(
msg: { config: VaultConfig; passphrase: string; referenceImageBytes: ArrayBuffer; deviceName: string },
state: PopupState,
): Promise<AttachVaultResponse | { ok: false; error: string }> {
const w = state.wasm;
let handle: SessionHandle | null = null;
try {
const referenceImageBytes = new Uint8Array(msg.referenceImageBytes);
const { config } = msg;
const git = createGitHost(config.hostType, config.hostUrl, config.repoPath, config.apiToken);
// The vault metadata and manifest are independent read-only GETs — fan out.
const [meta, encryptedManifest] = await Promise.all([
fetchVaultMeta(git),
git.readFile('manifest.enc'),
]);
const h: SessionHandle = w.unlock(msg.passphrase, referenceImageBytes, meta.salt, meta.paramsJson);
handle = h;
// manifest_decrypt verifies the passphrase + reference image — throws on AEAD failure.
const manifest = w.manifest_decrypt(h, encryptedManifest) as Manifest;
await registerDeviceAndPersistConfig(w, git, config, referenceImageBytes, msg.deviceName);
// SW now owns the unlocked session — transfer ownership to the session.
session.setCurrent(h);
state.gitHost = git;
state.manifest = manifest;
handle = null; // ownership transferred — do NOT lock-and-free in finally
return { ok: true, data: { deviceName: msg.deviceName } };
} catch (err) {
return { ok: false, error: err instanceof Error ? err.message : String(err) };
} finally {
// Same .free() policy as handleCreateVault: lock THEN free, only if we still
// own the handle (success path transfers ownership to session.setCurrent).
if (handle) {
try { w.lock(handle); } catch { /* lock may already have happened */ }
handle.free();
}
}
}
export interface VaultMeta { export interface VaultMeta {
salt: Uint8Array; salt: Uint8Array;
paramsJson: string; paramsJson: string;

View File

@@ -1,5 +1,6 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { finishSetup } from '../setup'; import { finishSetup, STEPS } from '../setup';
import { state, clearWizardState } from '../setup-steps';
describe('finishSetup', () => { describe('finishSetup', () => {
beforeEach(() => { beforeEach(() => {
@@ -35,3 +36,47 @@ describe('finishSetup', () => {
expect(chrome.tabs.create).toHaveBeenCalled(); expect(chrome.tabs.create).toHaveBeenCalled();
}); });
}); });
describe('setup step registry', () => {
it('has the six steps in canonical order', () => {
expect(STEPS.map((s) => s.id)).toEqual(['mode', 'host', 'connection', 'vault', 'device', 'done']);
});
it('each step renders non-empty HTML and attach returns a teardown', () => {
const ctx = { state: {} as never, rerender: vi.fn(), goto: vi.fn() };
for (const step of STEPS) {
const html = step.render(ctx as never);
expect(typeof html).toBe('string');
expect(html.length).toBeGreaterThan(0);
// render output must be in the DOM before attach (attach wires getElementById listeners)
document.body.innerHTML = `<div id="app">${html}</div>`;
const teardown = step.attach(document.body, ctx as never);
expect(typeof teardown).toBe('function');
teardown(); // must not throw
}
});
});
describe('clearWizardState', () => {
afterEach(() => {
clearWizardState();
});
it('zero-fills the reachable Uint8Array fields and resets state', () => {
const carrier = new Uint8Array([1, 2, 3, 4]);
const ref = new Uint8Array([5, 6, 7, 8]);
state.carrierImageBytes = carrier;
state.referenceImageBytes = ref;
state.passphrase = 'secret';
state.mode = 'new';
clearWizardState();
expect(Array.from(carrier)).toEqual([0, 0, 0, 0]); // fill(0) ran on the captured ref
expect(Array.from(ref)).toEqual([0, 0, 0, 0]);
expect(state.carrierImageBytes).toBeNull(); // field reset
expect(state.referenceImageBytes).toBeNull();
expect(state.passphrase).toBe('');
expect(state.mode).toBeNull();
});
});

View File

@@ -0,0 +1,805 @@
import { createGitHost } from '../service-worker/git-host';
import { probeVault } from './probe';
import type { VaultProbe } from './probe';
import { escapeHtml, ratePassphrase, scheduleRate, STRENGTH_LABELS, entropyText } from './setup-helpers';
import { GLYPH_NEXT } from '../shared/glyphs';
import type { VaultConfig } from '../shared/types';
import type { Request, Response } from '../shared/messages';
// --- SW messaging ---
export function swSend(msg: Request): Promise<Response> {
return new Promise((resolve) => chrome.runtime.sendMessage(msg, (r: Response) => resolve(r)));
}
// --- Step registry types ---
export type StepId = 'mode' | 'host' | 'connection' | 'vault' | 'device' | 'done';
export interface StepContext {
state: WizardState;
rerender: () => void;
goto: (id: StepId) => void;
}
export interface SetupStep {
id: StepId;
render: (ctx: StepContext) => string;
attach: (root: HTMLElement, ctx: StepContext) => () => void;
}
// --- State ---
export interface WizardState {
stepId: StepId;
mode: 'new' | 'attach' | null;
hostType: 'gitea' | 'github';
hostUrl: string;
repoPath: string;
apiToken: string;
connectionTested: boolean;
vaultProbe: VaultProbe | null;
carrierImageBytes: Uint8Array | null;
referenceImageBytesAttach: Uint8Array | null;
passphrase: string;
passphraseConfirm: string;
passphraseScore: number;
passphraseGuessesLog10: number;
passphraseVisible: boolean;
confirmVisible: boolean;
referenceImageBytes: Uint8Array | null;
creating: boolean;
attaching: boolean;
error: string | null;
deviceName: string;
}
export const state: WizardState = {
stepId: 'mode', mode: null, hostType: 'gitea', hostUrl: '', repoPath: '', apiToken: '',
connectionTested: false, vaultProbe: null, carrierImageBytes: null, referenceImageBytesAttach: null,
passphrase: '', passphraseConfirm: '', passphraseScore: -1, passphraseGuessesLog10: -1,
passphraseVisible: false, confirmVisible: false, referenceImageBytes: null,
creating: false, attaching: false, error: null, deviceName: '',
};
// --- State-coupled helpers ---
function updateStrengthUi(): void {
const bar = document.getElementById('strength-bar');
const label = document.getElementById('strength-label');
const entropy = document.getElementById('entropy-line');
const counter = document.getElementById('passphrase-counter');
const matchInd = document.getElementById('match-indicator');
const create = document.getElementById('create-btn') as HTMLButtonElement | null;
const score = state.passphraseScore;
if (bar) bar.className = `strength-bar${score >= 0 ? ` s${score}` : ''}`;
if (label) {
if (score < 0) { label.className = 'strength-label'; label.innerHTML = '&nbsp;'; }
else {
const meta = STRENGTH_LABELS[score] ?? STRENGTH_LABELS[0];
label.className = `strength-label ${meta.cls}`;
label.textContent = meta.text;
}
}
if (entropy) {
const txt = entropyText(state.passphraseGuessesLog10);
entropy.textContent = txt;
entropy.style.visibility = txt ? 'visible' : 'hidden';
}
if (counter) {
const n = state.passphrase.length;
counter.textContent = n === 0 ? '' : `${n} character${n === 1 ? '' : 's'}`;
}
if (matchInd) {
const p = state.passphrase, c = state.passphraseConfirm;
if (!p || !c) { matchInd.className = 'match-indicator'; matchInd.textContent = ''; }
else if (p === c) { matchInd.className = 'match-indicator ok'; matchInd.textContent = '✓'; }
else { matchInd.className = 'match-indicator bad'; matchInd.textContent = '✗'; }
}
const matchOk = !state.passphraseConfirm || state.passphrase === state.passphraseConfirm;
if (create) {
const disabled = state.creating || score < 3 || !state.passphraseConfirm || !matchOk;
create.disabled = disabled;
create.title = disabled
? (score < 3 ? 'passphrase must score "good" or better'
: !state.passphraseConfirm ? 'confirm your passphrase'
: !matchOk ? 'passphrases do not match' : '')
: '';
}
}
function vaultConfig(): VaultConfig {
return {
hostType: state.hostType,
hostUrl: state.hostType === 'github' ? 'https://api.github.com' : state.hostUrl,
repoPath: state.repoPath,
apiToken: state.apiToken,
};
}
// --- mode ---
const modeStep: SetupStep = {
id: 'mode',
render() {
const isNew = state.mode === 'new';
const isAttach = state.mode === 'attach';
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>set up Relicario</h3>
<p class="muted" style="margin-bottom:16px;">How are you using Relicario on this device?</p>
<div class="mode-cards">
<button class="mode-card glass ${isNew ? 'active' : ''}" data-mode="new">
<span class="mode-card__icon" style="font-size:28px;">◈</span>
<div class="mode-card-title">create new vault</div>
<p class="mode-card-blurb">I'm setting up Relicario for the first time. This will create a fresh encrypted vault on a new or empty git repository.</p>
</button>
<button class="mode-card glass ${isAttach ? 'active' : ''}" data-mode="attach">
<span class="mode-card__icon" style="font-size:28px;">⌥</span>
<div class="mode-card-title">attach this device</div>
<p class="mode-card-blurb">I already have a vault on another device. Connect this browser to it using my passphrase and reference image.</p>
</button>
</div>
<div class="form-actions" style="margin-top:24px;">
<button class="btn-primary" id="next-btn" ${state.mode ? '' : 'disabled'}>next ${GLYPH_NEXT}</button>
</div>
</div>`;
},
attach(_root, ctx) {
document.querySelectorAll('.mode-card').forEach((btn) => {
btn.addEventListener('click', () => {
state.mode = (btn as HTMLElement).dataset.mode as 'new' | 'attach';
ctx.rerender();
});
});
document.getElementById('next-btn')?.addEventListener('click', () => {
if (state.mode) ctx.goto('host');
});
return () => {};
},
};
// --- host ---
const GITEA_INSTRUCTIONS = `
<div class="step-instructions"><ol>
<li>Create a new <strong>private</strong> repository on your Gitea instance (e.g. <code>vault</code>)</li>
<li>Go to <strong>Settings &rarr; Applications</strong></li>
<li>Generate a new token with <code>repo</code> (read/write) permission</li>
<li>Copy the token &mdash; you will need it in the next step</li>
</ol></div>`;
const GITHUB_INSTRUCTIONS = `
<div class="step-instructions"><ol>
<li>Create a new <strong>private</strong> repository on GitHub (e.g. <code>vault</code>)</li>
<li>Go to <strong>Settings &rarr; Developer settings &rarr; Personal access tokens &rarr; Fine-grained tokens</strong></li>
<li>Generate a new token scoped to the vault repo with <strong>Contents</strong> read/write permission</li>
<li>Copy the token &mdash; you will need it in the next step</li>
</ol></div>`;
const hostStep: SetupStep = {
id: 'host',
render() {
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>choose host</h3>
<div class="form-group">
<label class="label">host type</label>
<div class="toggle-group">
<button class="${state.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">Gitea</button>
<button class="${state.hostType === 'github' ? 'active' : ''}" data-host="github">GitHub</button>
</div>
</div>
${state.hostType === 'gitea' ? GITEA_INSTRUCTIONS : GITHUB_INSTRUCTIONS}
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
<button class="btn-primary" id="next-btn">next ${GLYPH_NEXT}</button>
</div>
</div>`;
},
attach(_root, ctx) {
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('mode'));
document.querySelectorAll('.toggle-group button').forEach((btn) => {
btn.addEventListener('click', () => {
state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
state.connectionTested = false;
ctx.rerender();
});
});
document.getElementById('next-btn')?.addEventListener('click', () => ctx.goto('connection'));
return () => {};
},
};
// --- connection ---
function renderProbeBanner(): string {
const probe = state.vaultProbe;
if (!state.connectionTested || !probe) return '';
const meta = probe.lastCommit
? `Last commit: <code>${escapeHtml(probe.lastCommit.sha)}</code> by ${escapeHtml(probe.lastCommit.author)} on ${escapeHtml(probe.lastCommit.date.slice(0, 10))}.`
: '';
if (state.mode === 'new' && probe.exists) {
return `
<div class="banner banner-warn">
<strong>⚠ This repository already contains a Relicario vault.</strong>
<p>${meta}</p>
<p>Creating a new vault here would overwrite the existing one and <strong>destroy all data inside</strong>. To use this vault on this device, switch to <em>attach</em> mode instead. If you really mean to start over, delete the repository via your git host's web UI and come back here.</p>
<div class="form-actions"><button class="btn" id="switch-mode-btn" data-target="attach">switch to attach mode</button></div>
</div>`;
}
if (state.mode === 'attach' && !probe.exists) {
return `
<div class="banner banner-warn">
<strong>No vault found in this repo.</strong>
<p>Did you mean to create a new vault?</p>
<div class="form-actions"><button class="btn" id="switch-mode-btn" data-target="new">switch to new-vault mode</button></div>
</div>`;
}
if (state.mode === 'attach' && probe.exists) {
return `
<div class="banner banner-ok">
<strong>✓ Existing vault found.</strong>
<p>${meta}</p>
<p>Continue to attach this device.</p>
</div>`;
}
// mode = new, !exists
return `<div class="banner banner-ok"><strong>✓ Repo is empty — ready to create a new vault.</strong></div>`;
}
const connectionStep: SetupStep = {
id: 'connection',
render() {
const probe = state.vaultProbe;
const modeMismatch =
!!probe && ((state.mode === 'new' && probe.exists) || (state.mode === 'attach' && !probe.exists));
const nextDisabled = !state.connectionTested || !probe || modeMismatch;
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>configure connection</h3>
<div class="form-group" ${state.hostType === 'github' ? 'style="display:none;"' : ''}>
<label class="label" for="host-url">host url</label>
<input id="host-url" type="text" value="${escapeHtml(state.hostUrl)}" placeholder="https://git.example.com">
</div>
<div class="form-group">
<label class="label" for="repo-path">repository path</label>
<input id="repo-path" type="text" value="${escapeHtml(state.repoPath)}" placeholder="user/vault">
</div>
<div class="form-group">
<label class="label" for="api-token">api token</label>
<input id="api-token" type="password" value="${escapeHtml(state.apiToken)}" placeholder="paste your token here">
</div>
<div class="form-actions">
<button class="btn" id="test-btn">test connection</button>
${state.connectionTested ? '<span class="test-result pass">connected</span>' : ''}
</div>
${renderProbeBanner()}
<div class="form-actions" style="margin-top:12px;">
<button class="btn" id="back-btn">back</button>
<button class="btn-primary" id="next-btn" ${nextDisabled ? 'disabled' : ''}>next ${GLYPH_NEXT}</button>
</div>
</div>`;
},
attach(_root, ctx) {
document.getElementById('test-btn')?.addEventListener('click', async () => {
state.connectionTested = false;
state.vaultProbe = null;
const hostUrl = state.hostType === 'github'
? 'https://api.github.com'
: (document.getElementById('host-url') as HTMLInputElement).value.trim();
const repoPath = (document.getElementById('repo-path') as HTMLInputElement).value.trim();
const apiToken = (document.getElementById('api-token') as HTMLInputElement).value.trim();
if (!repoPath || !apiToken) {
state.error = 'Repository path and API token are required';
ctx.rerender();
return;
}
if (state.hostType === 'gitea' && !hostUrl) {
state.error = 'Host URL is required for Gitea';
ctx.rerender();
return;
}
state.hostUrl = hostUrl;
state.repoPath = repoPath;
state.apiToken = apiToken;
try {
const host = createGitHost(state.hostType, hostUrl, repoPath, apiToken);
await host.listDir('');
state.connectionTested = true;
state.error = null;
try {
state.vaultProbe = await probeVault(host);
} catch (probeErr) {
state.vaultProbe = null;
state.error = `Could not check repo state: ${probeErr instanceof Error ? probeErr.message : String(probeErr)}`;
}
} catch (err: unknown) {
state.connectionTested = false;
state.vaultProbe = null;
state.error = `Connection failed: ${err instanceof Error ? err.message : String(err)}`;
}
ctx.rerender();
});
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('host'));
document.getElementById('next-btn')?.addEventListener('click', () => {
if (state.connectionTested) ctx.goto('vault');
});
document.getElementById('switch-mode-btn')?.addEventListener('click', (e) => {
state.mode = (e.currentTarget as HTMLElement).dataset.target as 'new' | 'attach';
state.error = null;
ctx.rerender();
});
return () => {};
},
};
// --- vault ---
function renderVaultAttach(): string {
const p = state.passphrase;
const pType = state.passphraseVisible ? 'text' : 'password';
const pToggle = state.passphraseVisible ? 'hide' : 'show';
const hasImage = !!state.referenceImageBytesAttach;
const gateDisabled = !p || !hasImage;
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>attach this device</h3>
<p class="muted" style="margin-bottom:12px;">Use your existing passphrase and reference image to attach this browser to your vault. We'll verify both when you register this device.</p>
<div class="form-group">
<label class="label">reference image (JPEG)</label>
<div class="file-drop ${hasImage ? 'has-file' : ''}" id="ref-drop">
<input type="file" id="ref-input" accept="image/jpeg" style="display:none;">
${hasImage ? '<p class="secondary">reference image loaded</p>' : '<p class="secondary">click to select your reference JPEG</p>'}
</div>
<p class="muted" style="margin-top:4px;">The reference image is the JPEG you saved when you first created this vault — <strong>not the original photo</strong>. It has the 256-bit secret embedded.</p>
</div>
<div class="form-group">
<label class="label" for="passphrase">passphrase</label>
<div class="passphrase-field">
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter your passphrase" autocomplete="current-password">
<button type="button" class="eye-btn" id="eye-btn">${pToggle}</button>
</div>
</div>
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="attach-btn" ${gateDisabled ? 'disabled' : ''}>continue ${GLYPH_NEXT}</button>
</div>
</div>`;
}
function renderVaultNew(): string {
const score = state.passphraseScore;
const hasScore = score >= 0;
const meterClass = hasScore ? `s${score}` : '';
const labelMeta = hasScore ? STRENGTH_LABELS[score] : null;
const labelClass = labelMeta?.cls ?? '';
const labelText = labelMeta?.text ?? '&nbsp;';
const entropy = entropyText(state.passphraseGuessesLog10);
const p = state.passphrase, c = state.passphraseConfirm;
const matchState = !p || !c ? '' : p === c ? 'ok' : 'bad';
const matchGlyph = matchState === 'ok' ? '✓' : matchState === 'bad' ? '✗' : '';
const pType = state.passphraseVisible ? 'text' : 'password';
const cType = state.confirmVisible ? 'text' : 'password';
const pToggle = state.passphraseVisible ? 'hide' : 'show';
const cToggle = state.confirmVisible ? 'hide' : 'show';
const matchOk = !c || p === c;
const gateDisabled = state.creating || score < 3 || !c || !matchOk;
const nChars = p.length;
const counterText = nChars === 0 ? '' : `${nChars} character${nChars === 1 ? '' : 's'}`;
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>create vault</h3>
<div class="form-group">
<label class="label">carrier image (JPEG)</label>
<div class="file-drop ${state.carrierImageBytes ? 'has-file' : ''}" id="file-drop">
<input type="file" id="file-input" accept="image/jpeg" style="display:none;">
${state.carrierImageBytes ? '<p class="secondary">image loaded</p>' : '<p class="secondary">click to select a JPEG photo</p>'}
</div>
<p class="muted" style="margin-top:4px;">A 256-bit secret will be steganographically embedded in this image.</p>
</div>
<div class="pass-help">A long phrase of unrelated words is stronger than a short complex password. Your vault needs <strong>good</strong> (score&nbsp;≥&nbsp;3) to continue.</div>
<div class="form-group">
<label class="label" for="passphrase">passphrase</label>
<div class="passphrase-field">
<input id="passphrase" type="${pType}" value="${escapeHtml(p)}" placeholder="enter a strong passphrase" autocomplete="new-password">
<button type="button" class="eye-btn" id="eye-btn" aria-label="toggle passphrase visibility">${pToggle}</button>
</div>
<div class="strength-bar ${meterClass}" id="strength-bar" aria-hidden="true">
<div class="seg i0"></div><div class="seg i1"></div><div class="seg i2"></div><div class="seg i3"></div><div class="seg i4"></div>
</div>
<div class="strength-row">
<p class="strength-label ${labelClass}" id="strength-label">${labelText}</p>
<p class="char-counter" id="passphrase-counter">${escapeHtml(counterText)}</p>
</div>
<p class="entropy-line" id="entropy-line" style="visibility:${entropy ? 'visible' : 'hidden'};">${escapeHtml(entropy || ' ')}</p>
</div>
<div class="form-group">
<label class="label" for="passphrase-confirm">confirm passphrase</label>
<div class="passphrase-field">
<input id="passphrase-confirm" type="${cType}" value="${escapeHtml(c)}" placeholder="re-enter passphrase" autocomplete="new-password">
<span class="match-indicator ${matchState}" id="match-indicator">${matchGlyph}</span>
<button type="button" class="eye-btn" id="confirm-eye-btn" aria-label="toggle confirm visibility">${cToggle}</button>
</div>
</div>
<div class="form-actions">
<button class="btn" id="back-btn">back</button>
<button class="btn btn-primary" id="create-btn" ${gateDisabled ? 'disabled' : ''}>continue ${GLYPH_NEXT}</button>
</div>
</div>`;
}
const vaultStep: SetupStep = {
id: 'vault',
render(ctx) {
return ctx.state.mode === 'attach' ? renderVaultAttach() : renderVaultNew();
},
attach(_root, ctx) {
return state.mode === 'attach' ? attachVaultAttach(ctx) : attachVaultNew(ctx);
},
};
function attachVaultAttach(ctx: StepContext): () => void {
const refDrop = document.getElementById('ref-drop')!;
const refInput = document.getElementById('ref-input') as HTMLInputElement;
refDrop.addEventListener('click', () => refInput.click());
refInput.addEventListener('change', () => {
const file = refInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
state.referenceImageBytesAttach = new Uint8Array(reader.result as ArrayBuffer);
state.error = null;
ctx.rerender();
};
reader.readAsArrayBuffer(file);
});
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
passInput?.addEventListener('input', (e) => {
state.passphrase = (e.target as HTMLInputElement).value;
const btn = document.getElementById('attach-btn') as HTMLButtonElement | null;
if (btn) btn.disabled = !state.passphrase || !state.referenceImageBytesAttach;
});
document.getElementById('eye-btn')?.addEventListener('click', () => {
state.passphraseVisible = !state.passphraseVisible;
if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password';
const btn = document.getElementById('eye-btn');
if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show';
passInput?.focus();
});
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('connection'));
document.getElementById('attach-btn')?.addEventListener('click', () => {
if (!state.referenceImageBytesAttach) {
state.error = 'Please select your reference JPEG image';
ctx.rerender();
return;
}
if (!state.passphrase) {
state.error = 'Passphrase is required';
ctx.rerender();
return;
}
ctx.goto('device');
});
return () => {};
}
function attachVaultNew(ctx: StepContext): () => void {
const fileDrop = document.getElementById('file-drop')!;
const fileInput = document.getElementById('file-input') as HTMLInputElement;
fileDrop.addEventListener('click', () => fileInput.click());
fileInput.addEventListener('change', () => {
const file = fileInput.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = () => {
state.carrierImageBytes = new Uint8Array(reader.result as ArrayBuffer);
state.error = null;
ctx.rerender();
};
reader.readAsArrayBuffer(file);
});
// Track passphrase changes inline (no full re-render) so the input keeps focus.
// zxcvbn score is computed via the SW on a 150ms debounce — see scheduleRate.
const passInput = document.getElementById('passphrase') as HTMLInputElement | null;
passInput?.addEventListener('input', (e) => {
state.passphrase = (e.target as HTMLInputElement).value;
updateStrengthUi();
scheduleRate(state.passphrase, (s) => {
state.passphraseScore = s.score;
state.passphraseGuessesLog10 = s.guessesLog10;
updateStrengthUi();
});
});
const confirmInput = document.getElementById('passphrase-confirm') as HTMLInputElement | null;
confirmInput?.addEventListener('input', (e) => {
state.passphraseConfirm = (e.target as HTMLInputElement).value;
updateStrengthUi();
});
// Eye toggles — flip the input type and label without a full re-render so
// focus + cursor position survive the click.
document.getElementById('eye-btn')?.addEventListener('click', () => {
state.passphraseVisible = !state.passphraseVisible;
if (passInput) passInput.type = state.passphraseVisible ? 'text' : 'password';
const btn = document.getElementById('eye-btn');
if (btn) btn.textContent = state.passphraseVisible ? 'hide' : 'show';
passInput?.focus();
});
document.getElementById('confirm-eye-btn')?.addEventListener('click', () => {
state.confirmVisible = !state.confirmVisible;
if (confirmInput) confirmInput.type = state.confirmVisible ? 'text' : 'password';
const btn = document.getElementById('confirm-eye-btn');
if (btn) btn.textContent = state.confirmVisible ? 'hide' : 'show';
confirmInput?.focus();
});
document.getElementById('back-btn')?.addEventListener('click', () => ctx.goto('connection'));
document.getElementById('create-btn')?.addEventListener('click', async () => {
state.passphrase = (document.getElementById('passphrase') as HTMLInputElement).value;
state.passphraseConfirm = (document.getElementById('passphrase-confirm') as HTMLInputElement).value;
if (!state.carrierImageBytes) {
state.error = 'Please select a carrier JPEG image';
ctx.rerender();
return;
}
if (!state.passphrase) {
state.error = 'Passphrase is required';
ctx.rerender();
return;
}
// Re-rate synchronously in case the button was clicked before the debounced
// rater fired. Defence in depth — the button is already disabled when score < 3.
const strength = await ratePassphrase(state.passphrase);
state.passphraseScore = strength.score;
state.passphraseGuessesLog10 = strength.guessesLog10;
if (state.passphraseScore < 3) {
state.error = 'Passphrase is too weak (zxcvbn score must be ≥ 3).';
ctx.rerender();
return;
}
if (state.passphrase !== state.passphraseConfirm) {
state.error = 'Passphrases do not match';
ctx.rerender();
return;
}
ctx.goto('device');
});
return () => {};
}
// --- device ---
const deviceStep: SetupStep = {
id: 'device',
render() {
const busy = state.creating || state.attaching;
const platform = navigator.platform.toLowerCase();
const isChrome = /chrome/i.test(navigator.userAgent) && !/edg/i.test(navigator.userAgent);
const isFirefox = /firefox/i.test(navigator.userAgent);
const browser = isFirefox ? 'Firefox' : isChrome ? 'Chrome' : 'Browser';
const os = platform.includes('mac') ? 'macOS' : platform.includes('win') ? 'Windows' : 'Linux';
const defaultName = state.deviceName || `${browser} on ${os}`;
const busyLabel = state.attaching ? 'attaching…' : 'creating…';
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<h3>name this device</h3>
<p class="muted" style="margin-bottom:12px;">This helps you identify which devices have access to your vault.</p>
<div class="form-group">
<label class="label" for="device-name">device name</label>
<input id="device-name" type="text" value="${escapeHtml(defaultName)}" placeholder="e.g. Chrome on Linux" ${busy ? 'disabled' : ''}>
</div>
<div class="form-actions">
<button class="btn" id="back-btn" ${busy ? 'disabled' : ''}>back</button>
<button class="btn-primary" id="next-btn" ${busy ? 'disabled' : ''}>${busy ? `<span class="spinner"></span> ${busyLabel}` : `continue ${GLYPH_NEXT}`}</button>
</div>
</div>`;
},
attach(_root, ctx) {
document.getElementById('back-btn')?.addEventListener('click', () => {
if (!state.creating && !state.attaching) ctx.goto('vault');
});
document.getElementById('next-btn')?.addEventListener('click', async () => {
if (state.creating || state.attaching) return;
const name = (document.getElementById('device-name') as HTMLInputElement).value.trim();
if (!name) {
state.error = 'Device name is required';
ctx.rerender();
return;
}
state.deviceName = name;
state.error = null;
if (state.mode === 'attach') {
state.attaching = true;
ctx.rerender();
const resp = await swSend({
type: 'attach_vault',
config: vaultConfig(),
passphrase: state.passphrase,
referenceImageBytes: state.referenceImageBytesAttach!.buffer as ArrayBuffer,
deviceName: state.deviceName,
});
state.attaching = false;
if (resp.ok) ctx.goto('done');
else { state.error = resp.error; ctx.rerender(); }
} else {
state.creating = true;
ctx.rerender();
const resp = await swSend({
type: 'create_vault',
config: vaultConfig(),
passphrase: state.passphrase,
carrierImageBytes: state.carrierImageBytes!.buffer as ArrayBuffer,
deviceName: state.deviceName,
});
state.creating = false;
if (resp.ok) {
const data = resp.data as { referenceImageBytes: Uint8Array };
state.referenceImageBytes = new Uint8Array(data.referenceImageBytes);
ctx.goto('done');
} else { state.error = resp.error; ctx.rerender(); }
}
});
return () => {};
},
};
// --- done ---
const doneStep: SetupStep = {
id: 'done',
render() {
const isAttach = state.mode === 'attach';
const qrBannerHtml = isAttach ? '' : `
<div class="recovery-qr-banner" id="recovery-qr-banner" style="margin-bottom:16px;">
<div class="recovery-qr-banner__header">
<span style="font-size:20px;">◫</span>
<strong>Generate a recovery QR before you go</strong>
</div>
<p class="muted" style="font-size:12px;margin:4px 0 8px;">If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.</p>
<div class="recovery-qr-banner__actions">
<button class="btn btn-primary" id="setup-gen-qr">Generate now</button>
<button class="btn" id="setup-skip-qr">Skip — I'll do this in Settings</button>
</div>
</div>`;
const refSection = isAttach ? '' : `
<div class="form-group">
<label class="label">reference image</label>
<p class="muted" style="margin-bottom:8px;">Download and store this image securely. It is your second factor for decryption. Without it, you cannot unlock the vault.</p>
<button class="btn btn-primary" id="download-ref-btn">download reference.jpg</button>
</div>`;
return `
<div class="wizard-step glass" style="padding: 24px; margin-top: 16px;">
<div class="success-box">
<h3>${isAttach ? 'device attached' : 'vault created'}</h3>
<p class="secondary">${isAttach ? 'This device is now attached to your vault.' : 'Your vault has been initialized and pushed to the repository.'}</p>
</div>
${qrBannerHtml}
${refSection}
<div class="form-group" style="margin-top:16px;">
<label class="label">extension configuration</label>
<p class="muted" style="margin-bottom:8px;">
Copy this JSON to configure Relicario on another setup, or save it for later.
</p>
<div class="config-blob" id="config-blob">${escapeHtml(JSON.stringify(vaultConfig(), null, 2))}</div>
<button class="btn" id="copy-config-btn">copy to clipboard</button>
</div>
<div class="form-actions" style="margin-top:16px;">
<button class="btn btn-primary" id="open-vault-btn">open vault</button>
</div>
</div>`;
},
attach(_root, _ctx) {
document.getElementById('setup-gen-qr')?.addEventListener('click', async () => {
const btn = document.getElementById('setup-gen-qr') as HTMLButtonElement | null;
if (btn) { btn.disabled = true; btn.textContent = 'Generating…'; }
try {
const resp = await swSend({ type: 'generate_recovery_qr', passphrase: state.passphrase });
if (!resp.ok || !resp.data) throw new Error(resp.ok ? 'unknown error' : resp.error);
const svg = (resp.data as { svg: string }).svg;
await new Promise<void>((resolve) => {
chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve);
});
const banner = document.getElementById('recovery-qr-banner');
if (banner) {
banner.innerHTML = `
<div style="text-align:center;">${svg}</div>
<p style="font-size:12px;color:var(--success,#238636);margin:8px 0 0;">◉ Recovery QR generated — save or print this now.</p>
<div style="margin-top:8px;"><button class="btn" id="setup-qr-done">Done</button></div>`;
document.getElementById('setup-qr-done')?.addEventListener('click', () => {
banner.style.display = 'none';
});
}
} catch (err) {
if (btn) { btn.disabled = false; btn.textContent = 'Generate now'; }
alert(`Failed to generate QR: ${err instanceof Error ? err.message : String(err)}`);
}
});
document.getElementById('setup-skip-qr')?.addEventListener('click', () => {
const banner = document.getElementById('recovery-qr-banner');
if (banner) banner.style.display = 'none';
});
document.getElementById('download-ref-btn')?.addEventListener('click', () => {
if (!state.referenceImageBytes) return;
const blob = new Blob([state.referenceImageBytes.buffer as ArrayBuffer], { type: 'image/jpeg' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'reference.jpg';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
});
document.getElementById('copy-config-btn')?.addEventListener('click', async () => {
const blob = document.getElementById('config-blob');
if (!blob) return;
try {
await navigator.clipboard.writeText(blob.textContent ?? '');
const btn = document.getElementById('copy-config-btn')!;
btn.textContent = 'copied!';
setTimeout(() => { btn.textContent = 'copy to clipboard'; }, 2000);
} catch {
const range = document.createRange();
range.selectNodeContents(blob);
const sel = window.getSelection();
sel?.removeAllRanges();
sel?.addRange(range);
}
});
document.getElementById('open-vault-btn')?.addEventListener('click', () => void finishSetup());
return () => {};
},
};
// --- Registry ---
export const STEPS: ReadonlyArray<SetupStep> = [
modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep,
];
// --- Sensitive-state cleanup ---
export function clearWizardState(): void {
// Best-effort wipe — JS strings are GC-only (see spec Risks); zero-fill the Uint8Arrays.
state.carrierImageBytes?.fill(0);
state.referenceImageBytes?.fill(0);
state.referenceImageBytesAttach?.fill(0);
state.mode = null;
state.hostType = 'gitea';
state.hostUrl = '';
state.repoPath = '';
state.apiToken = '';
state.connectionTested = false;
state.vaultProbe = null;
state.carrierImageBytes = null;
state.referenceImageBytesAttach = null;
state.passphrase = '';
state.passphraseConfirm = '';
state.passphraseScore = -1;
state.passphraseGuessesLog10 = -1;
state.passphraseVisible = false;
state.confirmVisible = false;
state.referenceImageBytes = null;
state.creating = false;
state.attaching = false;
state.error = null;
state.deviceName = '';
}
// --- Completion handoff ---
/// Open the fullscreen vault tab and best-effort close the setup tab.
export async function finishSetup(): Promise<void> {
const vaultUrl = chrome.runtime.getURL('vault.html');
await chrome.tabs.create({ url: vaultUrl });
try {
const current = await chrome.tabs.getCurrent();
if (current?.id !== undefined) {
await chrome.tabs.remove(current.id);
}
} catch {
// Setup tab may not be closeable (e.g., opened as popup rather than a tab).
// The vault tab is open — that's the user-visible success.
}
}

File diff suppressed because it is too large Load Diff