refactor: one shared extension-API module, and drive the dApp flows on Firefox (closes #153) #281
Reference in New Issue
Block a user
Delete Branch "fix/issue-153-browser-api-compat"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes #153.
Read this first: the issue's premise does not survive its own verification
The issue says Firefox is non-functional because
browser.*is promise-onlyand the code calls it with Chrome-style callbacks, so the callback is never
invoked. That is false on Firefox 153.0.3, measured directly. Firefox's
browser.*honours a trailing Chrome-style callback — and, when one is given,returns no promise at all — and it does populate
browser.runtime.lastError.Probe run inside the pinned e2e container, from the extension's own popup page:
and, against item 8 specifically (
browser.tabs.sendMessageto a dead tab id):Stronger still, end to end: I stashed every
src/change, rebuilt, and ran thenew Firefox suite against the unconverted code. 8/8, exit 0. All four
DoD flows pass without this PR's fix. Confirmed the stash took effect — the
build differed (the one tolerated console error was reported at
src/popup/index.js:6unfixed against:1fixed).So this is not a repair of a broken target. What it is:
compat module, no namespace ternaries anywhere else, one call shape;
which is what produced the measurement above;
Reviewer's call whether the refactor is worth its diff on that basis. Nothing
in it is load-bearing for correctness on either browser today.
Strategy: (a), the promise shim
src/shared/browserApi.jsis now the only file in the tree that namesbrowserorchrome. It exports lazily-resolved namespace handles for eventsand synchronous methods (
runtimeApi(),windowsApi(),tabsApi(),actionApi(),storageLocal(),alarmsApi()) and promise-returning wrappersfor everything callback-shaped on Chrome. Callers
await.Why (a) over (b):
state.js,alarms.jsandphishingDomains.jsalready usedthe promise form, which is exactly why the popup wallet flows worked on
Firefox. (b) would have made the callback half "correct" by regressing the
working half to callbacks, or left the tree split down the middle. (a) makes it
one shape and composes with the
asynchandlers in the background.Three decisions worth naming rather than leaving to be found:
chrome.storage.local.get()returns a promise on MV3 and three modulesalready depended on that. Wrapping it would be a change, not a fix.
notify()is a separate entry point for a send whose answer nobodyreads. It sends with no callback and swallows the no-receiver rejection.
Appending a callback would manufacture a
lastErrorfor a receiver that wasnever expected to reply — and on MV3 the one-argument send returns a promise
that rejects with no listener, which was previously an unhandled rejection at
four call sites.
runtime.lastErroris gone entirely. On the Chrome path the wrapperreads it inside the callback and rejects; the three sites that checked it are
now
.catch(() => {})on the send itself.Per-call-site conversion
All eight still existed, at moved line numbers.
content/index.jsstorage.get("eip6963Uuid", cb)await storageGet(...)/await storageSet(...)in an async IIFE. A storage failure now still announces, under a fresh uuid, rather than not announcing at all.content/index.jsruntime.sendMessage(msg, cb)sendMessage(msg).then(...).catch(...). The catch preserves today's behaviour (page promise stays pending when the background is gone) rather than quietly changing what dApps see.background/index.jswindowsApi.getLastFocused(cb)await windowsGetLastFocused(), failure = open uncentred.background/index.jswindowsApi.create(opts, cb)await windowsCreate(opts), failure logged and the approval left pending.background/index.jstabsApi.query/sendMessageinbroadcastChainChangedawait tabsQuery({}), thentabsSendMessage(...).catch(() => {})per tab.background/index.jswindowsApi.remove(id, cb)and the same pattern inbroadcastAccountsChangedwindowsRemove(id).catch(() => {}),await tabsQuery({}).popup/views/approval.jsAUTISTMASK_GET_APPROVAL,AUTISTMASK_TX_RESPONSE,AUTISTMASK_SIGN_RESPONSEawait sendMessage(...);show()is async and absorbs its own failure. The three reject buttons and the site-approval buttons usenotify().background/index.jsthreeruntime.lastErrorchecksBeyond 1-8, so the DoD's "no ternaries outside that module" holds:
state.js,alarms.js,phishingDomains.js,walletDelete.js,popup/views/home.js,popup/views/settings.js. Namespace re-source only, no call-shape change.src/content/inpage.jsuntouched.grep -rn 'typeof browser\|\bchrome\.\|\bbrowser\.' src/outside the modulereturns two comments and no code.
No manifest change was needed, and none was made.
manifest/firefox.jsonstays MV2-correct.
The one real defect fixed
pendingApprovals[id].windowId = win.idwas unguarded.windows.create()isasynchronous on both browsers, and an approval settled during the open — an
address switch goes through
broadcastAccountsChanged()and deletes it — makesthat a dereference of a deleted entry. Guarded now, with the reason at the site.
Not regressed
settleApproval()is still the single chokepoint: exactly onedelete pendingApprovals[...]and oneapproval.resolve(...), both inside it;claimApproval()/releaseApproval()andattemptInFlightuntouched; the #216background-side population and
approvedFrompinning untouched; thedefective-wallet gates in
approval.jsuntouched. Only the browser-API callsaround them changed.
What was tested, and on what
Chrome —
make test-e2e, 37/37. Chromium frommcr.microsoft.com/playwright:v1.56.0-noble(pinned by digest), Playwright1.56.0, MV3 build from
dist/chrome/. Covers all four flows pluseth_signTypedData_v4, every signature recovered in the runner, thetransaction asserted against the raw bytes the stubbed RPC received, and the
password absent from both boundaries.
Firefox —
make test-e2e-firefox, 8/8. Firefox 153.0.3 and geckodriver0.36.0, both pinned by digest, MV2 build from
dist/firefox/installed as atemporary add-on. Steps 1-3 are the pre-existing popup steps; 4-8 are new:
announcement naming
berlin.sneak.autistmask, identity-checked againstwindow.ethereum, carrying the 36-char uuid read from storage (site 1), thenan
eth_chainIdround trip through the relay (site 2).eth_requestAccounts: prompt names the origin and the address,approve, page receives exactly the active address.
personal_sign: prompt shows origin, type, decoded message andsigning address; signature recovered in the runner with
verifyMessage(getBytes(...))and compared to the address read out ofextension storage.
eth_sendTransaction: prompt shows origin, sender, recipient, valueand raw calldata; the broadcast artifact is parsed from what reached the node
and checked field by field (signer, to, value, data, chainId 1); the hash the
page received and the hash on the wait screen must both be that artifact's.
the page's
ProviderRpcError(now that #274 has landed), and nothing wasbroadcast. This is the
windows.onRemovedpath, which can only fire ifwindows.create()produced a window id — site 4.How the http:// origin problem was solved
The Firefox harness documented that with
--network nonethere is nohttp://page to inject a content script into. Loopback survives
--network none.tests/e2e/firefox/dapp.jsserves the dApp page and a JSON-RPC node from127.0.0.1inside the same container, and the extension'srpcUrlis pointedat it. The run still reaches nothing but itself. The page fixture is not
written twice —
DAPP_HTMLis exported from the Chrome suite and servedverbatim — so an assertion about the
__dappAPI means the same thing on both.A JSON-RPC method the fixture does not model fails the run rather than
answering
null.What I could not drive, and one tolerated error
browsing context, so WebDriver cannot see or click it — the same blind spot
the Chrome harness documents.
extensions.openPopupWithoutUserGesture.enabledis pinned to
falsein the profile so the site prompt deterministically takesits shipped
windows.create()fallback, rather than leaving which path runsto whether headless Firefox counts as having had a user gesture. Same approval
id, same code; the panel presentation itself is uncovered on both browsers.
eth_signTypedData_v4is not in the Firefox suite. Chrome covers it; theDoD named four flows and those are the four.
executed, which they were not before, but no probe has forced a throw inside
one and watched it fail the run. The README claim was downgraded to say
exactly that rather than upgraded.
buffer.
ALLOWED_ERRORSlist mirroring the Chromeharness's, naming the issue that deletes it and printed on every occurrence:
Firefox reports
Promise rejected after context unloadedfor thesite-approval popup's unawaited
sendMessagewhenwindow.close()unloadsthe context. Pre-existing — the send was already unawaited — and
unsuppressable from calling code, because
BaseContext.wrapPromisereports itwhether or not a handler is attached. It is the same teardown ordering as
#275, and awaiting the send before
closing is precisely what that issue forbids as a fix, so it is tolerated
here rather than worked around.
Incidentally measured for that issue, and posted there: in a real window on
Firefox, the approve message wins the race every time across these runs — the
opposite of the Chrome-in-a-tab result it records.
Gates
make check— green. 28 suites, 681 tests;script/test-verify-build18/18 cases;
prettier --checkclean.make test-e2e— green, 37/37.make test-e2e-firefox— green, 8/8.All three re-run after the rebase onto
nextat9dcd875.FAIL - needs-rework.
1.
src/shared/browserApi.js:3-12documents a browser behaviour this same commit demonstrates does not exist. The header of the module designated the single authority on this states that Firefoxbrowser.*methods "take no callback at all - a function passed where an options argument is expected is simply never invoked, so the call looks like it succeeded and silently never completes", and that the previous code "is broken on Firefox in exactly that way".TODO.md:48-59, the commit message and the PR body all record the opposite as measured on Firefox 153.0.3, and record that all four flows pass against the unconverted code. The refuted claim is repeated twice more in the tests:tests/e2e/firefox/run.js:168("hand a Chrome-style callback to the promise-onlybrowser.*namespace and simply never complete") and:707("a callback thebrowser.*namespace never invoked"). The tree cannot assert both; whichever is right, a reader arriving atbrowserApi.jswill take its header as the reason the module exists. Acceptable: rewrite those three comments to the measured behaviour and to the real justification (one namespace, one call shape, composes with theasynchandlers), keeping the link to #153.2.
src/shared/browserApi.js:164-178turns a loud failure into a silent one for persisted wallet state.storageGet()resolves{}andstorageSet()resolves as a no-op whenstorage.localis absent.src/shared/state.js:114(saveState) and:118(loadState) andsrc/background/index.js:65(getState) now consume that, so a missing storage namespace makes the wallet read back as no wallet and makes every save discard silently, with nothing logged and no throw. Before this commitstate.jsresolvedchrome.storage.localat module load and threw immediately in that case. Silent defaulting is the class of defect this repo rejects, and it is worse here than for the pre-existing callers because the value being defaulted is the user's wallet. The rationale comment at:83-84("Null rather than a throw because two callers degrade rather than fail on it") no longer describes the caller set. Acceptable:storageGet/storageSetreject when there is nostorage.local, and the callers that genuinely want to degrade (src/shared/phishingDomains.js) keep usingstorageLocal()directly and keep their own null check.3. Raised, not asserted - I did not probe this.
src/popup/views/approval.js:444-show()becameasyncandsrc/popup/index.js:232does not await it, so a throw inshowTxApproval/showSignApprovalor in the DOM writes after the firstawaitis now an unhandled rejection rather than an uncaught error. Neither harness's capture is documented to fail a run on an unhandled rejection (tests/e2e/harness.jslistens onpageerror/console;tests/e2e/firefox/driver.jsreadsnsIConsoleService, non-warning entries only). Confirm before dismissing.Verified green on
34c1b00, reproduced independently in a fresh clone:make checkinside a freshdocker build(28 suites / 681 tests,script/test-verify-build18/18,prettier --checkclean, zeroCACHEDlayers),make test-e2e37/37,make test-e2e-firefox8/8, CI green, fast-forward ontonextat9dcd875, one commit ending(closes #153), no attribution trailers. Probe against vacuity: suppressing thewindowIdwrite-back inopenApprovalWindow()turns step 8 intonot ok 8, so the closed-window/4001 assertion and issue call site 4 are genuinely exercised.View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.