# 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 0: Add Heavy Comments to Existing Rust Code **Files:** - Modify: `crates/idfoto-core/src/lib.rs` - Modify: `crates/idfoto-core/src/error.rs` - Modify: `crates/idfoto-core/src/crypto.rs` - Modify: `crates/idfoto-core/src/entry.rs` - Modify: `crates/idfoto-core/src/vault.rs` - Modify: `crates/idfoto-core/src/imgsecret.rs` - Modify: `crates/idfoto-cli/src/main.rs` Add thorough documentation comments to all existing Rust code. Every public function, struct, field, constant, and non-trivial private function should have doc comments explaining what it does, why it exists, and any important constraints. Module-level docs should explain the module's role in the overall architecture. Guidelines: - Use `///` for public items and `//` for inline explanations of non-obvious logic - Explain the "why" not just the "what" — e.g., why XChaCha20 over AES-GCM, why mid-frequency DCT coefficients, why majority voting - Reference the crypto pipeline and threat model from the design spec where relevant - Document parameters, return values, and error conditions - For `imgsecret.rs`, explain the steganography algorithm step by step — this is the novel component and should read like a tutorial - For `crypto.rs`, document the binary format and why each field exists - For `entry.rs`, document the vault data model and serialization strategy - For `main.rs`, document the CLI flow and unlock sequence - [ ] **Step 1: Add module-level docs and comments to `lib.rs`** ```rust //! # idfoto-core //! //! Platform-agnostic core library for the idfoto password manager. //! //! This crate is deliberately bytes-in/bytes-out — no filesystem, no network, //! no git operations. This makes it portable to WASM (browser extension), //! Android (JNI), and iOS (Swift bridge) without modification. //! //! ## Modules //! //! - [`crypto`] — Argon2id key derivation + XChaCha20-Poly1305 authenticated encryption //! - [`entry`] — Entry, ManifestEntry, and Manifest data structures //! - [`vault`] — High-level encrypt/decrypt operations for entries and manifests //! - [`imgsecret`] — DCT-based 256-bit secret embedding in JPEG images //! - [`error`] — Error types for all operations ``` - [ ] **Step 2: Add comments to `error.rs`** Document each error variant with when it occurs and what the caller should do about it. - [ ] **Step 3: Add comments to `crypto.rs`** Document: - The binary ciphertext format (version byte + nonce + ciphertext + tag) and why each field exists - Why XChaCha20-Poly1305 was chosen over AES-GCM (192-bit nonce eliminates collision risk, fast in WASM/ARM without AES-NI) - The KDF pipeline: how passphrase + image_secret are concatenated as the Argon2id password input - KdfParams and why they're configurable (future parameter upgrades, different hardware profiles) - Constants and their significance (VERSION_BYTE, NONCE_LEN, TAG_LEN, HEADER_LEN) - [ ] **Step 4: Add comments to `entry.rs`** Document: - The vault data model: Entry (full credential), ManifestEntry (index metadata), Manifest (entry index) - Why the manifest exists (decrypt one file to list/search entries without decrypting every entry) - `skip_serializing_if` strategy for optional fields (backwards compatibility, compact JSON) - Entry ID format (random 8-char hex, 32 bits — sufficient for family vault sizes) - The search implementation and its case-insensitive substring matching - [ ] **Step 5: Add comments to `vault.rs`** Document: - The relationship between vault.rs and crypto.rs (vault = typed wrappers around raw encrypt/decrypt) - The serialization path: struct → JSON → encrypt → ciphertext bytes (and reverse) - Why a single master_key is used for both entries and manifest (simpler, sufficient for family vault sizes) - [ ] **Step 6: Add comments to `imgsecret.rs`** This is the technically novel component. Document heavily: - Module-level doc explaining the DCT steganography approach and why it was chosen - Constants: BLOCK_SIZE, QUANT_STEP (why 50.0 — higher than typical 25 to survive JPEG recompression), MIN_DIMENSION, SECRET_BITS, MIN_COPIES, BLOCKS_PER_COPY, EMBED_POSITIONS (mid-frequency DCT coefficients in zig-zag order) - YChannel: what it represents, why only luminance (survives re-encoding best, Cb/Cr often subsampled) - EmbedRegion: the central 70% concept, the 15% crumple zone for crop tolerance - DCT functions: what DCT is, why 8x8 blocks (matches JPEG's own block size), the 2D DCT as row-then-column 1D DCTs - QIM (Quantization Index Modulation): how it encodes bits by rounding to quantization grids, why it's robust to re-quantization - The embedding process step by step: decode → extract Y → define region → block DCT → select blocks → encode with redundancy → QIM embed → reconstruct - The extraction process: canonical try → crop recovery search → majority voting with confidence threshold - Bit conversion helpers - Block selection strategy (evenly spaced, fixed geometric pattern) - The reconstruct_jpeg function and the YCbCr ↔ RGB color space conversion - [ ] **Step 7: Add comments to `main.rs` (CLI)** Document: - Module-level doc explaining this is the platform layer (filesystem, git, terminal I/O) - The unlock flow: passphrase prompt → read reference image → extract image_secret → read salt/params → derive master_key - Each CLI command's purpose and flow - Helper functions: why git_commit uses `git add -A`, why generate_password uses OsRng, why now_iso8601 uses UNIX epoch seconds - Device management: ed25519 key generation, why device keys are separate from the KDF - [ ] **Step 8: Run tests to verify comments didn't break anything** Run: `cargo test` Expected: All tests pass unchanged. - [ ] **Step 9: Commit** ```bash git add crates/idfoto-core/src/ crates/idfoto-cli/src/main.rs git commit -m "docs: add heavy documentation comments to all Rust code" ``` --- ## 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 | |------|-------------|--------------| | 0 | Add heavy comments to existing Rust code | None | | 1 | Add `group` field to core data model | Task 0 | | 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.