Files
relicario/docs/superpowers/plans/2026-04-12-idfoto-wasm-extension.md
adlee-was-taken 01d5fd5d0d docs: add WASM + Chrome MV3 extension implementation plan
11 tasks covering core data model changes, WASM crate with TOTP,
extension scaffolding, git API layer, service worker, popup UI
with terminal aesthetic, content script autofill, and build integration.

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

3180 lines
97 KiB
Markdown

# 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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
pub password: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub notes: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub totp_secret: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
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<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub username: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub group: Option<String>,
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<Vec<u8>, 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, &params)
.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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<Vec<u8>, 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<String, JsValue> {
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<Vec<u8>, 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<String, JsValue> {
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<String, JsValue> {
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::<Sha1>::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": ["<all_urls>"],
"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": ["<all_urls>"],
"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
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=360">
<link rel="stylesheet" href="styles.css">
<title>idfoto</title>
</head>
<body>
<div id="app"></div>
<script src="popup.js"></script>
</body>
</html>
```
- [ ] **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<string, ManifestEntry>;
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<Entry, 'created_at' | 'updated_at'> }
| { type: 'update_entry'; id: string; entry: Omit<Entry, 'created_at' | 'updated_at'> }
| { 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<Uint8Array>;
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
deleteFile(path: string, message: string): Promise<void>;
listDir(path: string): Promise<string[]>;
}
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<Uint8Array>;
writeFile(path: string, content: Uint8Array, message: string): Promise<void>;
deleteFile(path: string, message: string): Promise<void>;
listDir(path: string): Promise<string[]>;
}
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<Uint8Array> {
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<void> {
// 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<string, string> = {
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<void> {
// 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<string[]> {
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<Uint8Array> {
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<void> {
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<string, string> = {
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<void> {
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<string[]> {
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<Manifest> {
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<Entry> {
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<void> {
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<void> {
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<typeof import('../../wasm/idfoto_wasm')> {
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<void> {
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<Response> {
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<Response> {
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 = `
<div class="brand">idfoto</div>
<div style="margin-top: 16px">
<div class="label">PASSPHRASE</div>
<input type="password" id="passphrase" placeholder="Enter passphrase..." autofocus>
<div id="unlock-error" class="error" style="margin-top: 4px"></div>
<div id="unlock-spinner" style="margin-top: 8px; display: none">
<span class="spinner"></span> <span class="secondary">Deriving key...</span>
</div>
</div>
<div class="actions" style="margin-top: 16px">
<button class="btn muted" id="btn-settings">settings</button>
<button class="btn-primary btn" id="btn-unlock">ENTER unlock</button>
</div>
`;
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 = `
<div class="header">
<div class="brand">idfoto</div>
<div class="status">🔓 unlocked</div>
</div>
<div class="search-bar">
<input type="text" id="search" placeholder="/ search...">
</div>
<div class="group-tabs" id="group-tabs"></div>
<div class="entry-list" id="entry-list">
<div class="empty">Loading...</div>
</div>
<div class="keyhints">↑↓ navigate · ENTER open · / search · + add</div>
`;
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<string>();
allEntries.forEach((e) => { if (e.group) groups.add(e.group); });
let html = `<button class="group-tab ${!activeGroup ? 'active' : ''}" data-group="">all</button>`;
for (const g of Array.from(groups).sort()) {
html += `<button class="group-tab ${activeGroup === g ? 'active' : ''}" data-group="${g}">${g}</button>`;
}
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 = '<div class="empty">No entries found.</div>';
return;
}
listEl.innerHTML = entries
.map((e, i) => {
const domain = e.url ? extractDomain(e.url) : '';
const meta = [e.username, domain].filter(Boolean).join(' · ');
return `<div class="entry-row ${i === selectedIndex ? 'selected' : ''}" data-index="${i}" data-id="${e.id}">
<div class="entry-name">${escapeHtml(e.name)}</div>
<div class="entry-meta">${escapeHtml(meta)}</div>
</div>`;
})
.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 = `
<div class="detail-header">
<span class="secondary" style="font-size:11px; cursor:pointer" id="back">← ESC back</span>
<span class="muted" id="entry-group"></span>
</div>
<div class="detail-title" id="entry-name">Loading...</div>
<div id="entry-fields" style="margin-top: 12px"></div>
<div class="keyhints">f fill · e edit · d delete</div>
`;
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 += `
<div class="field">
<div class="label">URL</div>
<div class="secondary">${escapeHtml(entry.url)}</div>
</div>`;
}
if (entry.username) {
fieldsHtml += `
<div class="field">
<div class="label">USERNAME</div>
<div class="field-value">
<span>${escapeHtml(entry.username)}</span>
<span class="hint" data-copy="username">c copy</span>
</div>
</div>`;
}
fieldsHtml += `
<div class="field">
<div class="label">PASSWORD</div>
<div class="field-value">
<span id="pw-display">••••••••••</span>
<span class="hint" data-copy="password">p copy</span>
</div>
</div>`;
if (entry.totp_secret) {
fieldsHtml += `
<div class="field">
<div class="label">TOTP</div>
<div class="field-value">
<span class="totp-code" id="totp-code">------</span>
<span class="hint"><span id="totp-remaining">--</span>s · <span data-copy="totp">t copy</span></span>
</div>
<div class="totp-bar"><div class="totp-bar-fill" id="totp-bar-fill"></div></div>
</div>`;
}
if (entry.notes) {
fieldsHtml += `
<div class="field">
<div class="label">NOTES</div>
<div class="secondary" style="white-space: pre-wrap">${escapeHtml(entry.notes)}</div>
</div>`;
}
fieldsEl.innerHTML = fieldsHtml;
// ─── TOTP countdown ────────────────────────────────────────────────────
let totpInterval: ReturnType<typeof setInterval> | 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 = `
<div class="confirm-box">
<p>Delete <strong>${escapeHtml(name)}</strong>?</p>
<p class="secondary" style="margin-top: 4px; font-size: 11px">This commits a removal to the vault.</p>
<div class="actions" style="margin-top: 12px">
<button class="btn" id="confirm-no">No</button>
<button class="btn btn-danger" id="confirm-yes">Yes, delete</button>
</div>
</div>
`;
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 = '<div class="error">Failed to load entry</div>';
return;
}
existing = (resp as { ok: true; entry: Entry }).entry;
}
container.innerHTML = `
<div class="detail-header">
<span class="secondary" style="font-size:11px; cursor:pointer" id="back">← ESC back</span>
<span class="brand">${isEdit ? 'edit' : 'add'}</span>
</div>
<div class="form-group">
<div class="label">NAME *</div>
<input type="text" id="f-name" value="${escapeAttr(existing?.name ?? '')}" autofocus>
</div>
<div class="form-group">
<div class="label">URL</div>
<input type="text" id="f-url" value="${escapeAttr(existing?.url ?? '')}" placeholder="https://...">
</div>
<div class="form-group">
<div class="label">USERNAME</div>
<input type="text" id="f-username" value="${escapeAttr(existing?.username ?? '')}">
</div>
<div class="form-group">
<div class="label">PASSWORD</div>
<div style="display:flex;gap:4px">
<input type="text" id="f-password" value="${escapeAttr(existing?.password ?? '')}" style="flex:1">
<button class="btn" id="btn-generate" title="Generate">gen</button>
</div>
</div>
<div class="form-group">
<div class="label">TOTP SECRET</div>
<input type="text" id="f-totp" value="${escapeAttr(existing?.totp_secret ?? '')}" placeholder="Base32 encoded">
</div>
<div class="form-group">
<div class="label">GROUP</div>
<input type="text" id="f-group" value="${escapeAttr(existing?.group ?? '')}" placeholder="personal, work, ...">
</div>
<div class="form-group">
<div class="label">NOTES</div>
<textarea id="f-notes" rows="3">${escapeHtml(existing?.notes ?? '')}</textarea>
</div>
<div id="form-error" class="error"></div>
<div class="actions">
<button class="btn" id="btn-cancel">Cancel</button>
<button class="btn btn-primary" id="btn-save">Save</button>
</div>
`;
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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
```
- [ ] **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<VaultConfig> = { hostType: 'gitea' };
let imageBase64: string | null = null;
function render() {
container.innerHTML = `
<div class="brand">idfoto setup</div>
<div class="wizard-step">step ${step} of 3 — ${['repository', 'reference image', 'test unlock'][step - 1]}</div>
<div class="progress-bar"><div class="progress-bar-fill" style="width: ${(step / 3) * 100}%"></div></div>
<div id="wizard-content"></div>
<div id="wizard-error" class="error" style="margin-top: 8px"></div>
<div class="actions" id="wizard-actions"></div>
`;
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 = `
<div class="form-group">
<div class="label">HOST TYPE</div>
<div class="host-toggle">
<button class="group-tab ${config.hostType === 'gitea' ? 'active' : ''}" data-host="gitea">gitea</button>
<button class="group-tab ${config.hostType === 'github' ? 'active' : ''}" data-host="github">github</button>
</div>
</div>
<div class="form-group">
<div class="label">HOST URL</div>
<input type="text" id="w-host" value="${config.hostUrl ?? ''}" placeholder="https://git.example.com">
</div>
<div class="form-group">
<div class="label">REPO PATH</div>
<input type="text" id="w-repo" value="${config.repoPath ?? ''}" placeholder="owner/repo">
</div>
<div class="form-group">
<div class="label">API TOKEN</div>
<input type="password" id="w-token" value="${config.apiToken ?? ''}" placeholder="Paste token here">
</div>
`;
actions.innerHTML = `<button class="btn btn-primary" id="btn-next">Next →</button>`;
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 = `
<div class="form-group">
<div class="label">REFERENCE IMAGE</div>
<p class="secondary" style="margin-bottom: 8px; font-size: 11px">
Upload the JPEG with your embedded secret. Stored locally, never sent to the server.
</p>
<input type="file" id="w-image" accept="image/jpeg">
<div id="image-status" class="secondary" style="margin-top: 4px; font-size: 11px">
${imageBase64 ? '✓ Image loaded' : 'No image selected'}
</div>
</div>
`;
actions.innerHTML = `
<button class="btn" id="btn-back">← Back</button>
<button class="btn btn-primary" id="btn-next">Next →</button>
`;
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 = `
<div class="form-group">
<div class="label">TEST UNLOCK</div>
<p class="secondary" style="margin-bottom: 8px; font-size: 11px">
Enter your passphrase to verify everything works.
</p>
<input type="password" id="w-passphrase" placeholder="Passphrase" autofocus>
<div id="test-spinner" style="margin-top: 8px; display: none">
<span class="spinner"></span> <span class="secondary">Testing...</span>
</div>
</div>
`;
actions.innerHTML = `
<button class="btn" id="btn-back">← Back</button>
<button class="btn btn-primary" id="btn-finish">Test & Save</button>
`;
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<HTMLInputElement>('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<HTMLInputElement>('input[autocomplete="username"]');
if (byAutocompleteUser) return byAutocompleteUser;
// Priority 2: autocomplete="email"
const byAutocompleteEmail = scope.querySelector<HTMLInputElement>('input[autocomplete="email"]');
if (byAutocompleteEmail) return byAutocompleteEmail;
// Priority 3: type="email"
const byTypeEmail = scope.querySelector<HTMLInputElement>('input[type="email"]');
if (byTypeEmail) return byTypeEmail;
// Priority 4: name matching pattern
const byNamePattern = scope.querySelector<HTMLInputElement>(
'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<HTMLInputElement>('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<HTMLInputElement>('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<HTMLInputElement>('input[autocomplete="username"]') ??
scope.querySelector<HTMLInputElement>('input[autocomplete="email"]') ??
scope.querySelector<HTMLInputElement>('input[type="email"]') ??
scope.querySelector<HTMLInputElement>(
'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<HTMLButtonElement>('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 = `<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="1" y="1" width="14" height="14" rx="2" fill="#1f6feb" fill-opacity="0.9"/>
<text x="8" y="11.5" text-anchor="middle" fill="white" font-size="9" font-family="monospace" font-weight="bold">id</text>
</svg>`;
const injectedFields = new WeakSet<HTMLInputElement>();
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 = `
<div>${escapeHtml(candidate.name)}</div>
<div style="color:#484f58;font-size:10px">${escapeHtml(candidate.username ?? '')}</div>
`;
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.