Files
relicario/docs/superpowers/plans/2026-04-12-idfoto-init-wizard.md
adlee-was-taken 0c800bcd4f docs: add vault initialization wizard implementation plan
6 tasks: WASM embed function, setup HTML, wizard TypeScript,
webpack/manifest updates, and build integration.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 10:52:51 -04:00

30 KiB

idfoto Vault Initialization Wizard Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Build a browser-based wizard that creates a new idfoto vault, pushes it to Gitea/GitHub via API, downloads the reference image, and optionally configures the Chrome extension.

Architecture: Single HTML page (extension/setup.html) bundled by webpack as a new entry point. Reuses the existing git API layer and WASM module. New embed_image_secret function added to the WASM crate. The wizard runs entirely client-side — all crypto happens in the browser via WASM.

Tech Stack: TypeScript, wasm-bindgen (existing WASM crate), webpack, Chrome extension APIs

Spec: docs/superpowers/specs/2026-04-12-idfoto-init-wizard-design.md


File Structure

Rust (modified)

crates/idfoto-wasm/src/lib.rs    # Add embed_image_secret function

Extension (new)

extension/
├── setup.html                    # Standalone wizard page
└── src/
    └── setup/
        └── setup.ts              # 4-step wizard logic

Extension (modified)

extension/webpack.config.js       # Add 'setup' entry point + copy setup.html
extension/manifest.json           # Add web_accessible_resources for setup.html

Task 1: Add embed_image_secret to WASM Crate

Files:

  • Modify: crates/idfoto-wasm/src/lib.rs

  • Step 1: Write the test

Add to the #[cfg(test)] mod tests block in crates/idfoto-wasm/src/lib.rs:

#[test]
fn embed_then_extract_round_trip() {
    // Create a synthetic test JPEG (same approach as idfoto-core tests)
    use image::codecs::jpeg::JpegEncoder;
    use image::{ImageBuffer, ImageEncoder, Rgb};

    let img = ImageBuffer::from_fn(400, 300, |x, y| {
        Rgb([
            ((x * 7 + y * 13) % 256) as u8,
            ((x * 11 + y * 3) % 256) as u8,
            ((x * 5 + y * 17) % 256) as u8,
        ])
    });
    let mut jpeg_buf = Vec::new();
    let encoder = JpegEncoder::new_with_quality(&mut jpeg_buf, 92);
    encoder
        .write_image(img.as_raw(), 400, 300, image::ExtendedColorType::Rgb8)
        .unwrap();

    let secret = [0xDE, 0xAD, 0xBE, 0xEF, 0x01, 0x02, 0x03, 0x04,
                  0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0x0C,
                  0x0D, 0x0E, 0x0F, 0x10, 0x11, 0x12, 0x13, 0x14,
                  0x15, 0x16, 0x17, 0x18, 0x19, 0x1A, 0x1B, 0x1Cu8];

    let stego = embed_image_secret(&jpeg_buf, &secret).unwrap();
    let extracted = extract_image_secret(&stego).unwrap();
    assert_eq!(extracted, secret);
}
  • Step 2: Run test to verify it fails

Run: cargo test -p idfoto-wasm embed_then_extract Expected: FAIL — embed_image_secret not defined.

  • Step 3: Add image dev-dependency to Cargo.toml

Add to crates/idfoto-wasm/Cargo.toml under [dev-dependencies]:

[dev-dependencies]
wasm-bindgen-test = "0.3"
image = { version = "0.25", default-features = false, features = ["jpeg"] }
  • Step 4: Implement the function

Add to crates/idfoto-wasm/src/lib.rs, after the extract_image_secret function:

/// Embed a 256-bit secret into a carrier JPEG image.
///
/// Takes the raw bytes of a JPEG image and a 32-byte secret, returns the
/// modified JPEG with the secret embedded via DCT steganography.
///
/// The returned JPEG should be saved as the "reference image" — the user
/// needs it alongside their passphrase to unlock the vault.
#[wasm_bindgen]
pub fn embed_image_secret(carrier_jpeg: &[u8], secret: &[u8]) -> Result<Vec<u8>, JsValue> {
    let secret: [u8; 32] = secret
        .try_into()
        .map_err(|_| JsValue::from_str("secret must be exactly 32 bytes"))?;
    idfoto_core::imgsecret::embed(carrier_jpeg, &secret)
        .map_err(|e| JsValue::from_str(&e.to_string()))
}
  • Step 5: Run test to verify it passes

Run: cargo test -p idfoto-wasm embed_then_extract Expected: PASS

  • Step 6: Rebuild WASM

Run: wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm Expected: Builds successfully.

  • Step 7: Commit
git add crates/idfoto-wasm/src/lib.rs crates/idfoto-wasm/Cargo.toml
git commit -m "feat: add embed_image_secret to WASM crate"

Task 2: Add WASM Type Declaration for New Function

Files:

  • Modify: extension/src/wasm.d.ts

  • Step 1: Add the type declaration

Add to extension/src/wasm.d.ts alongside the existing declarations:

export function embed_image_secret(carrier_jpeg: Uint8Array, secret: Uint8Array): Uint8Array;
  • Step 2: Commit
git add extension/src/wasm.d.ts
git commit -m "feat: add embed_image_secret type declaration"

Task 3: Create Setup Page HTML

Files:

  • Create: extension/setup.html

  • Step 1: Create the HTML file

Create extension/setup.html:

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>idfoto — vault setup</title>
  <link rel="stylesheet" href="styles.css">
  <style>
    /* Override popup constraints for full-page layout */
    body {
      width: auto;
      min-height: 100vh;
      max-height: none;
      overflow-y: auto;
      display: flex;
      justify-content: center;
      padding: 40px 20px;
    }
    #app {
      max-width: 560px;
      width: 100%;
    }
    .step-instructions {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 4px;
      padding: 16px;
      margin: 12px 0;
      font-size: 12px;
      line-height: 1.8;
    }
    .step-instructions ol {
      padding-left: 20px;
    }
    .step-instructions li {
      margin-bottom: 4px;
    }
    .step-instructions code {
      background: #0d1117;
      padding: 1px 4px;
      border-radius: 2px;
      color: #58a6ff;
    }
    .image-preview {
      max-width: 200px;
      max-height: 150px;
      border: 1px solid #30363d;
      border-radius: 2px;
      margin-top: 8px;
    }
    .strength-bar {
      height: 3px;
      background: #21262d;
      margin-top: 4px;
      border-radius: 2px;
    }
    .strength-bar-fill {
      height: 3px;
      border-radius: 2px;
      transition: width 0.3s, background 0.3s;
    }
    .success-box {
      background: #0d1117;
      border: 1px solid #3fb950;
      border-radius: 4px;
      padding: 16px;
      margin: 12px 0;
    }
    .config-blob {
      background: #161b22;
      border: 1px solid #30363d;
      border-radius: 4px;
      padding: 12px;
      font-size: 11px;
      word-break: break-all;
      margin-top: 8px;
      cursor: pointer;
    }
    .config-blob:hover {
      border-color: #58a6ff;
    }
    .test-result {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      margin-top: 6px;
      font-size: 11px;
    }
    .test-ok { color: #3fb950; }
    .test-fail { color: #f85149; }
  </style>
</head>
<body>
  <div id="app"></div>
  <script src="setup.js"></script>
</body>
</html>
  • Step 2: Commit
git add extension/setup.html
git commit -m "feat: add setup wizard HTML page"

Task 4: Create Setup Wizard TypeScript

Files:

  • Create: extension/src/setup/setup.ts

This is the main task. The wizard is a 4-step state machine that reuses the existing git API layer and WASM module.

  • Step 1: Create the setup wizard

Create extension/src/setup/setup.ts:

/// Standalone vault initialization wizard.
///
/// 4-step flow:
/// 1. Choose git host (Gitea/GitHub) with setup instructions
/// 2. Configure connection (URL, repo, token) with test button
/// 3. Create vault (carrier image, passphrase, generate + push)
/// 4. Finish (download reference image, push config to extension)

import { createGitHost, uint8ArrayToBase64, base64ToUint8Array } from '../service-worker/git-host';
import type { GitHost } from '../service-worker/git-host';
import type { VaultConfig } from '../shared/types';

// --- State ---

interface WizardState {
  step: number;
  hostType: 'gitea' | 'github';
  hostUrl: string;
  repoPath: string;
  apiToken: string;
  connectionTested: boolean;
  carrierImageBytes: Uint8Array | null;
  carrierImageName: string;
  passphrase: string;
  passphraseConfirm: string;
  referenceImageBytes: Uint8Array | null;
  creating: boolean;
  error: string;
  extensionDetected: boolean;
  configPushed: boolean;
}

let state: WizardState = {
  step: 1,
  hostType: 'gitea',
  hostUrl: '',
  repoPath: '',
  apiToken: '',
  connectionTested: false,
  carrierImageBytes: null,
  carrierImageName: '',
  passphrase: '',
  passphraseConfirm: '',
  referenceImageBytes: null,
  creating: false,
  error: '',
  extensionDetected: false,
  configPushed: false,
};

// --- WASM ---

type WasmModule = typeof import('idfoto-wasm');
let wasm: WasmModule | null = null;

async function initWasm(): Promise<WasmModule> {
  if (wasm) return wasm;
  const mod = await import(/* webpackIgnore: true */ '../idfoto_wasm.js');
  await mod.default();
  wasm = mod;
  return mod;
}

// --- Helpers ---

function escapeHtml(s: string): string {
  const div = document.createElement('div');
  div.textContent = s;
  return div.innerHTML;
}

function passwordStrength(pw: string): { label: string; color: string; pct: number } {
  if (pw.length < 8) return { label: 'too short', color: '#f85149', pct: 10 };
  let score = 0;
  if (pw.length >= 12) score++;
  if (pw.length >= 16) score++;
  if (/[a-z]/.test(pw) && /[A-Z]/.test(pw)) score++;
  if (/[0-9]/.test(pw)) score++;
  if (/[^a-zA-Z0-9]/.test(pw)) score++;
  if (score <= 1) return { label: 'weak', color: '#f85149', pct: 30 };
  if (score <= 3) return { label: 'ok', color: '#d29922', pct: 60 };
  return { label: 'strong', color: '#3fb950', pct: 100 };
}

// --- Render ---

function render(): void {
  const app = document.getElementById('app')!;
  const stepNames = ['git host', 'connection', 'create vault', 'done'];

  let html = `
    <div class="brand" style="font-size:18px;margin-bottom:4px">idfoto setup</div>
    <div class="wizard-step">step ${state.step} of 4 — ${stepNames[state.step - 1]}</div>
    <div class="progress-bar"><div class="progress-bar-fill" style="width:${(state.step / 4) * 100}%"></div></div>
  `;

  if (state.error) {
    html += `<div class="error" style="margin-bottom:12px">${escapeHtml(state.error)}</div>`;
  }

  switch (state.step) {
    case 1: html += renderStep1(); break;
    case 2: html += renderStep2(); break;
    case 3: html += renderStep3(); break;
    case 4: html += renderStep4(); break;
  }

  app.innerHTML = html;
  attachListeners();
}

// --- Step 1: Choose Git Host ---

function renderStep1(): string {
  return `
    <div class="form-group" style="margin-top:16px">
      <div class="label">HOST TYPE</div>
      <div class="host-toggle" style="display:flex;gap:8px;margin-top:4px">
        <button class="group-tab ${state.hostType === 'gitea' ? 'active' : ''}" data-action="host" data-host="gitea">gitea</button>
        <button class="group-tab ${state.hostType === 'github' ? 'active' : ''}" data-action="host" data-host="github">github</button>
      </div>
    </div>

    <div class="step-instructions">
      ${state.hostType === 'gitea' ? `
        <div class="label" style="margin-bottom:8px">GITEA SETUP</div>
        <ol>
          <li>Log in to your Gitea instance</li>
          <li>Click <code>+</code> → <code>New Repository</code></li>
          <li>Name it (e.g. <code>idfoto-vault</code>), leave it <strong>empty</strong> — no README, no .gitignore</li>
          <li>Go to <code>Settings</code> → <code>Applications</code> → <code>Manage Access Tokens</code></li>
          <li>Generate a new token with <code>repo</code> scope (read/write)</li>
          <li>Copy the token — you'll need it in the next step</li>
        </ol>
      ` : `
        <div class="label" style="margin-bottom:8px">GITHUB SETUP</div>
        <ol>
          <li>Go to <strong>github.com</strong> → <code>New Repository</code></li>
          <li>Name it (e.g. <code>idfoto-vault</code>), set to <strong>Private</strong>, leave it <strong>empty</strong> — no README, no .gitignore, no license</li>
          <li>Go to <code>Settings</code> → <code>Developer Settings</code> → <code>Personal Access Tokens</code> → <code>Fine-grained tokens</code></li>
          <li>Click <code>Generate new token</code></li>
          <li>Select <strong>only</strong> the vault repository under "Repository access"</li>
          <li>Under Permissions → Repository → <code>Contents</code>: set to <strong>Read and write</strong></li>
          <li>Generate and copy the token</li>
        </ol>
      `}
    </div>

    <div class="actions">
      <button class="btn btn-primary" data-action="next">Next →</button>
    </div>
  `;
}

// --- Step 2: Configure Connection ---

function renderStep2(): string {
  const defaultUrl = state.hostType === 'github' ? 'https://github.com' : '';

  return `
    <div class="form-group" style="margin-top:16px">
      <div class="label">HOST URL</div>
      <input type="text" id="host-url" value="${escapeHtml(state.hostUrl || defaultUrl)}" placeholder="${state.hostType === 'gitea' ? 'https://git.example.com' : 'https://github.com'}">
    </div>
    <div class="form-group">
      <div class="label">REPO PATH</div>
      <input type="text" id="repo-path" value="${escapeHtml(state.repoPath)}" placeholder="owner/repo-name">
    </div>
    <div class="form-group">
      <div class="label">API TOKEN</div>
      <input type="password" id="api-token" value="${escapeHtml(state.apiToken)}" placeholder="Paste your token">
    </div>
    <div id="test-result"></div>
    <div class="actions">
      <button class="btn" data-action="back">← Back</button>
      <button class="btn" data-action="test-connection">Test Connection</button>
      <button class="btn btn-primary" data-action="next" ${!state.connectionTested ? 'disabled' : ''}>Next →</button>
    </div>
  `;
}

// --- Step 3: Create Vault ---

function renderStep3(): string {
  const strength = state.passphrase ? passwordStrength(state.passphrase) : null;
  const mismatch = state.passphraseConfirm && state.passphrase !== state.passphraseConfirm;

  return `
    <div class="form-group" style="margin-top:16px">
      <div class="label">CARRIER IMAGE</div>
      <p class="secondary" style="font-size:11px;margin-bottom:8px">
        Pick any JPEG photo. A phone photo works great — at least 400x300 pixels.
      </p>
      <input type="file" id="carrier-image" accept="image/jpeg" style="font-size:11px">
      ${state.carrierImageName ? `<div class="secondary" style="margin-top:4px;font-size:11px">✓ ${escapeHtml(state.carrierImageName)}</div>` : ''}
    </div>
    <div class="form-group">
      <div class="label">PASSPHRASE</div>
      <input type="password" id="passphrase" value="${escapeHtml(state.passphrase)}" placeholder="Min 8 characters">
      ${strength ? `
        <div class="strength-bar"><div class="strength-bar-fill" style="width:${strength.pct}%;background:${strength.color}"></div></div>
        <div style="font-size:10px;color:${strength.color};margin-top:2px">${strength.label}</div>
      ` : ''}
    </div>
    <div class="form-group">
      <div class="label">CONFIRM PASSPHRASE</div>
      <input type="password" id="passphrase-confirm" value="${escapeHtml(state.passphraseConfirm)}" placeholder="Type it again">
      ${mismatch ? '<div class="error" style="margin-top:2px">Passphrases do not match</div>' : ''}
    </div>
    <div class="actions">
      <button class="btn" data-action="back">← Back</button>
      <button class="btn btn-primary" data-action="create-vault" ${state.creating ? 'disabled' : ''}>
        ${state.creating ? '<span class="spinner"></span> Creating...' : 'Create Vault'}
      </button>
    </div>
  `;
}

// --- Step 4: Finish ---

function renderStep4(): string {
  return `
    <div class="success-box" style="margin-top:16px">
      <div style="color:#3fb950;font-size:14px;margin-bottom:8px">✓ Vault created</div>
      <p class="secondary" style="font-size:12px">
        Your vault has been pushed to <strong>${escapeHtml(state.repoPath)}</strong>.
      </p>
    </div>

    <div class="form-group">
      <div class="label">REFERENCE IMAGE</div>
      <p class="secondary" style="font-size:11px;margin-bottom:8px">
        Download this image and keep it safe. You need it alongside your passphrase to unlock the vault.
        Store it somewhere you won't lose it — a USB drive, a safe, your phone's photo library.
      </p>
      <button class="btn btn-primary" data-action="download-image">Download reference.jpg</button>
    </div>

    ${state.extensionDetected ? `
      <div class="form-group" style="margin-top:16px">
        <div class="label">EXTENSION</div>
        ${state.configPushed ? `
          <p class="secondary" style="font-size:11px;color:#3fb950">
            ✓ Extension configured. Open the extension popup and enter your passphrase to unlock.
          </p>
        ` : `
          <p class="secondary" style="font-size:11px;margin-bottom:8px">
            idfoto extension detected. Push your vault config to it?
          </p>
          <button class="btn" data-action="push-to-extension">Configure Extension</button>
        `}
      </div>
    ` : `
      <div class="form-group" style="margin-top:16px">
        <div class="label">EXTENSION SETUP</div>
        <p class="secondary" style="font-size:11px;margin-bottom:8px">
          Install the idfoto extension, then enter these details in the setup wizard:
        </p>
        <div class="config-blob" data-action="copy-config" title="Click to copy">
          ${escapeHtml(JSON.stringify({
            hostType: state.hostType,
            hostUrl: state.hostUrl,
            repoPath: state.repoPath,
            apiToken: state.apiToken,
          }, null, 2))}
        </div>
        <div class="secondary" style="font-size:10px;margin-top:4px">Click to copy</div>
      </div>
    `}
  `;
}

// --- Event Listeners ---

function attachListeners(): void {
  // Host type toggle
  document.querySelectorAll('[data-action="host"]').forEach(btn => {
    btn.addEventListener('click', () => {
      state.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github';
      state.connectionTested = false;
      render();
    });
  });

  // Navigation
  document.querySelectorAll('[data-action="next"]').forEach(btn => {
    btn.addEventListener('click', () => {
      readInputs();
      state.error = '';
      state.step++;
      render();
    });
  });

  document.querySelectorAll('[data-action="back"]').forEach(btn => {
    btn.addEventListener('click', () => {
      readInputs();
      state.error = '';
      state.step--;
      render();
    });
  });

  // Test connection
  document.querySelector('[data-action="test-connection"]')?.addEventListener('click', async () => {
    readInputs();
    state.error = '';

    if (!state.hostUrl || !state.repoPath || !state.apiToken) {
      state.error = 'All fields are required';
      render();
      return;
    }

    const resultEl = document.getElementById('test-result')!;
    resultEl.innerHTML = '<div class="test-result"><span class="spinner"></span> Testing...</div>';

    try {
      const git = createGitHost(state.hostType, state.hostUrl, state.repoPath, state.apiToken);
      // Try to list the root directory — if the repo exists and token works, this succeeds
      await git.listDir('');
      state.connectionTested = true;
      resultEl.innerHTML = '<div class="test-result test-ok">✓ Connected</div>';
      // Re-render to enable Next button
      const nextBtn = document.querySelector('[data-action="next"]') as HTMLButtonElement;
      if (nextBtn) nextBtn.disabled = false;
    } catch (err) {
      state.connectionTested = false;
      resultEl.innerHTML = `<div class="test-result test-fail">✗ ${escapeHtml(String(err))}</div>`;
    }
  });

  // Carrier image file picker
  document.getElementById('carrier-image')?.addEventListener('change', (e) => {
    const input = e.target as HTMLInputElement;
    const file = input.files?.[0];
    if (!file) return;

    const reader = new FileReader();
    reader.onload = () => {
      const arrayBuf = reader.result as ArrayBuffer;
      state.carrierImageBytes = new Uint8Array(arrayBuf);
      state.carrierImageName = file.name;
      render();
    };
    reader.readAsArrayBuffer(file);
  });

  // Passphrase inputs (read on change, re-render for strength indicator)
  document.getElementById('passphrase')?.addEventListener('input', (e) => {
    state.passphrase = (e.target as HTMLInputElement).value;
    // Only re-render the strength indicator, not the whole page (avoids losing focus)
    const strength = passwordStrength(state.passphrase);
    const bar = document.querySelector('.strength-bar-fill') as HTMLElement;
    if (bar) {
      bar.style.width = `${strength.pct}%`;
      bar.style.background = strength.color;
    }
    const label = bar?.parentElement?.nextElementSibling as HTMLElement;
    if (label) {
      label.textContent = strength.label;
      label.style.color = strength.color;
    }
  });

  document.getElementById('passphrase-confirm')?.addEventListener('input', (e) => {
    state.passphraseConfirm = (e.target as HTMLInputElement).value;
  });

  // Create vault
  document.querySelector('[data-action="create-vault"]')?.addEventListener('click', async () => {
    readInputs();
    state.error = '';

    // Validation
    if (!state.carrierImageBytes) {
      state.error = 'Select a carrier image';
      render();
      return;
    }
    if (state.passphrase.length < 8) {
      state.error = 'Passphrase must be at least 8 characters';
      render();
      return;
    }
    if (state.passphrase !== state.passphraseConfirm) {
      state.error = 'Passphrases do not match';
      render();
      return;
    }

    state.creating = true;
    render();

    try {
      await createVault();
      state.creating = false;

      // Detect extension
      state.extensionDetected = await detectExtension();

      state.step = 4;
      render();
    } catch (err) {
      state.creating = false;
      state.error = `Vault creation failed: ${String(err)}`;
      render();
    }
  });

  // Download reference image
  document.querySelector('[data-action="download-image"]')?.addEventListener('click', () => {
    if (!state.referenceImageBytes) return;
    const blob = new Blob([state.referenceImageBytes], { type: 'image/jpeg' });
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = 'reference.jpg';
    a.click();
    URL.revokeObjectURL(url);
  });

  // Push config to extension
  document.querySelector('[data-action="push-to-extension"]')?.addEventListener('click', async () => {
    try {
      const config: VaultConfig = {
        hostType: state.hostType,
        hostUrl: state.hostUrl,
        repoPath: state.repoPath,
        apiToken: state.apiToken,
      };
      const imageBase64 = uint8ArrayToBase64(state.referenceImageBytes!);

      chrome.runtime.sendMessage(
        { type: 'save_setup', config, imageBase64 },
        (response) => {
          if (response?.ok) {
            state.configPushed = true;
            render();
          }
        }
      );
    } catch {
      state.error = 'Failed to push config to extension';
      render();
    }
  });

  // Copy config blob
  document.querySelector('[data-action="copy-config"]')?.addEventListener('click', async () => {
    const config = JSON.stringify({
      hostType: state.hostType,
      hostUrl: state.hostUrl,
      repoPath: state.repoPath,
      apiToken: state.apiToken,
    }, null, 2);
    await navigator.clipboard.writeText(config);
    const el = document.querySelector('[data-action="copy-config"]')!;
    el.classList.add('test-ok');
    setTimeout(() => el.classList.remove('test-ok'), 1500);
  });
}

// --- Read form inputs into state ---

function readInputs(): void {
  const hostUrl = document.getElementById('host-url') as HTMLInputElement;
  const repoPath = document.getElementById('repo-path') as HTMLInputElement;
  const apiToken = document.getElementById('api-token') as HTMLInputElement;
  const passphrase = document.getElementById('passphrase') as HTMLInputElement;
  const passphraseConfirm = document.getElementById('passphrase-confirm') as HTMLInputElement;

  if (hostUrl) state.hostUrl = hostUrl.value.trim();
  if (repoPath) state.repoPath = repoPath.value.trim();
  if (apiToken) state.apiToken = apiToken.value.trim();
  if (passphrase) state.passphrase = passphrase.value;
  if (passphraseConfirm) state.passphraseConfirm = passphraseConfirm.value;
}

// --- Vault Creation ---

async function createVault(): Promise<void> {
  const w = await initWasm();
  const git = createGitHost(state.hostType, state.hostUrl, state.repoPath, state.apiToken);

  // 1. Generate random 32-byte image_secret
  const imageSecret = new Uint8Array(32);
  crypto.getRandomValues(imageSecret);

  // 2. Embed secret into carrier JPEG
  const referenceJpeg = w.embed_image_secret(state.carrierImageBytes!, imageSecret);
  state.referenceImageBytes = new Uint8Array(referenceJpeg);

  // 3. Generate random 32-byte salt
  const salt = new Uint8Array(32);
  crypto.getRandomValues(salt);

  // 4. Create KDF params
  const params = { argon2_m: 65536, argon2_t: 3, argon2_p: 4 };
  const paramsJson = JSON.stringify(params);

  // 5. Derive master_key
  const masterKey = w.derive_master_key(state.passphrase, imageSecret, salt, paramsJson);

  // 6. Encrypt empty manifest
  const emptyManifest = JSON.stringify({ entries: {}, version: 1 });
  const manifestEnc = w.encrypt_manifest(emptyManifest, masterKey);

  // 7. Push vault files to repo
  await git.writeFile('.idfoto/salt', salt, 'feat: initialize idfoto vault');
  await git.writeFile('.idfoto/params.json', new TextEncoder().encode(paramsJson), 'chore: add KDF params');
  await git.writeFile('.idfoto/devices.json', new TextEncoder().encode('[]'), 'chore: add empty devices list');
  await git.writeFile('manifest.enc', new Uint8Array(manifestEnc), 'feat: add encrypted manifest');
}

// --- Extension Detection ---

function detectExtension(): Promise<boolean> {
  return new Promise((resolve) => {
    try {
      if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) {
        resolve(false);
        return;
      }
      chrome.runtime.sendMessage(
        { type: 'get_setup_state' },
        (response) => {
          if (chrome.runtime.lastError || !response) {
            resolve(false);
          } else {
            resolve(true);
          }
        }
      );
    } catch {
      resolve(false);
    }
  });
}

// --- Init ---

document.addEventListener('DOMContentLoaded', render);
  • Step 2: Commit
git add extension/src/setup/setup.ts
git commit -m "feat: add vault initialization wizard"

Task 5: Update Webpack and Manifest

Files:

  • Modify: extension/webpack.config.js

  • Modify: extension/manifest.json

  • Step 1: Add setup entry point to webpack

In extension/webpack.config.js, add setup to the entry object:

entry: {
    'service-worker': './src/service-worker/index.ts',
    popup: './src/popup/popup.ts',
    content: './src/content/detector.ts',
    setup: './src/setup/setup.ts',
},

Add setup.html to the CopyPlugin patterns array:

{ from: 'setup.html', to: '.' },
  • Step 2: Add web_accessible_resources to manifest.json

Add to extension/manifest.json, after the content_security_policy block:

"web_accessible_resources": [{
  "resources": ["setup.html", "setup.js", "styles.css", "idfoto_wasm_bg.wasm", "idfoto_wasm.js"],
  "matches": ["<all_urls>"]
}]
  • Step 3: Build and verify
cd extension && bun run build

Expected: Builds successfully with dist/setup.js and dist/setup.html in the output.

  • Step 4: Commit
git add extension/webpack.config.js extension/manifest.json
git commit -m "feat: add setup wizard to webpack build and extension manifest"

Task 6: Build and Manual Test

Files: None (integration testing)

  • Step 1: Rebuild WASM
wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm
  • Step 2: Rebuild extension
cd extension && bun run build
  • Step 3: Run Rust tests
cargo test

Expected: All tests pass (including the new embed_then_extract_round_trip).

  • Step 4: Load in Chrome and test
  1. Open chrome://extensions/, reload the unpacked extension from extension/dist/
  2. Open chrome-extension://<extension-id>/setup.html
  3. Verify:
    • Step 1: host toggle switches between Gitea/GitHub instructions
    • Step 2: enter real host/token/repo, test connection works
    • Step 3: pick a JPEG, enter passphrase, create vault pushes files
    • Step 4: download reference image works, extension detection works
  4. Verify the vault repo now has .idfoto/salt, .idfoto/params.json, .idfoto/devices.json, manifest.enc
  5. Open extension popup, unlock with passphrase — should work with the just-created vault
  • Step 5: Fix any issues found

  • Step 6: Final commit

git add -A
git commit -m "feat: complete vault initialization wizard"

Task Summary

Task Description Dependencies
1 Add embed_image_secret to WASM crate None
2 Add WASM type declaration Task 1
3 Create setup page HTML None
4 Create setup wizard TypeScript Task 2, 3
5 Update webpack and manifest Task 3, 4
6 Build and manual test All

Tasks 1 and 3 can run in parallel. Tasks 2 and 4 are sequential after 1. Task 5 depends on 3+4. Task 6 is final integration.