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

6.2 KiB

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)

{
  "manifest_version": 3,
  "background": {
    "service_worker": "service-worker.js",
    "type": "module"
  }
}

Firefox (manifest.firefox.json — new)

{
  "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:

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

{
  "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)