Files
relicario/docs/superpowers/specs/2026-04-12-relicario-firefox-extension-design.md
adlee-was-taken 39ae2ecbf3 style: capitalize "Relicario" in prose / UI / CLI help
Brand name uses capital R in user-facing text — extension UI strings,
CLI clap help / descriptions / error prose, markdown docs. Lowercase
preserved for the binary command, crate names, npm package, file
paths, env vars, and code identifiers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-01 17:29:10 -04:00

197 lines
6.2 KiB
Markdown

# Relicario — Firefox Extension Port Design
Port the existing Chrome MV3 extension to Firefox. Shared TypeScript source, separate manifests, separate build outputs. No code changes to components, popup, or content script.
## Scope
- Firefox-specific `manifest.json`
- WASM loading compatibility (dynamic import for Firefox background script)
- Second webpack config for Firefox build target
- npm scripts for building both browsers
## What Stays the Same
All TypeScript source files are shared between Chrome and Firefox:
- `src/service-worker/` — all files (with one environment check for WASM loading)
- `src/popup/` — all components, styles, HTML
- `src/content/` — detector, fill, icon, capture
- `src/setup/` — setup wizard
- `src/shared/` — types, messages
- `setup.html` — init wizard
Firefox supports the `chrome.*` namespace for WebExtension APIs, so no `browser.*` polyfill is needed. All Chrome API calls (`chrome.runtime.sendMessage`, `chrome.storage.local`, `chrome.tabs`, etc.) work as-is.
## Manifest Differences
### Chrome (`manifest.json` — existing)
```json
{
"manifest_version": 3,
"background": {
"service_worker": "service-worker.js",
"type": "module"
}
}
```
### Firefox (`manifest.firefox.json` — new)
```json
{
"manifest_version": 3,
"name": "relicario",
"version": "0.1.0",
"description": "Two-factor encrypted password manager",
"browser_specific_settings": {
"gecko": {
"id": "relicario@adlee.work",
"strict_min_version": "128.0"
}
},
"permissions": ["storage", "activeTab", "clipboardWrite"],
"host_permissions": ["<all_urls>"],
"background": {
"scripts": ["service-worker.js"]
},
"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'"
},
"web_accessible_resources": [{
"resources": ["setup.html", "setup.js", "styles.css", "relicario_wasm_bg.wasm", "relicario_wasm.js"]
}]
}
```
Key differences from Chrome manifest:
- `browser_specific_settings.gecko.id` — required for Firefox, uses email-style ID
- `browser_specific_settings.gecko.strict_min_version` — Firefox 128+ for stable MV3 support
- `background.scripts` instead of `background.service_worker` + `type: module` — Firefox MV3 background scripts are NOT service workers, they're persistent scripts
- `web_accessible_resources` — Firefox doesn't use `matches` field in the resource entries
## WASM Loading
The service worker `index.ts` currently uses `initSync` with `chrome.runtime.getURL` because Chrome MV3 service workers don't support dynamic `import()`. Firefox background scripts DO support `import()`.
Add an environment check to `index.ts`:
```typescript
async function initWasm(): Promise<WasmModule> {
if (wasm) return wasm;
if (typeof ServiceWorkerGlobalScope !== 'undefined') {
// Chrome MV3: service worker context — use initSync
const wasmResponse = await fetch(chrome.runtime.getURL('relicario_wasm_bg.wasm'));
const wasmBytes = await wasmResponse.arrayBuffer();
initSync({ module: new WebAssembly.Module(wasmBytes) });
} else {
// Firefox: background script context — dynamic import works
const wasmUrl = chrome.runtime.getURL('relicario_wasm_bg.wasm');
await initDefault(wasmUrl);
}
vault.setWasm(wasmBindings);
wasm = wasmBindings;
wasmReady = true;
return wasm;
}
```
This uses the static import of `initSync` and the default export (`initDefault`) from the WASM glue, branching on runtime environment. Both paths end with the same `wasmBindings` module reference.
## Build Pipeline
### New file: `extension/webpack.firefox.config.js`
Identical to `webpack.config.js` except:
- Output directory: `dist-firefox/` instead of `dist/`
- CopyPlugin copies `manifest.firefox.json` as `manifest.json` (instead of `manifest.json`)
### Updated `extension/package.json` scripts
```json
{
"scripts": {
"build": "webpack --mode production",
"build:firefox": "webpack --config webpack.firefox.config.js --mode production",
"build:all": "npm run build:wasm && npm run build && npm run build:firefox",
"dev": "webpack --mode development --watch",
"dev:firefox": "webpack --config webpack.firefox.config.js --mode development --watch",
"build:wasm": "wasm-pack build ../crates/relicario-wasm --target web --out-dir ../../extension/wasm"
}
}
```
### Output structure
```
extension/
├── dist/ # Chrome build (existing)
│ ├── service-worker.js
│ ├── popup.js
│ ├── content.js
│ ├── setup.js
│ ├── manifest.json # Chrome manifest
│ └── ...
├── dist-firefox/ # Firefox build (new)
│ ├── service-worker.js
│ ├── popup.js
│ ├── content.js
│ ├── setup.js
│ ├── manifest.json # Firefox manifest (copied from manifest.firefox.json)
│ └── ...
└── wasm/ # Shared WASM (same for both)
```
## Testing
### Load in Firefox
1. Open `about:debugging#/runtime/this-firefox`
2. Click "Load Temporary Add-on..."
3. Select `extension/dist-firefox/manifest.json`
4. Extension appears in toolbar
### Test matrix
Same as Chrome — all features should work identically:
- Setup wizard (`setup.html`)
- Unlock with passphrase
- Entry list, search, group filtering
- Entry detail with TOTP countdown
- Add/edit/delete entries
- Autofill via field icon
- Credential capture (if enabled)
- Settings view
### Firefox-specific checks
- WASM loads correctly (background script, not service worker)
- master_key persists longer (background script stays alive)
- Popup dimensions render correctly
- Content script injection works on all pages
## .gitignore
Add `extension/dist-firefox/` to `.gitignore`.
## Non-Goals
- Firefox for Android (different extension API surface)
- Publishing to addons.mozilla.org (manual for now)
- Automated cross-browser testing
- Shared webpack config with conditional logic (two separate configs is clearer)