diff --git a/docs/branch-memory/feature-firefox-signed-xpi.md b/docs/branch-memory/feature-firefox-signed-xpi.md new file mode 100644 index 0000000..6e2644c --- /dev/null +++ b/docs/branch-memory/feature-firefox-signed-xpi.md @@ -0,0 +1,41 @@ +# feature/firefox-signed-xpi +*Started: 2026-10-14 · Updated: 2026-10-15* + +**What this branch did** +Resolved issue #100: the Firefox extension now ships as a signed, unlisted, self-hosted XPI. Merged as PR #105. Follow-ups: #108 set the version to 0.1.0, and #109 (generated by the workflow) published it. 0.1.0 went live at `savr.link/extension/firefox.xpi`. Installs and the auto-update to 0.1.1 were verified on desktop and Android, and #100 is closed. 0.1.1 (#110) narrowed the popup and added an "Extension settings" button. 0.1.2 (#111) fit the popup and options to phone screens. Primary-source research is in `docs/extension-distribution-research.md`. The decision and release process are in `browser-extension/README.md` (Distribution / Versioning / Releasing). + +**Decisions** +- Signed as **unlisted on AMO, hosted on GitHub Releases** rather than an AMO listing. The user wanted less friction (no listing metadata or human review queue) and was open to listing later. Migrating is documented: ship one final self-hosted version *without* `update_url`, and Firefox moves those installs to AMO. +- `update_url` and the install link live on **savr.link**, while the files live on GitHub. Installed copies keep the `update_url` they were installed with forever, so the domain gives us an indirection we control. +- The publish step is a **PR that updates `public/extension/updates.json` and `public/_redirects`**, not a direct push to main. Merging that PR (plus the Netlify deploy) is the deliberate "go live" gate. The release and XPI exist before it. +- Extension releases are created with `--latest=false`, because the repo's single "latest release" belongs to the app (`v0.2.0`). Install and update links therefore use per-version URLs, never `releases/latest/download`. +- Extension versions are **independent semver with `ext-v*` tags** in the same repo. The app/extension contract is versioned separately by `protocol: 1` on bridge payloads. +- Started at **0.1.0, not 1.0.0**. Firefox only updates upward and AMO burns every submitted number, so 0.x keeps 1.0 for after Android and auto-update are verified. +- `data_collection_permissions` declares `websiteContent` + `browsingActivity` rather than `none`. Mozilla's definition doesn't cover "handed to a client-side app that syncs to the user's own storage", and declaring is the safe reading. That forced `strict_min_version` 142 (the key needs desktop 140 / Android 142; the user chose 142 for both). +- The extension stays **in this repo**. `youtube-transcript.js` is shared with the app's bookmarklet build, and the e2e tests pin both sides of the bridge. + +**Rejected** +- Hosting the XPI on savr.link/Netlify with a `_headers` Content-Type rule. GitHub assets keep their upload Content-Type and avoid coupling releases to app deploys. +- Signing through the AMO web UI by hand. It works (it's the recovery path in the README), but the API key lets CI do it. +- Tying the extension version to the app version. Every app release would force a re-sign and push an update to every user. +- Chrome self-hosted `.crx`. Google Chrome on Windows/macOS disables off-store installs for consumers. Chrome is tracked as an unlisted Chrome Web Store item in #106. + +**Gotchas** +- `updates.json` entries must use **`applications.gecko`**. Firefox silently ignores `browser_specific_settings` there (uBlock's file gets this wrong). A test in `scripts/extension-release.test.mjs` guards it. +- `gh release upload` stores `.xpi` as `application/octet-stream`, which makes Firefox download instead of install. The workflow uploads through the API with `application/x-xpinstall` and asserts the stored type. +- The vite-plugin-pwa service worker's `navigateFallback` would serve the app shell for `/extension/firefox.xpi`, so `vite.config.ts` has `navigateFallbackDenylist: [/^\/extension\//]`. +- `web-ext lint` errors on `update_url` unless run with `--self-hosted`, which is what AMO applies to unlisted uploads. +- Firefox **Android does not install from a web link** outside AMO. Users download the file, then Settings → About Firefox → tap the logo 5× → Install Extension from File. +- Firefox Android has no `windows` API, so `visitSavr` guards `api.windows`. +- In agent shells on this machine, Playwright's Chromium fails with GLIBC errors from an inherited `LD_LIBRARY_PATH` (the paseo alsa-lib). Run e2e as `env -u LD_LIBRARY_PATH -u PLAYWRIGHT_BROWSERS_PATH flox activate -c "node scripts/run-e2e.js …"`. +- AMO signing for 0.1.0 took about 3 minutes, against the documented "up to 24 hours". +- Firefox Android opens the popup and options as full pages. Without `` they are laid out ~980px wide and scaled down to a tiny box. The `@media (pointer: coarse)` rules widen them on phones only, because desktop popups size to content and a width-based media query there would be circular. +- Forcing an extension update on Firefox Android: Settings → About Firefox (tap the logo 5×) → Secret Settings → Enable Debug Drawer → floating button → Add-ons Tools → Check for updates. Otherwise Fenix checks every 12 h (`DefaultAddonUpdater` frequency in `Components.kt`). +- Publish PRs opened by the workflow (via GITHUB_TOKEN) trigger **no CI**. That's expected, since they only touch `updates.json`/`_redirects` and the release job already ran the script tests. +- CI `e2e-smoke` can flake when `npx http-server` downloads slowly (the content server misses its 10 s start window). Rerunning fixes it. + +**Open threads** +- Confirm the 0.1.2 phone layout on a real device (it was only checked under Playwright Pixel 7 emulation). +- #106: Chrome Web Store, unlisted. Since #104, `*://*/*` is a *required* host permission, which may lengthen review. +- "Allow GitHub Actions to create and approve pull requests" is now enabled, so the workflow's fallback warning path won't be hit. +- The app has no version-gating on `protocol`. Add handling only when a breaking payload change happens.