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>
This commit is contained in:
955
docs/superpowers/plans/2026-04-12-idfoto-init-wizard.md
Normal file
955
docs/superpowers/plans/2026-04-12-idfoto-init-wizard.md
Normal file
@@ -0,0 +1,955 @@
|
||||
# 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`:
|
||||
|
||||
```rust
|
||||
#[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]`:
|
||||
|
||||
```toml
|
||||
[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:
|
||||
|
||||
```rust
|
||||
/// 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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```typescript
|
||||
export function embed_image_secret(carrier_jpeg: Uint8Array, secret: Uint8Array): Uint8Array;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```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**
|
||||
|
||||
```bash
|
||||
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`:
|
||||
|
||||
```typescript
|
||||
/// 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**
|
||||
|
||||
```bash
|
||||
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:
|
||||
|
||||
```javascript
|
||||
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:
|
||||
|
||||
```javascript
|
||||
{ from: 'setup.html', to: '.' },
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Add web_accessible_resources to manifest.json**
|
||||
|
||||
Add to `extension/manifest.json`, after the `content_security_policy` block:
|
||||
|
||||
```json
|
||||
"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**
|
||||
|
||||
```bash
|
||||
cd extension && bun run build
|
||||
```
|
||||
|
||||
Expected: Builds successfully with `dist/setup.js` and `dist/setup.html` in the output.
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Rebuild extension**
|
||||
|
||||
```bash
|
||||
cd extension && bun run build
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Run Rust tests**
|
||||
|
||||
```bash
|
||||
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**
|
||||
|
||||
```bash
|
||||
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.
|
||||
Reference in New Issue
Block a user