diff --git a/docs/superpowers/plans/2026-04-12-idfoto-wasm-extension.md b/docs/superpowers/plans/2026-04-12-idfoto-wasm-extension.md new file mode 100644 index 0000000..5ffdfac --- /dev/null +++ b/docs/superpowers/plans/2026-04-12-idfoto-wasm-extension.md @@ -0,0 +1,3179 @@ +# idfoto WASM + Chrome MV3 Extension 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:** Compile `idfoto-core` to WASM and wrap it in a Chrome MV3 browser extension with a terminal-aesthetic popup, conservative autofill, and direct Gitea/GitHub API access. + +**Architecture:** Monolith service worker loads the WASM module and holds all state (master_key, cached manifest). Popup and content script are thin UI layers communicating via `chrome.runtime.sendMessage`. Vault data is fetched/committed directly via Gitea/GitHub REST APIs — no local clone, no CLI dependency. + +**Tech Stack:** Rust + wasm-bindgen (WASM crate), TypeScript + webpack (extension), Chrome MV3 APIs + +**Spec:** `docs/superpowers/specs/2026-04-12-idfoto-wasm-extension-design.md` + +--- + +## File Structure + +### Rust (new crate) + +``` +crates/idfoto-wasm/ +├── Cargo.toml +└── src/ + └── lib.rs # wasm-bindgen wrappers + TOTP implementation +``` + +### Rust (modified) + +``` +crates/idfoto-core/src/entry.rs # Add group field to Entry and ManifestEntry +Cargo.toml # Add idfoto-wasm to workspace members +``` + +### Extension (all new) + +``` +extension/ +├── manifest.json +├── package.json +├── tsconfig.json +├── webpack.config.js +├── src/ +│ ├── service-worker/ +│ │ ├── index.ts # WASM init, message router, state +│ │ ├── vault.ts # vault CRUD operations +│ │ ├── git-host.ts # GitHost interface + factory +│ │ ├── gitea.ts # Gitea API implementation +│ │ └── github.ts # GitHub API implementation +│ ├── popup/ +│ │ ├── index.html # popup HTML shell +│ │ ├── popup.ts # state machine: setup → locked → list → detail → edit +│ │ ├── styles.css # terminal dark theme +│ │ └── components/ +│ │ ├── unlock.ts # passphrase prompt +│ │ ├── entry-list.ts # search + group filter + entry rows +│ │ ├── entry-detail.ts # field display + TOTP countdown +│ │ ├── entry-form.ts # add/edit form +│ │ └── setup-wizard.ts # 3-step setup flow +│ ├── content/ +│ │ ├── detector.ts # login form detection +│ │ ├── fill.ts # credential injection +│ │ └── icon.ts # field icon injection + inline picker +│ └── shared/ +│ ├── messages.ts # typed message definitions +│ └── types.ts # Entry, ManifestEntry, VaultConfig types +└── icons/ + ├── icon-16.png + ├── icon-48.png + └── icon-128.png +``` + +--- + +## Task 1: Add `group` Field to Core Data Model + +**Files:** +- Modify: `crates/idfoto-core/src/entry.rs` +- Modify: `crates/idfoto-core/src/vault.rs` (test helpers) +- Modify: `crates/idfoto-cli/src/main.rs` (Entry construction sites) +- Test: `crates/idfoto-core/src/entry.rs` (inline tests) + +- [ ] **Step 1: Add `group` field to `Entry` struct** + +In `crates/idfoto-core/src/entry.rs`, add the field after `totp_secret`: + +```rust +pub struct Entry { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + pub password: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub notes: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub totp_secret: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, + pub created_at: String, + pub updated_at: String, +} +``` + +- [ ] **Step 2: Add `group` field to `ManifestEntry` struct** + +In the same file: + +```rust +pub struct ManifestEntry { + pub name: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub username: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub group: Option, + pub updated_at: String, +} +``` + +- [ ] **Step 3: Fix all Entry construction sites in tests and CLI** + +Update every place that constructs `Entry` or `ManifestEntry` to include `group: None`. These are: + +In `crates/idfoto-core/src/entry.rs` tests — `entry_serialization_round_trip`, `manifest_add_and_lookup`, `manifest_serialization_round_trip`, `manifest_search_case_insensitive`: + +```rust +// Every Entry construction gets: +group: None, + +// Every ManifestEntry construction gets: +group: None, +``` + +In `crates/idfoto-core/src/vault.rs` tests — `sample_entry()` helper and `manifest_encrypt_decrypt_round_trip`: + +```rust +// sample_entry() gets: +group: None, + +// ManifestEntry in manifest_encrypt_decrypt_round_trip gets: +group: None, +``` + +In `crates/idfoto-core/tests/integration.rs` — `full_vault_workflow()` Entry construction (line ~55) and ManifestEntry (line ~101): + +```rust +// Entry construction gets: +group: None, + +// ManifestEntry construction gets: +group: None, +``` + +In `crates/idfoto-cli/src/main.rs` — `cmd_add()` Entry construction (line ~328), `cmd_add()` ManifestEntry (line ~349), `cmd_edit()` Entry construction (line ~513), `cmd_edit()` ManifestEntry (line ~536): + +```rust +// Every Entry construction gets: +group: None, + +// Every ManifestEntry construction gets: +group: None, +``` + +- [ ] **Step 4: Add a test for backwards compatibility (deserialize without group)** + +In `crates/idfoto-core/src/entry.rs` tests: + +```rust +#[test] +fn entry_deserializes_without_group_field() { + let json = r#"{ + "name": "Legacy", + "password": "old-pw", + "created_at": "2024-01-01T00:00:00Z", + "updated_at": "2024-01-01T00:00:00Z" + }"#; + let entry: Entry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.name, "Legacy"); + assert_eq!(entry.group, None); +} + +#[test] +fn manifest_entry_deserializes_without_group_field() { + let json = r#"{ + "name": "Legacy", + "updated_at": "2024-01-01T00:00:00Z" + }"#; + let entry: ManifestEntry = serde_json::from_str(json).unwrap(); + assert_eq!(entry.name, "Legacy"); + assert_eq!(entry.group, None); +} + +#[test] +fn entry_with_group_round_trips() { + let entry = Entry { + name: "Work GitHub".to_string(), + url: Some("https://github.com".to_string()), + username: Some("alee-work".to_string()), + password: "s3cr3t".to_string(), + notes: None, + totp_secret: None, + group: Some("work".to_string()), + created_at: "2024-01-01T00:00:00Z".to_string(), + updated_at: "2024-01-01T00:00:00Z".to_string(), + }; + let json = serde_json::to_string(&entry).unwrap(); + assert!(json.contains("\"group\":\"work\"")); + let decoded: Entry = serde_json::from_str(&json).unwrap(); + assert_eq!(decoded.group, Some("work".to_string())); +} +``` + +- [ ] **Step 5: Run all tests** + +Run: `cargo test` +Expected: All tests pass, including new backwards-compatibility tests. + +- [ ] **Step 6: Commit** + +```bash +git add crates/idfoto-core/src/entry.rs crates/idfoto-core/src/vault.rs crates/idfoto-core/tests/integration.rs crates/idfoto-cli/src/main.rs +git commit -m "feat: add group field to Entry and ManifestEntry" +``` + +--- + +## Task 2: Create `idfoto-wasm` Crate + +**Files:** +- Create: `crates/idfoto-wasm/Cargo.toml` +- Create: `crates/idfoto-wasm/src/lib.rs` +- Modify: `Cargo.toml` (workspace members) + +- [ ] **Step 1: Create Cargo.toml** + +Create `crates/idfoto-wasm/Cargo.toml`: + +```toml +[package] +name = "idfoto-wasm" +version = "0.1.0" +edition = "2021" +description = "WASM bindings for idfoto password manager" + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +idfoto-core = { path = "../idfoto-core" } +wasm-bindgen = "0.2" +js-sys = "0.3" +serde_json = "1" +hmac = "0.12" +sha1 = "0.10" +data-encoding = "2" + +[dev-dependencies] +wasm-bindgen-test = "0.3" +``` + +- [ ] **Step 2: Add to workspace** + +In root `Cargo.toml`, add `"crates/idfoto-wasm"` to the members list: + +```toml +[workspace] +resolver = "2" +members = [ + "crates/idfoto-core", + "crates/idfoto-cli", + "crates/idfoto-wasm", +] +``` + +- [ ] **Step 3: Write the WASM wrapper** + +Create `crates/idfoto-wasm/src/lib.rs`: + +```rust +use wasm_bindgen::prelude::*; + +/// Derive a 32-byte master key from passphrase + image_secret + salt. +/// `params_json` is a JSON string like: {"argon2_m":65536,"argon2_t":3,"argon2_p":4} +#[wasm_bindgen] +pub fn derive_master_key( + passphrase: &str, + image_secret: &[u8], + salt: &[u8], + params_json: &str, +) -> Result, JsValue> { + let params: idfoto_core::KdfParams = + serde_json::from_str(params_json).map_err(|e| JsValue::from_str(&e.to_string()))?; + + let image_secret: [u8; 32] = image_secret + .try_into() + .map_err(|_| JsValue::from_str("image_secret must be exactly 32 bytes"))?; + let salt: [u8; 32] = salt + .try_into() + .map_err(|_| JsValue::from_str("salt must be exactly 32 bytes"))?; + + let key = idfoto_core::derive_master_key(passphrase.as_bytes(), &image_secret, &salt, ¶ms) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + + Ok(key.to_vec()) +} + +/// Encrypt plaintext bytes with a 32-byte key. Returns version+nonce+ciphertext+tag. +#[wasm_bindgen] +pub fn encrypt(plaintext: &[u8], key: &[u8]) -> Result, JsValue> { + let key: [u8; 32] = key + .try_into() + .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; + idfoto_core::crypto::encrypt(&key, plaintext).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Decrypt ciphertext with a 32-byte key. Returns plaintext bytes. +#[wasm_bindgen] +pub fn decrypt(ciphertext: &[u8], key: &[u8]) -> Result, JsValue> { + let key: [u8; 32] = key + .try_into() + .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; + idfoto_core::crypto::decrypt(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Extract a 256-bit secret from a JPEG with an embedded secret. +#[wasm_bindgen] +pub fn extract_image_secret(jpeg_bytes: &[u8]) -> Result, JsValue> { + let secret = + idfoto_core::imgsecret::extract(jpeg_bytes).map_err(|e| JsValue::from_str(&e.to_string()))?; + Ok(secret.to_vec()) +} + +/// Encrypt an entry (JSON string) with a 32-byte key. Returns encrypted bytes. +#[wasm_bindgen] +pub fn encrypt_entry(entry_json: &str, key: &[u8]) -> Result, JsValue> { + let key: [u8; 32] = key + .try_into() + .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; + let entry: idfoto_core::Entry = + serde_json::from_str(entry_json).map_err(|e| JsValue::from_str(&e.to_string()))?; + idfoto_core::encrypt_entry(&key, &entry).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Decrypt an entry from encrypted bytes. Returns JSON string. +#[wasm_bindgen] +pub fn decrypt_entry(ciphertext: &[u8], key: &[u8]) -> Result { + let key: [u8; 32] = key + .try_into() + .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; + let entry = + idfoto_core::decrypt_entry(&key, ciphertext).map_err(|e| JsValue::from_str(&e.to_string()))?; + serde_json::to_string(&entry).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Encrypt a manifest (JSON string) with a 32-byte key. Returns encrypted bytes. +#[wasm_bindgen] +pub fn encrypt_manifest(manifest_json: &str, key: &[u8]) -> Result, JsValue> { + let key: [u8; 32] = key + .try_into() + .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; + let manifest: idfoto_core::Manifest = + serde_json::from_str(manifest_json).map_err(|e| JsValue::from_str(&e.to_string()))?; + idfoto_core::encrypt_manifest(&key, &manifest).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Decrypt a manifest from encrypted bytes. Returns JSON string. +#[wasm_bindgen] +pub fn decrypt_manifest(ciphertext: &[u8], key: &[u8]) -> Result { + let key: [u8; 32] = key + .try_into() + .map_err(|_| JsValue::from_str("key must be exactly 32 bytes"))?; + let manifest = idfoto_core::decrypt_manifest(&key, ciphertext) + .map_err(|e| JsValue::from_str(&e.to_string()))?; + serde_json::to_string(&manifest).map_err(|e| JsValue::from_str(&e.to_string())) +} + +/// Generate a TOTP code for the given base32-encoded secret and unix timestamp. +/// Returns a 6-digit zero-padded string. +#[wasm_bindgen] +pub fn generate_totp(secret_base32: &str, timestamp_secs: u64) -> Result { + use hmac::{Hmac, Mac}; + use sha1::Sha1; + + let secret = data_encoding::BASE32_NOPAD + .decode(secret_base32.to_uppercase().as_bytes()) + .or_else(|_| data_encoding::BASE32.decode(secret_base32.to_uppercase().as_bytes())) + .map_err(|e| JsValue::from_str(&format!("invalid base32 secret: {e}")))?; + + let counter = timestamp_secs / 30; + let counter_bytes = counter.to_be_bytes(); + + let mut mac = Hmac::::new_from_slice(&secret) + .map_err(|e| JsValue::from_str(&format!("HMAC error: {e}")))?; + mac.update(&counter_bytes); + let result = mac.finalize().into_bytes(); + + let offset = (result[19] & 0x0f) as usize; + let code = ((result[offset] as u32 & 0x7f) << 24) + | ((result[offset + 1] as u32) << 16) + | ((result[offset + 2] as u32) << 8) + | (result[offset + 3] as u32); + let code = code % 1_000_000; + + Ok(format!("{:06}", code)) +} + +/// Generate a random password of the given length. +#[wasm_bindgen] +pub fn generate_password(length: u32) -> String { + use js_sys::Math; + + const CHARSET: &[u8] = + b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+"; + + (0..length) + .map(|_| { + let idx = (Math::random() * CHARSET.len() as f64).floor() as usize; + CHARSET[idx % CHARSET.len()] as char + }) + .collect() +} + +/// Generate a random 8-character hex entry ID. +#[wasm_bindgen] +pub fn generate_entry_id() -> String { + use js_sys::Math; + + let mut bytes = [0u8; 4]; + for b in &mut bytes { + *b = (Math::random() * 256.0).floor() as u8; + } + bytes.iter().map(|b| format!("{:02x}", b)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn totp_rfc6238_test_vector() { + // RFC 6238 test vector: SHA1, secret = "12345678901234567890" (ASCII), + // time = 59, expected code = 287082 + let secret_ascii = b"12345678901234567890"; + let secret_b32 = data_encoding::BASE32.encode(secret_ascii); + let result = generate_totp(&secret_b32, 59).unwrap(); + assert_eq!(result, "287082"); + } + + #[test] + fn totp_rfc6238_test_vector_2() { + // time = 1111111109, expected = 081804 + let secret_ascii = b"12345678901234567890"; + let secret_b32 = data_encoding::BASE32.encode(secret_ascii); + let result = generate_totp(&secret_b32, 1111111109).unwrap(); + assert_eq!(result, "081804"); + } + + #[test] + fn totp_rfc6238_test_vector_3() { + // time = 1234567890, expected = 005924 + let secret_ascii = b"12345678901234567890"; + let secret_b32 = data_encoding::BASE32.encode(secret_ascii); + let result = generate_totp(&secret_b32, 1234567890).unwrap(); + assert_eq!(result, "005924"); + } + + #[test] + fn totp_invalid_base32_fails() { + let result = generate_totp("not-valid-base32!!!", 1000); + assert!(result.is_err()); + } + + #[test] + fn derive_key_via_wasm_wrapper() { + let params = r#"{"argon2_m":256,"argon2_t":1,"argon2_p":1}"#; + let image_secret = [0x42u8; 32]; + let salt = [0x01u8; 32]; + let key = derive_master_key("test-passphrase", &image_secret, &salt, params).unwrap(); + assert_eq!(key.len(), 32); + + // Same inputs should produce same output + let key2 = derive_master_key("test-passphrase", &image_secret, &salt, params).unwrap(); + assert_eq!(key, key2); + } + + #[test] + fn encrypt_decrypt_via_wasm_wrapper() { + let key = [0xABu8; 32]; + let plaintext = b"hello wasm"; + let ciphertext = encrypt(plaintext, &key).unwrap(); + let decrypted = decrypt(&ciphertext, &key).unwrap(); + assert_eq!(decrypted, plaintext); + } + + #[test] + fn encrypt_entry_decrypt_entry_round_trip() { + let key = [0xABu8; 32]; + let entry_json = r#"{"name":"Test","password":"secret","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}"#; + let ciphertext = encrypt_entry(entry_json, &key).unwrap(); + let result = decrypt_entry(&ciphertext, &key).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&result).unwrap(); + assert_eq!(parsed["name"], "Test"); + assert_eq!(parsed["password"], "secret"); + } +} +``` + +- [ ] **Step 4: Verify it compiles** + +Run: `cargo build -p idfoto-wasm` +Expected: Compiles successfully. + +- [ ] **Step 5: Run tests** + +Run: `cargo test -p idfoto-wasm` +Expected: All tests pass, including TOTP RFC 6238 test vectors. + +- [ ] **Step 6: Test WASM compilation** + +Run: `cargo install wasm-pack` (if not already installed), then: +```bash +wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm +``` +Expected: Produces `extension/wasm/idfoto_wasm.js` and `extension/wasm/idfoto_wasm_bg.wasm`. Note the WASM binary size for later reference. + +- [ ] **Step 7: Commit** + +```bash +git add crates/idfoto-wasm/ Cargo.toml extension/wasm/ +git commit -m "feat: add idfoto-wasm crate with wasm-bindgen wrappers and TOTP" +``` + +--- + +## Task 3: Extension Scaffolding + +**Files:** +- Create: `extension/manifest.json` +- Create: `extension/package.json` +- Create: `extension/tsconfig.json` +- Create: `extension/webpack.config.js` +- Create: `extension/src/popup/index.html` +- Create: `extension/icons/` (placeholder icons) +- Modify: `.gitignore` (add extension build artifacts) + +- [ ] **Step 1: Create `extension/package.json`** + +```json +{ + "name": "idfoto-extension", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "webpack --mode production", + "dev": "webpack --mode development --watch", + "build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm", + "build:all": "npm run build:wasm && npm run build" + }, + "devDependencies": { + "typescript": "^5.4", + "ts-loader": "^9.5", + "webpack": "^5.90", + "webpack-cli": "^5.1", + "copy-webpack-plugin": "^12.0", + "@anthropic-ai/sdk": "^0.39" + } +} +``` + +Note: `@anthropic-ai/sdk` is NOT needed — remove that. The devDependencies should be: + +```json +{ + "name": "idfoto-extension", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "webpack --mode production", + "dev": "webpack --mode development --watch", + "build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm", + "build:all": "npm run build:wasm && npm run build" + }, + "devDependencies": { + "typescript": "^5.4", + "ts-loader": "^9.5", + "webpack": "^5.90", + "webpack-cli": "^5.1", + "copy-webpack-plugin": "^12.0", + "@anthropic-ai/sdk": "^0.39.0" + } +} +``` + +Actually, strike that — no anthropic SDK. Final version: + +```json +{ + "name": "idfoto-extension", + "version": "0.1.0", + "private": true, + "scripts": { + "build": "webpack --mode production", + "dev": "webpack --mode development --watch", + "build:wasm": "wasm-pack build ../crates/idfoto-wasm --target web --out-dir ../../extension/wasm", + "build:all": "npm run build:wasm && npm run build" + }, + "devDependencies": { + "typescript": "^5.4", + "ts-loader": "^9.5", + "webpack": "^5.90", + "webpack-cli": "^5.1", + "copy-webpack-plugin": "^12.0" + } +} +``` + +- [ ] **Step 2: Create `extension/tsconfig.json`** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "strict": true, + "esModuleInterop": true, + "outDir": "./dist", + "rootDir": "./src", + "sourceMap": true, + "lib": ["ES2022", "DOM"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "wasm"] +} +``` + +- [ ] **Step 3: Create `extension/webpack.config.js`** + +```javascript +const path = require('path'); +const CopyPlugin = require('copy-webpack-plugin'); + +module.exports = { + entry: { + 'service-worker': './src/service-worker/index.ts', + popup: './src/popup/popup.ts', + content: './src/content/detector.ts', + }, + output: { + path: path.resolve(__dirname, 'dist'), + filename: '[name].js', + clean: true, + }, + resolve: { + extensions: ['.ts', '.js'], + }, + module: { + rules: [ + { + test: /\.ts$/, + use: 'ts-loader', + exclude: /node_modules/, + }, + ], + }, + plugins: [ + new CopyPlugin({ + patterns: [ + { from: 'manifest.json', to: '.' }, + { from: 'src/popup/index.html', to: 'popup.html' }, + { from: 'src/popup/styles.css', to: 'styles.css' }, + { from: 'icons', to: 'icons' }, + { from: 'wasm/idfoto_wasm_bg.wasm', to: '.' }, + { from: 'wasm/idfoto_wasm.js', to: '.' }, + ], + }), + ], + experiments: { + asyncWebAssembly: true, + }, +}; +``` + +- [ ] **Step 4: Create `extension/manifest.json`** + +```json +{ + "manifest_version": 3, + "name": "idfoto", + "version": "0.1.0", + "description": "Two-factor encrypted password manager", + "permissions": ["storage", "activeTab", "clipboardWrite"], + "host_permissions": [""], + "background": { + "service_worker": "service-worker.js", + "type": "module" + }, + "action": { + "default_popup": "popup.html", + "default_icon": { + "16": "icons/icon-16.png", + "48": "icons/icon-48.png", + "128": "icons/icon-128.png" + } + }, + "content_scripts": [ + { + "matches": [""], + "js": ["content.js"], + "run_at": "document_idle" + } + ], + "content_security_policy": { + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self'" + } +} +``` + +- [ ] **Step 5: Create popup HTML shell** + +Create `extension/src/popup/index.html`: + +```html + + + + + + + idfoto + + +
+ + + +``` + +- [ ] **Step 6: Create placeholder icons** + +Generate simple placeholder PNGs for `extension/icons/`. These are 1x1 blue pixels scaled to 16, 48, 128 — functional placeholders to avoid Chrome errors. Use a simple script or create them manually. + +```bash +cd extension && mkdir -p icons +# Create minimal valid PNGs (can replace with real icons later) +python3 -c " +import struct, zlib +def make_png(size, path): + raw = b'' + for y in range(size): + raw += b'\x00' + b'\x1f\x6f\xeb\xff' * size + def chunk(ctype, data): + c = ctype + data + return struct.pack('>I', len(data)) + c + struct.pack('>I', zlib.crc32(c) & 0xffffffff) + ihdr = struct.pack('>IIBBBBB', size, size, 8, 6, 0, 0, 0) + with open(path, 'wb') as f: + f.write(b'\x89PNG\r\n\x1a\n') + f.write(chunk(b'IHDR', ihdr)) + f.write(chunk(b'IDAT', zlib.compress(raw))) + f.write(chunk(b'IEND', b'')) +make_png(16, 'icons/icon-16.png') +make_png(48, 'icons/icon-48.png') +make_png(128, 'icons/icon-128.png') +" +``` + +- [ ] **Step 7: Add to `.gitignore`** + +Append to the root `.gitignore`: + +``` +extension/node_modules/ +extension/dist/ +.superpowers/ +``` + +- [ ] **Step 8: Commit** + +```bash +git add extension/manifest.json extension/package.json extension/tsconfig.json extension/webpack.config.js extension/src/popup/index.html extension/icons/ .gitignore +git commit -m "feat: add extension scaffolding (manifest, webpack, tsconfig)" +``` + +--- + +## Task 4: Shared Types and Message Definitions + +**Files:** +- Create: `extension/src/shared/types.ts` +- Create: `extension/src/shared/messages.ts` + +- [ ] **Step 1: Create shared types** + +Create `extension/src/shared/types.ts`: + +```typescript +export interface Entry { + name: string; + url?: string; + username?: string; + password: string; + notes?: string; + totp_secret?: string; + group?: string; + created_at: string; + updated_at: string; +} + +export interface ManifestEntry { + name: string; + url?: string; + username?: string; + group?: string; + updated_at: string; +} + +export interface Manifest { + entries: Record; + version: number; +} + +export interface VaultConfig { + hostType: 'gitea' | 'github'; + hostUrl: string; + repoPath: string; + apiToken: string; +} + +export interface SetupState { + config: VaultConfig | null; + imageBase64: string | null; // reference JPEG stored as base64 + isConfigured: boolean; +} +``` + +- [ ] **Step 2: Create message definitions** + +Create `extension/src/shared/messages.ts`: + +```typescript +import type { Entry, ManifestEntry, VaultConfig } from './types'; + +// ─── Requests (popup/content → service worker) ───────────────────────────── + +export type Request = + | { type: 'unlock'; passphrase: string } + | { type: 'lock' } + | { type: 'is_unlocked' } + | { type: 'list_entries'; group?: string } + | { type: 'get_entry'; id: string } + | { type: 'search_entries'; query: string } + | { type: 'add_entry'; entry: Omit } + | { type: 'update_entry'; id: string; entry: Omit } + | { type: 'delete_entry'; id: string } + | { type: 'get_totp'; id: string } + | { type: 'get_autofill_candidates'; url: string } + | { type: 'get_credentials'; id: string } + | { type: 'sync' } + | { type: 'get_setup_state' } + | { type: 'save_setup'; config: VaultConfig; imageBase64: string } + | { type: 'fill_credentials'; username: string; password: string }; + +// ─── Responses (service worker → popup/content) ──────────────────────────── + +export type Response = + | { ok: true } + | { ok: true; id: string } + | { ok: true; unlocked: boolean } + | { ok: true; entries: Array<{ id: string } & ManifestEntry> } + | { ok: true; entry: Entry } + | { ok: true; code: string; remaining_seconds: number } + | { ok: true; candidates: Array<{ id: string } & ManifestEntry> } + | { ok: true; username: string; password: string } + | { ok: true; state: import('./types').SetupState } + | { error: string }; +``` + +- [ ] **Step 3: Commit** + +```bash +git add extension/src/shared/ +git commit -m "feat: add shared types and message definitions" +``` + +--- + +## Task 5: Git API Layer + +**Files:** +- Create: `extension/src/service-worker/git-host.ts` +- Create: `extension/src/service-worker/gitea.ts` +- Create: `extension/src/service-worker/github.ts` + +- [ ] **Step 1: Create the GitHost interface** + +Create `extension/src/service-worker/git-host.ts`: + +```typescript +export interface GitHost { + readFile(path: string): Promise; + writeFile(path: string, content: Uint8Array, message: string): Promise; + deleteFile(path: string, message: string): Promise; + listDir(path: string): Promise; +} + +export function createGitHost( + hostType: 'gitea' | 'github', + hostUrl: string, + repoPath: string, + apiToken: string +): GitHost { + switch (hostType) { + case 'gitea': + return new (require('./gitea').GiteaHost)(hostUrl, repoPath, apiToken); + case 'github': + return new (require('./github').GitHubHost)(hostUrl, repoPath, apiToken); + } +} +``` + +Actually, since we're using ES modules and webpack, use dynamic imports or just import both. Simpler approach: + +```typescript +import { GiteaHost } from './gitea'; +import { GitHubHost } from './github'; + +export interface GitHost { + readFile(path: string): Promise; + writeFile(path: string, content: Uint8Array, message: string): Promise; + deleteFile(path: string, message: string): Promise; + listDir(path: string): Promise; +} + +export function createGitHost( + hostType: 'gitea' | 'github', + hostUrl: string, + repoPath: string, + apiToken: string +): GitHost { + if (hostType === 'gitea') { + return new GiteaHost(hostUrl, repoPath, apiToken); + } + return new GitHubHost(hostUrl, repoPath, apiToken); +} +``` + +- [ ] **Step 2: Create Gitea implementation** + +Create `extension/src/service-worker/gitea.ts`: + +```typescript +import type { GitHost } from './git-host'; + +export class GiteaHost implements GitHost { + private baseUrl: string; + private headers: HeadersInit; + + constructor(hostUrl: string, repoPath: string, apiToken: string) { + // Remove trailing slash from hostUrl + const host = hostUrl.replace(/\/+$/, ''); + this.baseUrl = `${host}/api/v1/repos/${repoPath}/contents`; + this.headers = { + Authorization: `token ${apiToken}`, + 'Content-Type': 'application/json', + }; + } + + async readFile(path: string): Promise { + const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (!resp.ok) { + throw new Error(`Failed to read ${path}: ${resp.status} ${resp.statusText}`); + } + const data = await resp.json(); + const decoded = atob(data.content); + const bytes = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i++) { + bytes[i] = decoded.charCodeAt(i); + } + return bytes; + } + + async writeFile(path: string, content: Uint8Array, message: string): Promise { + // Check if file exists to get its SHA + let sha: string | undefined; + try { + const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (resp.ok) { + const data = await resp.json(); + sha = data.sha; + } + } catch { + // File doesn't exist, that's fine + } + + const body: Record = { + message, + content: uint8ArrayToBase64(content), + }; + if (sha) { + body.sha = sha; + } + + const method = sha ? 'PUT' : 'POST'; + const resp = await fetch(`${this.baseUrl}/${path}`, { + method, + headers: this.headers, + body: JSON.stringify(body), + }); + if (!resp.ok) { + throw new Error(`Failed to write ${path}: ${resp.status} ${resp.statusText}`); + } + } + + async deleteFile(path: string, message: string): Promise { + // Get file SHA first + const getResp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (!getResp.ok) { + throw new Error(`Failed to read ${path} for deletion: ${getResp.status}`); + } + const data = await getResp.json(); + + const resp = await fetch(`${this.baseUrl}/${path}`, { + method: 'DELETE', + headers: this.headers, + body: JSON.stringify({ message, sha: data.sha }), + }); + if (!resp.ok) { + throw new Error(`Failed to delete ${path}: ${resp.status} ${resp.statusText}`); + } + } + + async listDir(path: string): Promise { + const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (!resp.ok) { + if (resp.status === 404) return []; + throw new Error(`Failed to list ${path}: ${resp.status} ${resp.statusText}`); + } + const data = await resp.json(); + if (!Array.isArray(data)) return []; + return data.map((item: { name: string }) => item.name); + } +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} +``` + +- [ ] **Step 3: Create GitHub implementation** + +Create `extension/src/service-worker/github.ts`: + +```typescript +import type { GitHost } from './git-host'; + +export class GitHubHost implements GitHost { + private baseUrl: string; + private headers: HeadersInit; + + constructor(_hostUrl: string, repoPath: string, apiToken: string) { + this.baseUrl = `https://api.github.com/repos/${repoPath}/contents`; + this.headers = { + Authorization: `Bearer ${apiToken}`, + Accept: 'application/vnd.github.v3+json', + 'Content-Type': 'application/json', + }; + } + + async readFile(path: string): Promise { + const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (!resp.ok) { + throw new Error(`Failed to read ${path}: ${resp.status} ${resp.statusText}`); + } + const data = await resp.json(); + const decoded = atob(data.content.replace(/\n/g, '')); + const bytes = new Uint8Array(decoded.length); + for (let i = 0; i < decoded.length; i++) { + bytes[i] = decoded.charCodeAt(i); + } + return bytes; + } + + async writeFile(path: string, content: Uint8Array, message: string): Promise { + let sha: string | undefined; + try { + const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (resp.ok) { + const data = await resp.json(); + sha = data.sha; + } + } catch { + // File doesn't exist + } + + const body: Record = { + message, + content: uint8ArrayToBase64(content), + }; + if (sha) { + body.sha = sha; + } + + const resp = await fetch(`${this.baseUrl}/${path}`, { + method: 'PUT', + headers: this.headers, + body: JSON.stringify(body), + }); + if (!resp.ok) { + throw new Error(`Failed to write ${path}: ${resp.status} ${resp.statusText}`); + } + } + + async deleteFile(path: string, message: string): Promise { + const getResp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (!getResp.ok) { + throw new Error(`Failed to read ${path} for deletion: ${getResp.status}`); + } + const data = await getResp.json(); + + const resp = await fetch(`${this.baseUrl}/${path}`, { + method: 'DELETE', + headers: this.headers, + body: JSON.stringify({ message, sha: data.sha }), + }); + if (!resp.ok) { + throw new Error(`Failed to delete ${path}: ${resp.status} ${resp.statusText}`); + } + } + + async listDir(path: string): Promise { + const resp = await fetch(`${this.baseUrl}/${path}`, { headers: this.headers }); + if (!resp.ok) { + if (resp.status === 404) return []; + throw new Error(`Failed to list ${path}: ${resp.status} ${resp.statusText}`); + } + const data = await resp.json(); + if (!Array.isArray(data)) return []; + return data.map((item: { name: string }) => item.name); + } +} + +function uint8ArrayToBase64(bytes: Uint8Array): string { + let binary = ''; + for (let i = 0; i < bytes.length; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary); +} +``` + +- [ ] **Step 4: Commit** + +```bash +git add extension/src/service-worker/git-host.ts extension/src/service-worker/gitea.ts extension/src/service-worker/github.ts +git commit -m "feat: add git API layer with Gitea and GitHub implementations" +``` + +--- + +## Task 6: Service Worker — Vault Operations + +**Files:** +- Create: `extension/src/service-worker/vault.ts` + +- [ ] **Step 1: Create vault operations module** + +Create `extension/src/service-worker/vault.ts`: + +```typescript +import type { GitHost } from './git-host'; +import type { Entry, Manifest, ManifestEntry } from '../shared/types'; + +// These will be set by the service worker index after WASM init +let wasm: typeof import('../../wasm/idfoto_wasm'); + +export function setWasm(w: typeof wasm) { + wasm = w; +} + +export async function fetchVaultMeta(git: GitHost): Promise<{ salt: Uint8Array; paramsJson: string }> { + const salt = await git.readFile('.idfoto/salt'); + const paramsBytes = await git.readFile('.idfoto/params.json'); + const paramsJson = new TextDecoder().decode(paramsBytes); + return { salt, paramsJson }; +} + +export async function fetchAndDecryptManifest(git: GitHost, masterKey: Uint8Array): Promise { + const encBytes = await git.readFile('manifest.enc'); + const json = wasm.decrypt_manifest(encBytes, masterKey); + return JSON.parse(json); +} + +export async function fetchAndDecryptEntry(git: GitHost, masterKey: Uint8Array, id: string): Promise { + const encBytes = await git.readFile(`entries/${id}.enc`); + const json = wasm.decrypt_entry(encBytes, masterKey); + return JSON.parse(json); +} + +export async function encryptAndWriteEntry( + git: GitHost, + masterKey: Uint8Array, + id: string, + entry: Entry, + commitMsg: string +): Promise { + const json = JSON.stringify(entry); + const encBytes = wasm.encrypt_entry(json, masterKey); + await git.writeFile(`entries/${id}.enc`, encBytes, commitMsg); +} + +export async function encryptAndWriteManifest( + git: GitHost, + masterKey: Uint8Array, + manifest: Manifest, + commitMsg: string +): Promise { + const json = JSON.stringify(manifest); + const encBytes = wasm.encrypt_manifest(json, masterKey); + await git.writeFile('manifest.enc', encBytes, commitMsg); +} + +export function listEntries(manifest: Manifest, group?: string): Array<{ id: string } & ManifestEntry> { + const entries = Object.entries(manifest.entries).map(([id, entry]) => ({ id, ...entry })); + if (group) { + return entries.filter((e) => e.group === group); + } + return entries; +} + +export function searchEntries(manifest: Manifest, query: string): Array<{ id: string } & ManifestEntry> { + const q = query.toLowerCase(); + return Object.entries(manifest.entries) + .filter(([, e]) => { + return ( + e.name.toLowerCase().includes(q) || + (e.url && e.url.toLowerCase().includes(q)) || + (e.username && e.username.toLowerCase().includes(q)) + ); + }) + .map(([id, entry]) => ({ id, ...entry })); +} + +export function findByUrl(manifest: Manifest, url: string): Array<{ id: string } & ManifestEntry> { + let hostname: string; + try { + hostname = new URL(url).hostname; + } catch { + return []; + } + return Object.entries(manifest.entries) + .filter(([, e]) => { + if (!e.url) return false; + try { + return new URL(e.url).hostname === hostname; + } catch { + return false; + } + }) + .map(([id, entry]) => ({ id, ...entry })); +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add extension/src/service-worker/vault.ts +git commit -m "feat: add vault operations module for service worker" +``` + +--- + +## Task 7: Service Worker — Main Entry Point + +**Files:** +- Create: `extension/src/service-worker/index.ts` + +- [ ] **Step 1: Create the service worker entry point** + +Create `extension/src/service-worker/index.ts`: + +```typescript +import type { Manifest, VaultConfig, SetupState } from '../shared/types'; +import type { Request, Response } from '../shared/messages'; +import { createGitHost, type GitHost } from './git-host'; +import { + setWasm, + fetchVaultMeta, + fetchAndDecryptManifest, + fetchAndDecryptEntry, + encryptAndWriteEntry, + encryptAndWriteManifest, + listEntries, + searchEntries, + findByUrl, +} from './vault'; + +// ─── State ────────────────────────────────────────────────────────────────── + +let masterKey: Uint8Array | null = null; +let manifest: Manifest | null = null; +let gitHost: GitHost | null = null; +let wasm: typeof import('../../wasm/idfoto_wasm') | null = null; + +// ─── WASM initialization ─────────────────────────────────────────────────── + +async function initWasm(): Promise { + if (wasm) return wasm; + const mod = await import(/* webpackIgnore: true */ './idfoto_wasm.js'); + await mod.default(); + wasm = mod; + setWasm(mod); + return mod; +} + +// ─── Config helpers ───────────────────────────────────────────────────────── + +async function loadConfig(): Promise<{ config: VaultConfig; imageBase64: string } | null> { + const data = await chrome.storage.local.get(['vaultConfig', 'imageBase64']); + if (!data.vaultConfig || !data.imageBase64) return null; + return { config: data.vaultConfig, imageBase64: data.imageBase64 }; +} + +async function saveConfig(config: VaultConfig, imageBase64: string): Promise { + await chrome.storage.local.set({ vaultConfig: config, imageBase64 }); +} + +function base64ToUint8Array(base64: string): Uint8Array { + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return bytes; +} + +function isoNow(): string { + return new Date().toISOString(); +} + +// ─── Message handler ──────────────────────────────────────────────────────── + +chrome.runtime.onMessage.addListener( + (request: Request, _sender, sendResponse: (response: Response) => void) => { + handleMessage(request) + .then(sendResponse) + .catch((err) => sendResponse({ error: String(err) })); + return true; // async response + } +); + +async function handleMessage(req: Request): Promise { + const w = await initWasm(); + + switch (req.type) { + case 'get_setup_state': { + const stored = await loadConfig(); + const state: SetupState = { + config: stored?.config ?? null, + imageBase64: stored?.imageBase64 ?? null, + isConfigured: stored !== null, + }; + return { ok: true, state } as Response; + } + + case 'save_setup': { + await saveConfig(req.config, req.imageBase64); + return { ok: true }; + } + + case 'is_unlocked': { + return { ok: true, unlocked: masterKey !== null } as Response; + } + + case 'unlock': { + const stored = await loadConfig(); + if (!stored) return { error: 'Extension not configured. Run setup first.' }; + + const imageBytes = base64ToUint8Array(stored.imageBase64); + const imageSecret = w.extract_image_secret(imageBytes); + + gitHost = createGitHost( + stored.config.hostType, + stored.config.hostUrl, + stored.config.repoPath, + stored.config.apiToken + ); + + const { salt, paramsJson } = await fetchVaultMeta(gitHost); + const key = w.derive_master_key(req.passphrase, imageSecret, salt, paramsJson); + masterKey = new Uint8Array(key); + + manifest = await fetchAndDecryptManifest(gitHost, masterKey); + return { ok: true }; + } + + case 'lock': { + masterKey = null; + manifest = null; + return { ok: true }; + } + + case 'list_entries': { + if (!manifest) return { error: 'Vault is locked' }; + const entries = listEntries(manifest, req.group); + return { ok: true, entries } as Response; + } + + case 'search_entries': { + if (!manifest) return { error: 'Vault is locked' }; + const entries = searchEntries(manifest, req.query); + return { ok: true, entries } as Response; + } + + case 'get_entry': { + if (!masterKey || !gitHost) return { error: 'Vault is locked' }; + const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id); + return { ok: true, entry } as Response; + } + + case 'add_entry': { + if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' }; + const now = isoNow(); + const entry = { ...req.entry, created_at: now, updated_at: now }; + const id = w.generate_entry_id(); + + await encryptAndWriteEntry(gitHost, masterKey, id, entry, `add entry '${entry.name}'`); + + manifest.entries[id] = { + name: entry.name, + url: entry.url, + username: entry.username, + group: entry.group, + updated_at: now, + }; + await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: add '${entry.name}'`); + + return { ok: true, id } as Response; + } + + case 'update_entry': { + if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' }; + const existing = manifest.entries[req.id]; + if (!existing) return { error: `Entry ${req.id} not found` }; + + const now = isoNow(); + const oldEntry = await fetchAndDecryptEntry(gitHost, masterKey, req.id); + const updated = { ...req.entry, created_at: oldEntry.created_at, updated_at: now }; + + await encryptAndWriteEntry(gitHost, masterKey, req.id, updated, `edit entry '${updated.name}'`); + + manifest.entries[req.id] = { + name: updated.name, + url: updated.url, + username: updated.username, + group: updated.group, + updated_at: now, + }; + await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: edit '${updated.name}'`); + + return { ok: true }; + } + + case 'delete_entry': { + if (!masterKey || !gitHost || !manifest) return { error: 'Vault is locked' }; + const entry = manifest.entries[req.id]; + if (!entry) return { error: `Entry ${req.id} not found` }; + + await gitHost.deleteFile(`entries/${req.id}.enc`, `remove entry '${entry.name}'`); + delete manifest.entries[req.id]; + await encryptAndWriteManifest(gitHost, masterKey, manifest, `update manifest: remove '${entry.name}'`); + + return { ok: true }; + } + + case 'get_totp': { + if (!masterKey || !gitHost) return { error: 'Vault is locked' }; + const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id); + if (!entry.totp_secret) return { error: 'No TOTP secret for this entry' }; + + const now = Math.floor(Date.now() / 1000); + const code = w.generate_totp(entry.totp_secret, BigInt(now)); + const remaining = 30 - (now % 30); + return { ok: true, code, remaining_seconds: remaining } as Response; + } + + case 'get_autofill_candidates': { + if (!manifest) return { error: 'Vault is locked' }; + const candidates = findByUrl(manifest, req.url); + return { ok: true, candidates } as Response; + } + + case 'get_credentials': { + if (!masterKey || !gitHost) return { error: 'Vault is locked' }; + const entry = await fetchAndDecryptEntry(gitHost, masterKey, req.id); + return { ok: true, username: entry.username ?? '', password: entry.password } as Response; + } + + case 'sync': { + if (!masterKey || !gitHost) return { error: 'Vault is locked' }; + manifest = await fetchAndDecryptManifest(gitHost, masterKey); + return { ok: true }; + } + + case 'fill_credentials': { + // This is handled by the content script directly, not the service worker + return { ok: true }; + } + + default: + return { error: `Unknown message type: ${(req as Request).type}` }; + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add extension/src/service-worker/index.ts +git commit -m "feat: add service worker with WASM init, state management, and message router" +``` + +--- + +## Task 8: Popup — Styles + +**Files:** +- Create: `extension/src/popup/styles.css` + +- [ ] **Step 1: Create the terminal dark theme** + +Create `extension/src/popup/styles.css`: + +```css +/* ─── Reset & Base ───────────────────────────────────────────────────────── */ + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + width: 360px; + min-height: 200px; + max-height: 500px; + overflow-y: auto; + background: #0d1117; + color: #c9d1d9; + font-family: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', 'SF Mono', 'Menlo', monospace; + font-size: 12px; + line-height: 1.5; +} + +#app { + padding: 16px; +} + +/* ─── Typography ─────────────────────────────────────────────────────────── */ + +.brand { + color: #58a6ff; + font-size: 14px; + letter-spacing: 2px; + font-weight: normal; +} + +.label { + color: #484f58; + font-size: 10px; + text-transform: uppercase; + margin-bottom: 2px; +} + +.secondary { + color: #8b949e; +} + +.muted { + color: #484f58; +} + +.error { + color: #f85149; + font-size: 11px; +} + +/* ─── Inputs ─────────────────────────────────────────────────────────────── */ + +input, textarea { + background: #161b22; + border: 1px solid #30363d; + color: #c9d1d9; + font-family: inherit; + font-size: 12px; + padding: 6px 10px; + border-radius: 2px; + width: 100%; + outline: none; +} + +input:focus, textarea:focus { + border-color: #58a6ff; +} + +input::placeholder, textarea::placeholder { + color: #484f58; +} + +/* ─── Buttons ────────────────────────────────────────────────────────────── */ + +.btn { + background: #21262d; + border: 1px solid #30363d; + color: #c9d1d9; + font-family: inherit; + font-size: 11px; + padding: 4px 12px; + border-radius: 2px; + cursor: pointer; +} + +.btn:hover { + background: #30363d; +} + +.btn-primary { + background: #1f6feb; + border-color: #1f6feb; + color: #fff; +} + +.btn-primary:hover { + background: #388bfd; +} + +.btn-danger { + background: #da3633; + border-color: #da3633; + color: #fff; +} + +.btn-danger:hover { + background: #f85149; +} + +/* ─── Header ─────────────────────────────────────────────────────────────── */ + +.header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.status { + color: #484f58; + font-size: 10px; +} + +/* ─── Search ─────────────────────────────────────────────────────────────── */ + +.search-bar { + margin-bottom: 8px; +} + +/* ─── Group Tabs ─────────────────────────────────────────────────────────── */ + +.group-tabs { + display: flex; + gap: 6px; + margin-bottom: 10px; + flex-wrap: wrap; +} + +.group-tab { + background: #21262d; + border: 1px solid #30363d; + padding: 2px 8px; + border-radius: 2px; + font-size: 10px; + color: #8b949e; + cursor: pointer; + font-family: inherit; +} + +.group-tab:hover { + background: #30363d; +} + +.group-tab.active { + background: #1f6feb; + border-color: #1f6feb; + color: #fff; +} + +/* ─── Entry List ─────────────────────────────────────────────────────────── */ + +.entry-list { + max-height: 300px; + overflow-y: auto; +} + +.entry-row { + padding: 6px 10px; + border-left: 2px solid transparent; + cursor: pointer; +} + +.entry-row:hover { + background: #161b22; +} + +.entry-row.selected { + border-left-color: #58a6ff; + background: #161b22; +} + +.entry-name { + color: #c9d1d9; + font-size: 12px; +} + +.entry-meta { + color: #484f58; + font-size: 10px; +} + +/* ─── Entry Detail ───────────────────────────────────────────────────────── */ + +.detail-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 12px; +} + +.detail-title { + color: #58a6ff; + font-size: 14px; +} + +.field { + margin-bottom: 8px; +} + +.field-value { + display: flex; + justify-content: space-between; + align-items: center; +} + +.field-value .hint { + color: #484f58; + font-size: 10px; +} + +/* ─── TOTP ───────────────────────────────────────────────────────────────── */ + +.totp-code { + color: #3fb950; + font-size: 16px; + letter-spacing: 4px; +} + +.totp-bar { + height: 2px; + background: #21262d; + margin-top: 4px; + border-radius: 1px; +} + +.totp-bar-fill { + height: 2px; + background: #3fb950; + border-radius: 1px; + transition: width 1s linear; +} + +/* ─── Footer / Keybinding Hints ──────────────────────────────────────────── */ + +.keyhints { + color: #484f58; + font-size: 10px; + margin-top: 8px; + padding-top: 8px; + border-top: 1px solid #21262d; +} + +/* ─── Setup Wizard ───────────────────────────────────────────────────────── */ + +.wizard-step { + color: #484f58; + font-size: 10px; + margin-bottom: 4px; +} + +.progress-bar { + height: 2px; + background: #21262d; + margin-bottom: 16px; + border-radius: 1px; +} + +.progress-bar-fill { + height: 2px; + background: #1f6feb; + border-radius: 1px; + transition: width 0.3s; +} + +.form-group { + margin-bottom: 12px; +} + +.host-toggle { + display: flex; + gap: 8px; + margin-bottom: 12px; +} + +/* ─── Spinner ────────────────────────────────────────────────────────────── */ + +.spinner { + display: inline-block; + width: 12px; + height: 12px; + border: 2px solid #30363d; + border-top-color: #58a6ff; + border-radius: 50%; + animation: spin 0.6s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ─── Actions Bar ────────────────────────────────────────────────────────── */ + +.actions { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 12px; +} + +/* ─── Confirm Dialog ─────────────────────────────────────────────────────── */ + +.confirm-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 100; +} + +.confirm-box { + background: #161b22; + border: 1px solid #30363d; + padding: 16px; + border-radius: 4px; + max-width: 300px; +} + +/* ─── Empty State ────────────────────────────────────────────────────────── */ + +.empty { + color: #484f58; + text-align: center; + padding: 24px 0; + font-size: 11px; +} + +/* ─── Scrollbar ──────────────────────────────────────────────────────────── */ + +::-webkit-scrollbar { + width: 4px; +} + +::-webkit-scrollbar-track { + background: transparent; +} + +::-webkit-scrollbar-thumb { + background: #30363d; + border-radius: 2px; +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add extension/src/popup/styles.css +git commit -m "feat: add terminal dark theme for popup" +``` + +--- + +## Task 9: Popup — State Machine and Components + +**Files:** +- Create: `extension/src/popup/popup.ts` +- Create: `extension/src/popup/components/unlock.ts` +- Create: `extension/src/popup/components/entry-list.ts` +- Create: `extension/src/popup/components/entry-detail.ts` +- Create: `extension/src/popup/components/entry-form.ts` +- Create: `extension/src/popup/components/setup-wizard.ts` + +This is the largest task. Each component is a function that renders into a container element and attaches its own event listeners. The popup state machine orchestrates transitions. + +- [ ] **Step 1: Create the popup state machine** + +Create `extension/src/popup/popup.ts`: + +```typescript +import type { Request, Response } from '../shared/messages'; +import { renderUnlock } from './components/unlock'; +import { renderEntryList } from './components/entry-list'; +import { renderEntryDetail } from './components/entry-detail'; +import { renderEntryForm } from './components/entry-form'; +import { renderSetupWizard } from './components/setup-wizard'; + +export type PopupState = + | { view: 'setup' } + | { view: 'locked' } + | { view: 'list' } + | { view: 'detail'; entryId: string } + | { view: 'add' } + | { view: 'edit'; entryId: string }; + +let currentState: PopupState = { view: 'locked' }; + +export function sendMessage(request: Request): Promise { + return chrome.runtime.sendMessage(request); +} + +export function navigate(state: PopupState) { + currentState = state; + render(); +} + +function render() { + const app = document.getElementById('app')!; + app.innerHTML = ''; + + switch (currentState.view) { + case 'setup': + renderSetupWizard(app); + break; + case 'locked': + renderUnlock(app); + break; + case 'list': + renderEntryList(app); + break; + case 'detail': + renderEntryDetail(app, currentState.entryId); + break; + case 'add': + renderEntryForm(app, null); + break; + case 'edit': + renderEntryForm(app, currentState.entryId); + break; + } +} + +// ─── Init ─────────────────────────────────────────────────────────────────── + +async function init() { + const resp = await sendMessage({ type: 'get_setup_state' }); + if ('error' in resp) { + navigate({ view: 'setup' }); + return; + } + + const state = (resp as { ok: true; state: import('../shared/types').SetupState }).state; + if (!state.isConfigured) { + navigate({ view: 'setup' }); + return; + } + + const unlockResp = await sendMessage({ type: 'is_unlocked' }); + if ('error' in unlockResp) { + navigate({ view: 'locked' }); + return; + } + + const unlocked = (unlockResp as { ok: true; unlocked: boolean }).unlocked; + navigate(unlocked ? { view: 'list' } : { view: 'locked' }); +} + +document.addEventListener('DOMContentLoaded', init); +``` + +- [ ] **Step 2: Create unlock component** + +Create `extension/src/popup/components/unlock.ts`: + +```typescript +import { sendMessage, navigate } from '../popup'; + +export function renderUnlock(container: HTMLElement) { + container.innerHTML = ` +
idfoto
+
+
PASSPHRASE
+ +
+ +
+
+ + +
+ `; + + const input = container.querySelector('#passphrase') as HTMLInputElement; + const errorEl = container.querySelector('#unlock-error') as HTMLElement; + const spinnerEl = container.querySelector('#unlock-spinner') as HTMLElement; + const btnUnlock = container.querySelector('#btn-unlock') as HTMLButtonElement; + const btnSettings = container.querySelector('#btn-settings') as HTMLButtonElement; + + async function doUnlock() { + const passphrase = input.value; + if (!passphrase) return; + + errorEl.textContent = ''; + spinnerEl.style.display = 'block'; + btnUnlock.disabled = true; + + const resp = await sendMessage({ type: 'unlock', passphrase }); + spinnerEl.style.display = 'none'; + btnUnlock.disabled = false; + + if ('error' in resp) { + errorEl.textContent = resp.error; + input.value = ''; + input.focus(); + } else { + navigate({ view: 'list' }); + } + } + + input.addEventListener('keydown', (e) => { + if (e.key === 'Enter') doUnlock(); + if (e.key === 'Escape') window.close(); + }); + + btnUnlock.addEventListener('click', doUnlock); + btnSettings.addEventListener('click', () => navigate({ view: 'setup' })); +} +``` + +- [ ] **Step 3: Create entry list component** + +Create `extension/src/popup/components/entry-list.ts`: + +```typescript +import { sendMessage, navigate } from '../popup'; +import type { ManifestEntry } from '../../shared/types'; + +export async function renderEntryList(container: HTMLElement) { + container.innerHTML = ` +
+
idfoto
+
🔓 unlocked
+
+ +
+
+
Loading...
+
+
↑↓ navigate · ENTER open · / search · + add
+ `; + + const searchInput = container.querySelector('#search') as HTMLInputElement; + const groupTabsEl = container.querySelector('#group-tabs') as HTMLElement; + const listEl = container.querySelector('#entry-list') as HTMLElement; + + let entries: Array<{ id: string } & ManifestEntry> = []; + let selectedIndex = 0; + let activeGroup: string | undefined; + + async function loadEntries() { + const resp = await sendMessage({ type: 'list_entries', group: activeGroup }); + if ('error' in resp) return; + entries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries; + entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + selectedIndex = 0; + renderList(); + renderGroupTabs(); + } + + function renderGroupTabs() { + // Get all groups from entries (fetch all, not filtered) + sendMessage({ type: 'list_entries' }).then((resp) => { + if ('error' in resp) return; + const allEntries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries; + const groups = new Set(); + allEntries.forEach((e) => { if (e.group) groups.add(e.group); }); + + let html = ``; + for (const g of Array.from(groups).sort()) { + html += ``; + } + groupTabsEl.innerHTML = html; + + groupTabsEl.querySelectorAll('.group-tab').forEach((btn) => { + btn.addEventListener('click', () => { + const g = (btn as HTMLElement).dataset.group; + activeGroup = g || undefined; + loadEntries(); + }); + }); + }); + } + + function renderList() { + if (entries.length === 0) { + listEl.innerHTML = '
No entries found.
'; + return; + } + + listEl.innerHTML = entries + .map((e, i) => { + const domain = e.url ? extractDomain(e.url) : ''; + const meta = [e.username, domain].filter(Boolean).join(' · '); + return `
+
${escapeHtml(e.name)}
+ +
`; + }) + .join(''); + + listEl.querySelectorAll('.entry-row').forEach((row) => { + row.addEventListener('click', () => { + const id = (row as HTMLElement).dataset.id!; + navigate({ view: 'detail', entryId: id }); + }); + }); + } + + function handleSearch() { + const query = searchInput.value.trim(); + if (!query) { + loadEntries(); + return; + } + sendMessage({ type: 'search_entries', query }).then((resp) => { + if ('error' in resp) return; + entries = (resp as { ok: true; entries: Array<{ id: string } & ManifestEntry> }).entries; + entries.sort((a, b) => a.name.toLowerCase().localeCompare(b.name.toLowerCase())); + selectedIndex = 0; + renderList(); + }); + } + + searchInput.addEventListener('input', handleSearch); + + document.addEventListener('keydown', function handler(e) { + // Clean up when we navigate away + if (!container.isConnected) { + document.removeEventListener('keydown', handler); + return; + } + + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (selectedIndex < entries.length - 1) { + selectedIndex++; + renderList(); + } + } else if (e.key === 'ArrowUp') { + e.preventDefault(); + if (selectedIndex > 0) { + selectedIndex--; + renderList(); + } + } else if (e.key === 'Enter' && document.activeElement !== searchInput) { + if (entries[selectedIndex]) { + navigate({ view: 'detail', entryId: entries[selectedIndex].id }); + } + } else if (e.key === '/' && document.activeElement !== searchInput) { + e.preventDefault(); + searchInput.focus(); + } else if (e.key === '+' && document.activeElement !== searchInput) { + e.preventDefault(); + navigate({ view: 'add' }); + } else if (e.key === 'Escape') { + if (document.activeElement === searchInput) { + searchInput.value = ''; + searchInput.blur(); + loadEntries(); + } else { + window.close(); + } + } + }); + + await loadEntries(); +} + +function extractDomain(url: string): string { + try { + return new URL(url).hostname; + } catch { + return url; + } +} + +function escapeHtml(s: string): string { + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; +} +``` + +- [ ] **Step 4: Create entry detail component** + +Create `extension/src/popup/components/entry-detail.ts`: + +```typescript +import { sendMessage, navigate } from '../popup'; +import type { Entry } from '../../shared/types'; + +export async function renderEntryDetail(container: HTMLElement, entryId: string) { + container.innerHTML = ` +
+ ← ESC back + +
+
Loading...
+
+
f fill · e edit · d delete
+ `; + + const backBtn = container.querySelector('#back') as HTMLElement; + const nameEl = container.querySelector('#entry-name') as HTMLElement; + const groupEl = container.querySelector('#entry-group') as HTMLElement; + const fieldsEl = container.querySelector('#entry-fields') as HTMLElement; + + backBtn.addEventListener('click', () => navigate({ view: 'list' })); + + const resp = await sendMessage({ type: 'get_entry', id: entryId }); + if ('error' in resp) { + nameEl.textContent = 'Error loading entry'; + return; + } + + const entry = (resp as { ok: true; entry: Entry }).entry; + nameEl.textContent = entry.name; + groupEl.textContent = entry.group ?? ''; + + let fieldsHtml = ''; + + if (entry.url) { + fieldsHtml += ` +
+
URL
+
${escapeHtml(entry.url)}
+
`; + } + + if (entry.username) { + fieldsHtml += ` +
+
USERNAME
+
+ ${escapeHtml(entry.username)} + c copy +
+
`; + } + + fieldsHtml += ` +
+
PASSWORD
+
+ •••••••••• + p copy +
+
`; + + if (entry.totp_secret) { + fieldsHtml += ` +
+
TOTP
+
+ ------ + --s · t copy +
+
+
`; + } + + if (entry.notes) { + fieldsHtml += ` +
+
NOTES
+
${escapeHtml(entry.notes)}
+
`; + } + + fieldsEl.innerHTML = fieldsHtml; + + // ─── TOTP countdown ──────────────────────────────────────────────────── + + let totpInterval: ReturnType | null = null; + + if (entry.totp_secret) { + async function updateTotp() { + const totpResp = await sendMessage({ type: 'get_totp', id: entryId }); + if ('error' in totpResp) return; + const { code, remaining_seconds } = totpResp as { ok: true; code: string; remaining_seconds: number }; + + const codeEl = container.querySelector('#totp-code'); + const remainEl = container.querySelector('#totp-remaining'); + const barEl = container.querySelector('#totp-bar-fill') as HTMLElement | null; + + if (codeEl) codeEl.textContent = code.slice(0, 3) + ' ' + code.slice(3); + if (remainEl) remainEl.textContent = String(remaining_seconds); + if (barEl) barEl.style.width = `${(remaining_seconds / 30) * 100}%`; + } + + await updateTotp(); + totpInterval = setInterval(updateTotp, 1000); + } + + // ─── Copy handlers ──────────────────────────────────────────────────── + + async function copyToClipboard(text: string) { + await navigator.clipboard.writeText(text); + } + + container.querySelectorAll('[data-copy]').forEach((el) => { + el.addEventListener('click', async () => { + const field = (el as HTMLElement).dataset.copy; + if (field === 'username' && entry.username) { + await copyToClipboard(entry.username); + } else if (field === 'password') { + await copyToClipboard(entry.password); + } else if (field === 'totp') { + const totpResp = await sendMessage({ type: 'get_totp', id: entryId }); + if (!('error' in totpResp)) { + await copyToClipboard((totpResp as { ok: true; code: string }).code); + } + } + }); + }); + + // ─── Keyboard shortcuts ─────────────────────────────────────────────── + + document.addEventListener('keydown', function handler(e) { + if (!container.isConnected) { + if (totpInterval) clearInterval(totpInterval); + document.removeEventListener('keydown', handler); + return; + } + + if (e.key === 'Escape') { + navigate({ view: 'list' }); + } else if (e.key === 'c') { + if (entry.username) copyToClipboard(entry.username); + } else if (e.key === 'p') { + copyToClipboard(entry.password); + } else if (e.key === 't' && entry.totp_secret) { + sendMessage({ type: 'get_totp', id: entryId }).then((resp) => { + if (!('error' in resp)) { + copyToClipboard((resp as { ok: true; code: string }).code); + } + }); + } else if (e.key === 'e') { + navigate({ view: 'edit', entryId }); + } else if (e.key === 'f') { + // Autofill active tab + chrome.tabs.query({ active: true, currentWindow: true }, async (tabs) => { + if (!tabs[0]?.id) return; + const creds = await sendMessage({ type: 'get_credentials', id: entryId }); + if ('error' in creds) return; + const { username, password } = creds as { ok: true; username: string; password: string }; + chrome.tabs.sendMessage(tabs[0].id, { type: 'fill_credentials', username, password }); + }); + } else if (e.key === 'd') { + showDeleteConfirm(container, entryId, entry.name); + } + }); +} + +function showDeleteConfirm(container: HTMLElement, entryId: string, name: string) { + const overlay = document.createElement('div'); + overlay.className = 'confirm-overlay'; + overlay.innerHTML = ` +
+

Delete ${escapeHtml(name)}?

+

This commits a removal to the vault.

+
+ + +
+
+ `; + container.appendChild(overlay); + + overlay.querySelector('#confirm-no')!.addEventListener('click', () => overlay.remove()); + overlay.querySelector('#confirm-yes')!.addEventListener('click', async () => { + await sendMessage({ type: 'delete_entry', id: entryId }); + navigate({ view: 'list' }); + }); +} + +function escapeHtml(s: string): string { + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; +} +``` + +- [ ] **Step 5: Create entry form component (add/edit)** + +Create `extension/src/popup/components/entry-form.ts`: + +```typescript +import { sendMessage, navigate } from '../popup'; +import type { Entry } from '../../shared/types'; + +export async function renderEntryForm(container: HTMLElement, entryId: string | null) { + const isEdit = entryId !== null; + let existing: Entry | null = null; + + if (isEdit) { + const resp = await sendMessage({ type: 'get_entry', id: entryId }); + if ('error' in resp) { + container.innerHTML = '
Failed to load entry
'; + return; + } + existing = (resp as { ok: true; entry: Entry }).entry; + } + + container.innerHTML = ` +
+ ← ESC back + ${isEdit ? 'edit' : 'add'} +
+
+
NAME *
+ +
+
+
URL
+ +
+
+
USERNAME
+ +
+
+
PASSWORD
+
+ + +
+
+
+
TOTP SECRET
+ +
+
+
GROUP
+ +
+
+
NOTES
+ +
+
+
+ + +
+ `; + + const backBtn = container.querySelector('#back') as HTMLElement; + const btnCancel = container.querySelector('#btn-cancel') as HTMLButtonElement; + const btnSave = container.querySelector('#btn-save') as HTMLButtonElement; + const btnGenerate = container.querySelector('#btn-generate') as HTMLButtonElement; + const errorEl = container.querySelector('#form-error') as HTMLElement; + + function goBack() { + if (isEdit) { + navigate({ view: 'detail', entryId: entryId! }); + } else { + navigate({ view: 'list' }); + } + } + + backBtn.addEventListener('click', goBack); + btnCancel.addEventListener('click', goBack); + + btnGenerate.addEventListener('click', async () => { + const resp = await sendMessage({ type: 'is_unlocked' }); // Just to trigger WASM load + // Generate locally via service worker — but we can just use crypto.getRandomValues + const chars = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+'; + const array = new Uint8Array(24); + crypto.getRandomValues(array); + const password = Array.from(array, (b) => chars[b % chars.length]).join(''); + (container.querySelector('#f-password') as HTMLInputElement).value = password; + }); + + btnSave.addEventListener('click', async () => { + const name = (container.querySelector('#f-name') as HTMLInputElement).value.trim(); + if (!name) { + errorEl.textContent = 'Name is required'; + return; + } + + const entry = { + name, + url: (container.querySelector('#f-url') as HTMLInputElement).value.trim() || undefined, + username: (container.querySelector('#f-username') as HTMLInputElement).value.trim() || undefined, + password: (container.querySelector('#f-password') as HTMLInputElement).value, + totp_secret: (container.querySelector('#f-totp') as HTMLInputElement).value.trim() || undefined, + group: (container.querySelector('#f-group') as HTMLInputElement).value.trim() || undefined, + notes: (container.querySelector('#f-notes') as HTMLTextAreaElement).value.trim() || undefined, + }; + + btnSave.disabled = true; + errorEl.textContent = ''; + + let resp; + if (isEdit) { + resp = await sendMessage({ type: 'update_entry', id: entryId!, entry }); + } else { + resp = await sendMessage({ type: 'add_entry', entry }); + } + + if ('error' in resp) { + errorEl.textContent = resp.error; + btnSave.disabled = false; + return; + } + + if (isEdit) { + navigate({ view: 'detail', entryId: entryId! }); + } else { + navigate({ view: 'list' }); + } + }); + + document.addEventListener('keydown', function handler(e) { + if (!container.isConnected) { + document.removeEventListener('keydown', handler); + return; + } + if (e.key === 'Escape') goBack(); + }); +} + +function escapeHtml(s: string): string { + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; +} + +function escapeAttr(s: string): string { + return s.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); +} +``` + +- [ ] **Step 6: Create setup wizard component** + +Create `extension/src/popup/components/setup-wizard.ts`: + +```typescript +import { sendMessage, navigate } from '../popup'; +import type { VaultConfig } from '../../shared/types'; + +export function renderSetupWizard(container: HTMLElement) { + let step = 1; + let config: Partial = { hostType: 'gitea' }; + let imageBase64: string | null = null; + + function render() { + container.innerHTML = ` +
idfoto setup
+
step ${step} of 3 — ${['repository', 'reference image', 'test unlock'][step - 1]}
+
+
+
+
+ `; + const content = container.querySelector('#wizard-content') as HTMLElement; + const actions = container.querySelector('#wizard-actions') as HTMLElement; + + if (step === 1) renderStep1(content, actions); + else if (step === 2) renderStep2(content, actions); + else if (step === 3) renderStep3(content, actions); + } + + function renderStep1(content: HTMLElement, actions: HTMLElement) { + content.innerHTML = ` +
+
HOST TYPE
+
+ + +
+
+
+
HOST URL
+ +
+
+
REPO PATH
+ +
+
+
API TOKEN
+ +
+ `; + actions.innerHTML = ``; + + content.querySelectorAll('[data-host]').forEach((btn) => { + btn.addEventListener('click', () => { + config.hostType = (btn as HTMLElement).dataset.host as 'gitea' | 'github'; + render(); + }); + }); + + actions.querySelector('#btn-next')!.addEventListener('click', () => { + config.hostUrl = (content.querySelector('#w-host') as HTMLInputElement).value.trim(); + config.repoPath = (content.querySelector('#w-repo') as HTMLInputElement).value.trim(); + config.apiToken = (content.querySelector('#w-token') as HTMLInputElement).value.trim(); + + const errorEl = container.querySelector('#wizard-error') as HTMLElement; + if (!config.hostUrl || !config.repoPath || !config.apiToken) { + errorEl.textContent = 'All fields are required'; + return; + } + step = 2; + render(); + }); + } + + function renderStep2(content: HTMLElement, actions: HTMLElement) { + content.innerHTML = ` +
+
REFERENCE IMAGE
+

+ Upload the JPEG with your embedded secret. Stored locally, never sent to the server. +

+ +
+ ${imageBase64 ? '✓ Image loaded' : 'No image selected'} +
+
+ `; + actions.innerHTML = ` + + + `; + + const fileInput = content.querySelector('#w-image') as HTMLInputElement; + const statusEl = content.querySelector('#image-status') as HTMLElement; + + fileInput.addEventListener('change', () => { + const file = fileInput.files?.[0]; + if (!file) return; + const reader = new FileReader(); + reader.onload = () => { + const dataUrl = reader.result as string; + // Strip the data:image/jpeg;base64, prefix + imageBase64 = dataUrl.split(',')[1]; + statusEl.textContent = `✓ ${file.name} (${(file.size / 1024).toFixed(0)} KB)`; + }; + reader.readAsDataURL(file); + }); + + actions.querySelector('#btn-back')!.addEventListener('click', () => { step = 1; render(); }); + actions.querySelector('#btn-next')!.addEventListener('click', () => { + const errorEl = container.querySelector('#wizard-error') as HTMLElement; + if (!imageBase64) { + errorEl.textContent = 'Please select a reference image'; + return; + } + step = 3; + render(); + }); + } + + function renderStep3(content: HTMLElement, actions: HTMLElement) { + content.innerHTML = ` +
+
TEST UNLOCK
+

+ Enter your passphrase to verify everything works. +

+ + +
+ `; + actions.innerHTML = ` + + + `; + + actions.querySelector('#btn-back')!.addEventListener('click', () => { step = 2; render(); }); + actions.querySelector('#btn-finish')!.addEventListener('click', async () => { + const passphrase = (content.querySelector('#w-passphrase') as HTMLInputElement).value; + const errorEl = container.querySelector('#wizard-error') as HTMLElement; + const spinnerEl = content.querySelector('#test-spinner') as HTMLElement; + + if (!passphrase) { + errorEl.textContent = 'Enter your passphrase'; + return; + } + + errorEl.textContent = ''; + spinnerEl.style.display = 'block'; + + // Save config first + await sendMessage({ + type: 'save_setup', + config: config as VaultConfig, + imageBase64: imageBase64!, + }); + + // Try to unlock + const resp = await sendMessage({ type: 'unlock', passphrase }); + spinnerEl.style.display = 'none'; + + if ('error' in resp) { + errorEl.textContent = `Test failed: ${resp.error}`; + return; + } + + navigate({ view: 'list' }); + }); + } + + render(); +} +``` + +- [ ] **Step 7: Install npm dependencies** + +```bash +cd extension && npm install +``` + +Expected: `node_modules/` created with TypeScript, webpack, etc. + +- [ ] **Step 8: Build the extension** + +```bash +cd extension && npm run build +``` + +Expected: `dist/` directory with `service-worker.js`, `popup.js`, `content.js`, `popup.html`, `styles.css`, `manifest.json`, WASM files, and icons. + +If build fails, fix any TypeScript errors and retry. + +- [ ] **Step 9: Commit** + +```bash +git add extension/src/popup/ extension/src/service-worker/index.ts +git commit -m "feat: add popup UI with state machine, all components, and setup wizard" +``` + +--- + +## Task 10: Content Script — Form Detection and Autofill + +**Files:** +- Create: `extension/src/content/detector.ts` +- Create: `extension/src/content/fill.ts` +- Create: `extension/src/content/icon.ts` + +- [ ] **Step 1: Create the form detector and content script entry point** + +Create `extension/src/content/detector.ts`: + +```typescript +import { injectFieldIcons } from './icon'; +import { setupFillListener } from './fill'; + +function detectLoginForms(): { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[] { + const passwordFields = document.querySelectorAll('input[type="password"]'); + const results: { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[] = []; + + for (const pwField of passwordFields) { + if (!pwField.offsetParent) continue; // skip hidden fields + + const usernameField = findUsernameField(pwField); + results.push({ passwordField: pwField, usernameField }); + } + + return results; +} + +function findUsernameField(passwordField: HTMLInputElement): HTMLInputElement | null { + const form = passwordField.closest('form'); + const scope = form ?? document; + + // Priority 1: autocomplete="username" + const byAutocompleteUser = scope.querySelector('input[autocomplete="username"]'); + if (byAutocompleteUser) return byAutocompleteUser; + + // Priority 2: autocomplete="email" + const byAutocompleteEmail = scope.querySelector('input[autocomplete="email"]'); + if (byAutocompleteEmail) return byAutocompleteEmail; + + // Priority 3: type="email" + const byTypeEmail = scope.querySelector('input[type="email"]'); + if (byTypeEmail) return byTypeEmail; + + // Priority 4: name matching pattern + const byNamePattern = scope.querySelector( + 'input[name*="user" i], input[name*="email" i], input[name*="login" i], input[name*="account" i]' + ); + if (byNamePattern && byNamePattern !== passwordField) return byNamePattern; + + // Priority 5: nearest preceding text input in same form + if (form) { + const inputs = Array.from(form.querySelectorAll('input[type="text"], input[type="email"], input:not([type])')); + const pwIndex = Array.from(form.querySelectorAll('input')).indexOf(passwordField); + for (let i = inputs.length - 1; i >= 0; i--) { + const inp = inputs[i]; + const inpIndex = Array.from(form.querySelectorAll('input')).indexOf(inp); + if (inpIndex < pwIndex) return inp; + } + } + + return null; +} + +// ─── Init ─────────────────────────────────────────────────────────────────── + +function init() { + const forms = detectLoginForms(); + if (forms.length > 0) { + injectFieldIcons(forms); + } + setupFillListener(); +} + +// Run detection after DOM is ready +if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); +} else { + init(); +} + +// Re-detect on DOM changes (for SPAs that load forms dynamically) +const observer = new MutationObserver(() => { + const forms = detectLoginForms(); + if (forms.length > 0) { + injectFieldIcons(forms); + } +}); +observer.observe(document.body, { childList: true, subtree: true }); +``` + +- [ ] **Step 2: Create the fill module** + +Create `extension/src/content/fill.ts`: + +```typescript +export function setupFillListener() { + chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message.type === 'fill_credentials') { + fillFields(message.username, message.password); + sendResponse({ ok: true }); + } + return false; + }); +} + +export function fillFields(username: string, password: string) { + const passwordField = document.querySelector('input[type="password"]'); + if (!passwordField) return; + + // Fill password + setInputValue(passwordField, password); + + // Find and fill username + const form = passwordField.closest('form'); + const scope = form ?? document; + + const usernameField = + scope.querySelector('input[autocomplete="username"]') ?? + scope.querySelector('input[autocomplete="email"]') ?? + scope.querySelector('input[type="email"]') ?? + scope.querySelector( + 'input[name*="user" i], input[name*="email" i], input[name*="login" i]' + ); + + if (usernameField && username) { + setInputValue(usernameField, username); + } + + // Focus submit button or password field + const submitBtn = form?.querySelector('button[type="submit"], input[type="submit"]'); + if (submitBtn) { + submitBtn.focus(); + } else { + passwordField.focus(); + } +} + +function setInputValue(input: HTMLInputElement, value: string) { + // Use native setter to bypass React/Vue/Angular controlled component checks + const nativeSetter = Object.getOwnPropertyDescriptor(HTMLInputElement.prototype, 'value')?.set; + if (nativeSetter) { + nativeSetter.call(input, value); + } else { + input.value = value; + } + + // Dispatch events that frameworks listen for + input.dispatchEvent(new Event('input', { bubbles: true })); + input.dispatchEvent(new Event('change', { bubbles: true })); +} +``` + +- [ ] **Step 3: Create the field icon module** + +Create `extension/src/content/icon.ts`: + +```typescript +import type { ManifestEntry } from '../shared/types'; +import { fillFields } from './fill'; + +const ICON_SVG = ` + + id +`; + +const injectedFields = new WeakSet(); + +export function injectFieldIcons( + forms: { passwordField: HTMLInputElement; usernameField: HTMLInputElement | null }[] +) { + for (const { passwordField, usernameField } of forms) { + if (injectedFields.has(passwordField)) continue; + injectedFields.add(passwordField); + + const icon = document.createElement('div'); + icon.innerHTML = ICON_SVG; + icon.style.cssText = ` + position: absolute; + right: 8px; + top: 50%; + transform: translateY(-50%); + cursor: pointer; + z-index: 10000; + opacity: 0.8; + transition: opacity 0.2s; + `; + icon.addEventListener('mouseenter', () => { icon.style.opacity = '1'; }); + icon.addEventListener('mouseleave', () => { icon.style.opacity = '0.8'; }); + + // Wrap the password field in a relative container if needed + const parent = passwordField.parentElement; + if (parent) { + const computed = getComputedStyle(parent); + if (computed.position === 'static') { + parent.style.position = 'relative'; + } + parent.appendChild(icon); + } + + icon.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + + const url = window.location.href; + const resp = await chrome.runtime.sendMessage({ type: 'get_autofill_candidates', url }); + + if ('error' in resp || !resp.candidates || resp.candidates.length === 0) { + // No matches — open popup + return; + } + + const candidates = resp.candidates as Array<{ id: string } & ManifestEntry>; + + if (candidates.length === 1) { + // Single match — fill immediately + const creds = await chrome.runtime.sendMessage({ type: 'get_credentials', id: candidates[0].id }); + if (!('error' in creds)) { + fillFields(creds.username, creds.password); + } + } else { + // Multiple matches — show picker + showPicker(icon, candidates, usernameField, passwordField); + } + }); + } +} + +function showPicker( + anchor: HTMLElement, + candidates: Array<{ id: string } & ManifestEntry>, + usernameField: HTMLInputElement | null, + passwordField: HTMLInputElement +) { + // Remove any existing picker + document.querySelectorAll('.idfoto-picker').forEach((el) => el.remove()); + + const picker = document.createElement('div'); + picker.className = 'idfoto-picker'; + picker.style.cssText = ` + position: absolute; + right: 0; + top: 100%; + margin-top: 4px; + background: #161b22; + border: 1px solid #30363d; + border-radius: 4px; + min-width: 200px; + z-index: 10001; + font-family: monospace; + font-size: 12px; + box-shadow: 0 4px 12px rgba(0,0,0,0.4); + `; + + for (const candidate of candidates) { + const row = document.createElement('div'); + row.style.cssText = ` + padding: 6px 10px; + cursor: pointer; + color: #c9d1d9; + border-bottom: 1px solid #21262d; + `; + row.addEventListener('mouseenter', () => { row.style.background = '#21262d'; }); + row.addEventListener('mouseleave', () => { row.style.background = 'transparent'; }); + row.innerHTML = ` +
${escapeHtml(candidate.name)}
+
${escapeHtml(candidate.username ?? '')}
+ `; + row.addEventListener('click', async () => { + const creds = await chrome.runtime.sendMessage({ type: 'get_credentials', id: candidate.id }); + if (!('error' in creds)) { + fillFields(creds.username, creds.password); + } + picker.remove(); + }); + picker.appendChild(row); + } + + anchor.parentElement?.appendChild(picker); + + // Close picker on outside click + document.addEventListener('click', function handler(e) { + if (!picker.contains(e.target as Node) && e.target !== anchor) { + picker.remove(); + document.removeEventListener('click', handler); + } + }); +} + +function escapeHtml(s: string): string { + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; +} +``` + +- [ ] **Step 4: Update webpack config entry for content script** + +The webpack config should bundle all three content script files together. Update the content entry in `extension/webpack.config.js`: + +The entry `content: './src/content/detector.ts'` already imports `icon.ts` and `fill.ts`, so webpack will bundle them together. No change needed. + +- [ ] **Step 5: Build and verify** + +```bash +cd extension && npm run build +``` + +Expected: `dist/content.js` contains the bundled content script. + +- [ ] **Step 6: Commit** + +```bash +git add extension/src/content/ +git commit -m "feat: add content script with form detection, field icon, and autofill" +``` + +--- + +## Task 11: Build Integration and Manual Test + +**Files:** +- Modify: `extension/webpack.config.js` (if needed) +- Create: `Makefile` (optional convenience) + +- [ ] **Step 1: Full build from scratch** + +```bash +# Build WASM +wasm-pack build crates/idfoto-wasm --target web --out-dir ../../extension/wasm + +# Install deps and build extension +cd extension && npm install && npm run build +``` + +Expected: `extension/dist/` contains all files needed to load as an unpacked Chrome extension. + +- [ ] **Step 2: Note the WASM binary size** + +```bash +ls -lh extension/wasm/idfoto_wasm_bg.wasm +ls -lh extension/dist/idfoto_wasm_bg.wasm +``` + +Record the size for reference. If >2 MB uncompressed, consider optimizing later. + +- [ ] **Step 3: Load in Chrome** + +Open `chrome://extensions/`, enable Developer Mode, click "Load unpacked", select `extension/dist/`. Verify: +- Extension appears in the toolbar +- Clicking the icon opens the popup +- Setup wizard renders correctly +- No console errors in the service worker (inspect via "Service Worker" link on the extensions page) + +- [ ] **Step 4: Test the full flow manually** + +1. Setup wizard: enter Gitea/GitHub host, repo, token, upload reference image +2. Test unlock: enter passphrase, verify spinner + successful unlock +3. Entry list: verify entries load from remote vault +4. Add entry: create a new entry with a group +5. Entry detail: verify TOTP countdown (if entry has TOTP), copy shortcuts +6. Autofill: navigate to a login page, verify field icon appears, click to fill +7. Lock/re-unlock: close popup, wait, reopen — verify re-unlock is required + +- [ ] **Step 5: Fix any issues found during manual testing** + +Address any bugs or UX issues discovered during manual testing. Each fix gets its own commit. + +- [ ] **Step 6: Final commit** + +```bash +git add -A +git commit -m "feat: complete WASM + Chrome MV3 extension build" +``` + +--- + +## Task Summary + +| Task | Description | Dependencies | +|------|-------------|--------------| +| 1 | Add `group` field to core data model | None | +| 2 | Create `idfoto-wasm` crate | Task 1 | +| 3 | Extension scaffolding | Task 2 | +| 4 | Shared types and messages | Task 3 | +| 5 | Git API layer | Task 4 | +| 6 | Service worker — vault operations | Task 4, 5 | +| 7 | Service worker — main entry point | Task 5, 6 | +| 8 | Popup styles | Task 3 | +| 9 | Popup state machine and components | Task 4, 7, 8 | +| 10 | Content script | Task 4 | +| 11 | Build integration and manual test | All | + +Tasks 3-4, 5, 8 can be parallelized. Tasks 6-7 are sequential. Task 9 depends on 7+8. Task 10 is independent of 9. Task 11 is the final integration.