Skip to content

Firefox port: dual-target build, event-page background, OAuth via Worker redirect - #114

Merged
gilgold merged 34 commits into
mainfrom
feature/firefox-port
Aug 16, 2026
Merged

Firefox port: dual-target build, event-page background, OAuth via Worker redirect#114
gilgold merged 34 commits into
mainfrom
feature/firefox-port

Conversation

@gilgold

@gilgold gilgold commented Aug 8, 2026

Copy link
Copy Markdown
Owner

Summary

Tabox now builds and runs on Firefox 139+ (tested on 153) from the same codebase, with zero Chromium regression.

  • Dual build targets: yarn build:firefoxbuild-firefox/ via a pure buildManifest() transform; Chrome's manifest stays the single source of truth and its build output is byte-identical (gated per commit against a baseline)
  • Event-page background: same 24 background modules load via background.scripts on Firefox (guarded importScripts for Chrome's SW)
  • Firefox OAuth (client + Worker): Google sign-in through a fixed share.tbxpro.app/auth/callback redirect with CSRF nonce, since Firefox's per-profile redirect URL can't be registered with Google; Chrome's auth URL is pinned byte-identical by test
  • Feature-detection guards: system.display (→ primary-screen fallback), windows.onBoundsChanged, moz-extension:// scheme awareness, Firefox new-tab URLs in isNewWindow (fixes an extra blank tab on restore)
  • Testing: real-Firefox harness (e2e-firefox/run.sh, selenium via throwaway install — no lockfile changes): smoke 8/8 + save/restore journey with a grey tab group 14/14; CI now builds + web-ext lints the Firefox target on every push

Verification

  • Jest: 255 suites / 1883 tests green (100% coverage)
  • Chrome Playwright e2e: 71/71 at final HEAD
  • Real-Firefox harness: 22/22 (smoke + journey)
  • Server vitest: 332/332 (adversarial open-redirect suite included)
  • build/manifest.json byte-identical to pre-branch baseline; no lockfile changes

Before Firefox sign-in works live (not blocking merge)

  1. Register https://share.tbxpro.app/auth/callback in the Google OAuth client's authorized redirect URIs
  2. Deploy the Worker (/auth/callback route)

Notes for reviewer

  • First commit (fea42dd, share.tbxpro.app API-base switch) is pre-existing work from feature/4.2.1 this branch was cut on — confirm intended to ship together
  • AMO data_collection_permissions: ["browsingActivity"] needs owner sign-off before store submission
  • Known Firefox degradations (release notes): shared-folder push → 1-minute polling; multi-monitor restore → primary screen; window move/resize doesn't trigger auto-update (no onBoundsChanged); share-link handoff deferred (no externally_connectable)
  • Manual check still recommended: Ctrl+Shift+1–4 keyboard commands in Firefox (historically restricted digit shortcuts)

🤖 Generated with Claude Code

gilgold and others added 30 commits August 8, 2026 16:22
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wires buildManifest into webpack behind --env target=firefox flag;
outputs to build-firefox/ with firefox-specific manifest (scripts-based
background, no oauth2/key, browser_specific_settings). Adds dev:firefox
and build:firefox scripts. Adds build-firefox/ to .gitignore and eslint
config ignores.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wraps the importScripts block in a typeof guard so it runs only in
Chrome's service-worker scope, not in Firefox's event-page scope where
scripts are pre-loaded by the manifest. Normalizes the unguarded
chrome.runtime.getManifest() call to browser.runtime.getManifest().

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…oauth2

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
browser.system.display is Chrome-only; Firefox lacks the entire namespace
and all four unguarded calls would throw, aborting collection-open flows.
This helper guards the call and falls back to a single pseudo-display built
from window.screen, so multi-monitor position restore degrades to primary-
screen clamping on Firefox while Chrome behavior remains unchanged.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
displayInfo.js was reading the bare global browser instead of importing
it like every other app/utils module. This fragile approach relied on
side-effects from the polyfill being loaded elsewhere in the bundle.

Update to follow the same import pattern as collectionBulkActions and
other app/utils modules: import { browser } from '../../static/globals'.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add 'moz-extension://' to SYSTEM_URL_PREFIXES array in background.js
  for Firefox extension URL filtering
- Update saveCollectionSnapshot.js to filter extension URLs using
  browser.runtime.getURL() instead of scheme-literal checks, enabling
  cross-browser compatibility for both chrome-extension:// and
  moz-extension:// schemes

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
… already scheme-agnostic

The original unwrapDeferredUrl logic is already scheme-agnostic and handles
both chrome-extension:// and moz-extension:// URLs correctly. The added
filter logic that dropped extension-origin tabs could silently destroy user
data (forbidden by migration rules). Revert to original implementation which
only unwraps deferred URLs without filtering.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Add Firefox build and web-ext lint steps to the test job in release.yml,
ensuring the Firefox target builds successfully and passes lint checks on
every push and PR. The --self-hosted flag skips AMO-listing-only checks
until Phase 2/3 sets up the AMO listing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ed in Firefox)

Firefox doesn't support browser.windows.onBoundsChanged; optional chaining
prevents TypeError at service worker startup, allowing subsequent listeners
to attach. Tab events continue triggering auto-updates on Firefox.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…nge allowlist

Adds a Worker GET /auth/callback that Firefox's launchWebAuthFlow can use as
a fixed, Google-registered redirect_uri (per-profile allizom.org URLs can't
be pre-registered). The unauthenticated, internet-facing callback validates
state's embedded target strictly (https + hostname ends with the
dot-prefixed .extensions.allizom.org, closing off lookalike-suffix and
prefix-as-substring open-redirect tricks) before 302-redirecting the code (or
error) back to it, with state echoed only in the Location header, never in
an HTML body. googleAuth.js's isExtensionRedirect now also accepts exactly
selfOrigin + /auth/callback so /auth/token's code exchange works for both
the Chrome/Edge chromiumapp.org redirect and this new Firefox path.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…adversarial tests)

Cache-Control: no-store on both the 302 redirect and 400 JSON responses (auth
codes must never be cached). Strip embedded userinfo (user:pass@) from the
target before building the redirect Location. Decode base64url state via
TextDecoder instead of the latin1-per-byte atob path. Drop dead
typeof-string checks on URLSearchParams.get() results (always string|null).
Pin four adversarial targets already rejected as explicit regression tests:
bare-domain-as-prefix, hyphen-boundary lookalike, backslash-form foreign
host, and userinfo-smuggling.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ew deps)

Boots build-firefox/ as a temporary WebExtension in real Firefox
(selenium-webdriver + geckodriver, staged into a throwaway npm prefix at
run time so package.json/yarn.lock stay untouched) and asserts the popup,
full-page view, and background event page all come up correctly.
Playwright can't load Firefox extensions, hence the separate harness.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Google sign-in via launchWebAuthFlow can't work on Firefox as-is:
identity.getRedirectURL() returns a per-profile
https://<uuid>.extensions.allizom.org/ URL that can't be pre-registered
with Google. Branch createAuthEndpoint/getTokens on the redirect value
(never UA): *.chromiumapp.org (Chrome/Edge) keeps the exact current
auth URL and exchange, byte-identical; anything else routes through
the Worker's fixed /auth/callback, packing the real per-profile
redirect and a per-attempt nonce into `state`. The login handler
verifies the Worker's echoed nonce before ever exchanging the code,
rejecting on mismatch or missing state.

Also fixes three pre-existing tests whose mocked identity.getRedirectURL
values happened to be non-chromiumapp.org and therefore silently flip
to the new viaWorker path, plus jsdom's crypto polyfill lacking
randomUUID (mutate crypto.randomUUID directly; replacing the global
crypto object is a silent no-op under this jsdom version).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…t-denied short-circuit

- Replace the unconditional crypto.randomUUID() nonce call with the
  file's existing guarded generateUidSafe() — Chrome 89-90 (this
  extension's manifest minimum) predates Crypto.randomUUID, so login
  would have thrown before launchWebAuthFlow ever ran.
- Capture getAuthRedirectConfig() once into authConfig and reuse it for
  the post-flow viaWorker decision, instead of calling it again after
  launchWebAuthFlow resolves (removes a TOCTOU window).
- On the Worker path, short-circuit on params.error / missing
  params.code before calling getTokens — a user declining consent is
  the normal case, not a failure worth a doomed token exchange.
- Drop the crypto.randomUUID stubs from tests/backgroundLoginEntitlement
  and tests/sharedSyncAlarm now that generateUidSafe's own Math.random
  fallback covers jsdom's missing randomUUID (their getAuthRedirectConfig
  stubs stay); adjust tests/oauthConfig accordingly and add a test
  asserting getTokens is skipped on error=access_denied with no code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e login comment

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rney

Adds e2e-firefox/journey.cjs: creates a real Firefox tab group, saves it as
a collection through the extension's real background storage path
(importData -> saveSingleCollectionBG), and restores it into a fresh window
via the same openTabs message the popup sends, asserting the tab group and
tab count come back correctly. run.sh now runs both smoke.cjs and
journey.cjs and exits non-zero if either fails.

Surfaces one real Firefox-port bug (documented, not fixed): restoring a
collection into a new window leaves an extra blank tab because
chrome/background.js:975's isNewWindow() only recognizes Chrome's
"://newtab" URL shape, which never matches Firefox's about:home/about:blank
default tab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…b pages in isNewWindow

Firefox's default new-window starter tab (about:home/about:newtab/about:blank,
or about:privatebrowsing for a fresh private window) never matched the
Chrome-specific '://newtab' substring check, so isNewWindow() was always
false on Firefox and openTabs() left an extra blank tab behind after
restoring a collection into a new window. Found by the real-Firefox journey
harness (.superpowers/sdd/task-8-firefox-harness-report.md).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fix two live-Firefox bugs reported from the Firefox port:

1. browser.windows.onRemoved.addListener(fn, { windowTypes: ['normal'] })
   throws `TypeError: Incorrect argument types for windows.onRemoved` on
   Firefox, at the top level of chrome/background.js. Since that throw is
   synchronous and unhandled, it aborted the rest of the script load, so
   every listener registered after it (windows.onCreated/onFocusChanged/
   onBoundsChanged, all tabs.* events) never attached on Firefox at all.
   Wrap the filtered registration in try/catch and fall back to registering
   the same named callback without the filter on TypeError; Chrome's
   behavior is unchanged since the filtered call still succeeds there.

2. Firefox reports chrome://mozapps/skin/... favIconUrl values for its own
   privileged pages, and Chrome-authored collections can carry chrome://
   favicons too, which extension pages are not allowed to load as images
   (Security Error in Firefox). Add safeFavIconUrl() in
   app/utils/sharedConstants.js as the single render-time guard (passes
   http:/https:/data: URLs through, falls back otherwise) and apply it at
   every favicon <img src> site across the popup and full-page views. This
   never mutates stored collection data.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…only TypeError

The previous fix's catch block only swallowed TypeError and re-threw
everything else, but real Firefox's WebExtensions argument validator throws
a plain Error ("Incorrect argument types for windows.onRemoved.") for the
{ windowTypes } event filter, not a TypeError. That mismatch meant the
unfiltered-fallback registration never ran on real Firefox, and the original
top-level throw kept aborting every listener registered after it (confirmed
via live Firefox console after rebuild). Catch any throw from the filtered
registration and fall back unconditionally, since depending on a specific
Firefox error class isn't part of any spec and isn't future-proof.

Updated tests/backgroundWindowsOnRemovedFirefoxFallback.test.js to exercise
both the real plain-Error shape and TypeError as a secondary case, plus a
case for the (already-existing, unrelated to this fix) outer try/catch that
swallows a throw from the fallback registration itself.

Added real-Firefox proof beyond the jest mocks: the e2e-firefox journey now
restores with trackOpenedWindow: true, closes the tracked window, and
asserts collectionsToTrack was pruned by windowId afterward — that pruning
only happens if the windows.onRemoved listener actually attached and fired,
which is exactly what the { windowTypes } filter bug prevented. Verified
green against a real Firefox binary via e2e-firefox/run.sh (smoke 8/8,
journey 16/16 including the two new listener-registration assertions).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ardown can't orphan them

Firefox destroys the popup document the moment a newly created window
takes focus, so the popup's old flow (windows.create, then send openTabs)
never sent the message — a blank window with no tabs. The popup now
builds a createWindowSpec and sends ONE message before any window
exists; the background creates the window (incl. incognito fallback),
opens the tabs, and stamps lastOpened. All four new-window call sites
converted (popup open, folder open-all, full-page sidebar, bulk actions).

Verified: jest 257 suites/1907 tests, Chrome e2e 72/72 on a clean build,
real-Firefox harness 20/20 incl. a createWindowSpec journey fence,
Chrome manifest byte-identical to baseline.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…lete/sync machinery)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…absWindow consolidation

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rag-resize guard test, and 4.2 marketing drafts

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…background-clip:text

Firefox paints a text-clipped layer inside a multi-value background-clip
list as a full box, so the gradient covered the card and the transparent
"Welcome" text vanished. The text gradient now lives on an inner span
with a single-value clip; regression test guards all CSS files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…snap to frame zero; saved-box flight moved off the layout path

Two causes of the perceived lag:
1. On every step change the outgoing scene both remounted (key flip to
   `-idle`) and lost `.is-active`, stripping its animations and visibly
   resetting it to frame zero while still sliding out. Scenes now keep a
   per-scene run counter: only the scene becoming active remounts
   (restarting its animation), and played scenes retain `.is-active` so
   fill-mode holds their end state during the slide.
2. `saved-box-to-tabox` animated left/top (reflow every frame). The
   flight now uses transform with container-query units
   (translate(calc(100cqw - 302px))), landing within 1px of the old
   position in both engines, verified in real Firefox and Chromium.

Regression tests cover outgoing-scene persistence and ban left/top in
scene keyframes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…droid) for data_collection_permissions; keep .DS_Store out of builds

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
gilgold and others added 4 commits August 9, 2026 14:00
…rs, replace webpack global shim

AMO flagged three `Function` constructor sites: webpack's global-object
runtime shim and regenerator-runtime. Added browserslist (Chrome/Edge 89+,
Firefox 140+) so Babel stops transpiling async/await (removing regenerator
entirely, -240KB fullpage) and replaced webpack's `global` handling with a
globalThis define. Surfaced three latent const-TDZ bugs previously masked
by ES5 var-hoisting (AIToolsModal dispatchAiRun, App
refreshLastSyncTimeFromStorage, FPContentArea hasSelectedCollections /
visibleSingleTabEntryKeySet) — declarations reordered.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…etadata (drop MIT badge, UNLICENSED)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Resolves #69 — repo no longer shows as unlicensed on GitHub. Terms match
the UNLICENSED package metadata: view/build/contribute allowed, no
redistribution or reuse.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@gilgold
gilgold merged commit 14878ee into main Aug 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant