diff --git a/extension/src/setup/setup-steps.ts b/extension/src/setup/setup-steps.ts new file mode 100644 index 0000000..09dad61 --- /dev/null +++ b/extension/src/setup/setup-steps.ts @@ -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 { + 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 = ' '; } + 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' : '') + : ''; + } +} + +export 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 ` +
+

set up Relicario

+

How are you using Relicario on this device?

+
+ + +
+
+ +
+
`; + }, + 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 = ` +
    +
  1. Create a new private repository on your Gitea instance (e.g. vault)
  2. +
  3. Go to Settings → Applications
  4. +
  5. Generate a new token with repo (read/write) permission
  6. +
  7. Copy the token — you will need it in the next step
  8. +
`; + +const GITHUB_INSTRUCTIONS = ` +
    +
  1. Create a new private repository on GitHub (e.g. vault)
  2. +
  3. Go to Settings → Developer settings → Personal access tokens → Fine-grained tokens
  4. +
  5. Generate a new token scoped to the vault repo with Contents read/write permission
  6. +
  7. Copy the token — you will need it in the next step
  8. +
`; + +const hostStep: SetupStep = { + id: 'host', + render() { + return ` +
+

choose host

+
+ +
+ + +
+
+ ${state.hostType === 'gitea' ? GITEA_INSTRUCTIONS : GITHUB_INSTRUCTIONS} +
+ + +
+
`; + }, + 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: ${escapeHtml(probe.lastCommit.sha)} by ${escapeHtml(probe.lastCommit.author)} on ${escapeHtml(probe.lastCommit.date.slice(0, 10))}.` + : ''; + if (state.mode === 'new' && probe.exists) { + return ` + `; + } + if (state.mode === 'attach' && !probe.exists) { + return ` + `; + } + if (state.mode === 'attach' && probe.exists) { + return ` + `; + } + // mode = new, !exists + return ``; +} + +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 ` +
+

configure connection

+
+ + +
+
+ + +
+
+ + +
+
+ + ${state.connectionTested ? 'connected' : ''} +
+ ${renderProbeBanner()} +
+ + +
+
`; + }, + 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 ` +
+

attach this device

+

Use your existing passphrase and reference image to attach this browser to your vault. We'll verify both when you register this device.

+
+ +
+ + ${hasImage ? '

reference image loaded

' : '

click to select your reference JPEG

'} +
+

The reference image is the JPEG you saved when you first created this vault — not the original photo. It has the 256-bit secret embedded.

+
+
+ +
+ + +
+
+
+ + +
+
`; +} + +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 ?? ' '; + 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 ` +
+

create vault

+
+ +
+ + ${state.carrierImageBytes ? '

image loaded

' : '

click to select a JPEG photo

'} +
+

A 256-bit secret will be steganographically embedded in this image.

+
+
A long phrase of unrelated words is stronger than a short complex password. Your vault needs good (score ≥ 3) to continue.
+
+ +
+ + +
+ +
+

${labelText}

+

${escapeHtml(counterText)}

+
+

${escapeHtml(entropy || ' ')}

+
+
+ +
+ + ${matchGlyph} + +
+
+
+ + +
+
`; +} + +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 ` +
+

name this device

+

This helps you identify which devices have access to your vault.

+
+ + +
+
+ + +
+
`; + }, + 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 ? '' : ` +
+
+ + Generate a recovery QR before you go +
+

If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.

+
+ + +
+
`; + const refSection = isAttach ? '' : ` +
+ +

Download and store this image securely. It is your second factor for decryption. Without it, you cannot unlock the vault.

+ +
`; + return ` +
+
+

${isAttach ? 'device attached' : 'vault created'}

+

${isAttach ? 'This device is now attached to your vault.' : 'Your vault has been initialized and pushed to the repository.'}

+
+ ${qrBannerHtml} + ${refSection} +
+ +

+ Copy this JSON to configure Relicario on another setup, or save it for later. +

+
${escapeHtml(JSON.stringify(vaultConfig(), null, 2))}
+ +
+
+ +
+
`; + }, + 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((resolve) => { + chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve); + }); + const banner = document.getElementById('recovery-qr-banner'); + if (banner) { + banner.innerHTML = ` +
${svg}
+

◉ Recovery QR generated — save or print this now.

+
`; + 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 = [ + 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 { + 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. + } +} diff --git a/extension/src/setup/setup.ts b/extension/src/setup/setup.ts index 84c7cb4..5d04ace 100644 --- a/extension/src/setup/setup.ts +++ b/extension/src/setup/setup.ts @@ -1,75 +1,12 @@ -/// Vault initialization wizard — UI-only step registry. +/// Vault initialization wizard — thin shell (render loop + boot). /// -/// mode → host → connection → vault → device → done -/// -/// All crypto/remote/device orchestration lives in the service worker -/// (create_vault / attach_vault). This module is presentation + validation -/// only: it collects inputs, then fires a single SW message from the device -/// step (where the device name is known) and renders the result. +/// Step registry, state, and all step implementations live in ./setup-steps. +/// This module owns only: progress track, rerender/goto loop, and the +/// DOMContentLoaded boot. -import { createGitHost } from '../service-worker/git-host'; -import { probeVault } 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 (setup does not register a StateHost) --- - -function swSend(msg: Request): Promise { - return new Promise((resolve) => chrome.runtime.sendMessage(msg, (r: Response) => resolve(r))); -} - -// --- Step registry types --- - -type StepId = 'mode' | 'host' | 'connection' | 'vault' | 'device' | 'done'; - -interface StepContext { - state: WizardState; - rerender: () => void; - goto: (id: StepId) => void; -} - -interface SetupStep { - id: StepId; - render: (ctx: StepContext) => string; - attach: (root: HTMLElement, ctx: StepContext) => () => void; -} - -// --- State --- - -interface WizardState { - stepId: StepId; - mode: 'new' | 'attach' | null; - hostType: 'gitea' | 'github'; - hostUrl: string; - repoPath: string; - apiToken: string; - connectionTested: boolean; - vaultProbe: import('./probe').VaultProbe | null; - carrierImageBytes: Uint8Array | null; - referenceImageBytesAttach: Uint8Array | null; - passphrase: string; - passphraseConfirm: string; - // zxcvbn meter state — -1 means "not yet scored" (empty passphrase). - passphraseScore: number; - passphraseGuessesLog10: number; - passphraseVisible: boolean; - confirmVisible: boolean; - referenceImageBytes: Uint8Array | null; - creating: boolean; - attaching: boolean; - error: string | null; - deviceName: string; -} - -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: '', -}; +import { STEPS, state, clearWizardState, finishSetup } from './setup-steps'; +import type { StepId, StepContext } from './setup-steps'; +import { escapeHtml } from './setup-helpers'; // --- Progress track --- @@ -82,680 +19,7 @@ function renderProgressTrack(current: number): string { }).join('')}`; } -// --- State-coupled helpers (pure helpers live in ./setup-helpers.ts) --- - -/// Update just the meter DOM without a full re-render (so the input keeps focus and -/// the cursor position is preserved). Also updates the char counter and match indicator. -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 = ' '; } - 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 ` -
-

set up Relicario

-

How are you using Relicario on this device?

-
- - -
-
- -
-
`; - }, - 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 = ` -
    -
  1. Create a new private repository on your Gitea instance (e.g. vault)
  2. -
  3. Go to Settings → Applications
  4. -
  5. Generate a new token with repo (read/write) permission
  6. -
  7. Copy the token — you will need it in the next step
  8. -
`; - -const GITHUB_INSTRUCTIONS = ` -
    -
  1. Create a new private repository on GitHub (e.g. vault)
  2. -
  3. Go to Settings → Developer settings → Personal access tokens → Fine-grained tokens
  4. -
  5. Generate a new token scoped to the vault repo with Contents read/write permission
  6. -
  7. Copy the token — you will need it in the next step
  8. -
`; - -const hostStep: SetupStep = { - id: 'host', - render() { - return ` -
-

choose host

-
- -
- - -
-
- ${state.hostType === 'gitea' ? GITEA_INSTRUCTIONS : GITHUB_INSTRUCTIONS} -
- - -
-
`; - }, - 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: ${escapeHtml(probe.lastCommit.sha)} by ${escapeHtml(probe.lastCommit.author)} on ${escapeHtml(probe.lastCommit.date.slice(0, 10))}.` - : ''; - if (state.mode === 'new' && probe.exists) { - return ` - `; - } - if (state.mode === 'attach' && !probe.exists) { - return ` - `; - } - if (state.mode === 'attach' && probe.exists) { - return ` - `; - } - // mode = new, !exists - return ``; -} - -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 ` -
-

configure connection

-
- - -
-
- - -
-
- - -
-
- - ${state.connectionTested ? 'connected' : ''} -
- ${renderProbeBanner()} -
- - -
-
`; - }, - 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 ` -
-

attach this device

-

Use your existing passphrase and reference image to attach this browser to your vault. We'll verify both when you register this device.

-
- -
- - ${hasImage ? '

reference image loaded

' : '

click to select your reference JPEG

'} -
-

The reference image is the JPEG you saved when you first created this vault — not the original photo. It has the 256-bit secret embedded.

-
-
- -
- - -
-
-
- - -
-
`; -} - -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 ?? ' '; - 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 ` -
-

create vault

-
- -
- - ${state.carrierImageBytes ? '

image loaded

' : '

click to select a JPEG photo

'} -
-

A 256-bit secret will be steganographically embedded in this image.

-
-
A long phrase of unrelated words is stronger than a short complex password. Your vault needs good (score ≥ 3) to continue.
-
- -
- - -
- -
-

${labelText}

-

${escapeHtml(counterText)}

-
-

${escapeHtml(entropy || ' ')}

-
-
- -
- - ${matchGlyph} - -
-
-
- - -
-
`; -} - -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 ` -
-

name this device

-

This helps you identify which devices have access to your vault.

-
- - -
-
- - -
-
`; - }, - 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 ? '' : ` -
-
- - Generate a recovery QR before you go -
-

If you lose your reference image, this QR lets you recover your vault. Print it and store it safely.

-
- - -
-
`; - const refSection = isAttach ? '' : ` -
- -

Download and store this image securely. It is your second factor for decryption. Without it, you cannot unlock the vault.

- -
`; - return ` -
-
-

${isAttach ? 'device attached' : 'vault created'}

-

${isAttach ? 'This device is now attached to your vault.' : 'Your vault has been initialized and pushed to the repository.'}

-
- ${qrBannerHtml} - ${refSection} -
- -
-
`; - }, - 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 { - // The SW uses its current session (set by create_vault) — no handle passed. - 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((resolve) => { - chrome.storage.local.set({ recovery_qr_generated_at: Date.now() }, resolve); - }); - const banner = document.getElementById('recovery-qr-banner'); - if (banner) { - banner.innerHTML = ` -
${svg}
-

◉ Recovery QR generated — save or print this now.

-
`; - 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('open-vault-btn')?.addEventListener('click', () => void finishSetup()); - return () => {}; - }, -}; - -// --- Registry + render loop --- - -const STEPS: ReadonlyArray = [ - modeStep, hostStep, connectionStep, vaultStep, deviceStep, doneStep, -]; +// --- Render loop --- let teardown: (() => void) | null = null; @@ -783,52 +47,6 @@ function goto(id: StepId): void { rerender(); } -// --- 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 { - 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. - } -} - // --- Boot --- document.addEventListener('DOMContentLoaded', () => { @@ -836,4 +54,5 @@ document.addEventListener('DOMContentLoaded', () => { rerender(); }); -export { STEPS }; +// Re-exports so existing test and bundle imports resolve unchanged. +export { STEPS, clearWizardState, finishSetup } from './setup-steps';