refactor: one shared extension-API module, and drive the dApp flows on Firefox (closes #153)
All checks were successful
check / check (push) Successful in 27s
e2e / e2e-chrome (push) Successful in 45s
e2e / e2e-firefox (push) Successful in 23s

This commit was merged in pull request #281.
This commit is contained in:
2026-08-17 09:06:13 +02:00
parent ab1c1846a7
commit 4b7a678a9b
17 changed files with 1639 additions and 286 deletions

View File

@@ -40,17 +40,21 @@ const {
registerAlarmHandlers,
} = require("../shared/alarms");
const storageApi =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
const windowsApi =
typeof browser !== "undefined" ? browser.windows : chrome.windows;
const tabsApi = typeof browser !== "undefined" ? browser.tabs : chrome.tabs;
const actionApi =
typeof browser !== "undefined" ? browser.browserAction : chrome.action;
const {
actionApi,
runtimeApi,
storageGet,
tabsQuery,
tabsSendMessage,
windowsApi,
windowsCreate,
windowsGetLastFocused,
windowsRemove,
} = require("../shared/browserApi");
const runtime = runtimeApi();
const windowsNs = windowsApi();
const actionNs = actionApi();
// Connected sites (in-memory, non-persisted): { "origin:address": true }
const connectedSites = {};
@@ -167,7 +171,7 @@ function approvedNonce(approvedTx) {
}
async function getState() {
const result = await storageApi.get("autistmask");
const result = await storageGet("autistmask");
return (
result.autistmask || {
wallets: [],
@@ -231,8 +235,8 @@ async function proxyRpc(method, params) {
}
function resetPopupUrl() {
if (actionApi && typeof actionApi.setPopup === "function") {
actionApi.setPopup({ popup: "src/popup/index.html" });
if (actionNs && typeof actionNs.setPopup === "function") {
actionNs.setPopup({ popup: "src/popup/index.html" });
}
}
@@ -336,58 +340,71 @@ function releaseApproval(approval) {
// Open approval in a separate popup window.
// This is the primary mechanism for tx/sign approvals (triggered programmatically,
// not from a user gesture) and the fallback for site-connection approvals.
function openApprovalWindow(id) {
// Never rejects. Its callers raise it from inside a Promise executor and drop
// the result on the floor, so a rejection here would be unhandled.
async function openApprovalWindow(id) {
const popupUrl = runtime.getURL("src/popup/index.html?approval=" + id);
const popupWidth = 360;
const popupHeight = 600;
windowsApi.getLastFocused((currentWin) => {
const opts = {
url: popupUrl,
type: "popup",
width: popupWidth,
height: popupHeight,
};
if (currentWin) {
opts.left = Math.round(
currentWin.left + (currentWin.width - popupWidth) / 2,
);
opts.top = Math.round(
currentWin.top + (currentWin.height - popupHeight) / 2,
);
}
windowsApi.create(opts, (win) => {
const approval = pendingApprovals[id];
if (!approval) {
// Settled while the window was opening — an address switch,
// say. Nothing is waiting on it, and a window showing an
// approval that no longer exists is not left on screen.
if (win) {
windowsApi.remove(win.id, () => {
if (runtime.lastError) {
// window already closed
}
});
}
return;
}
if (!win) {
// No window means no way to ever answer this approval, and an
// approval nothing can answer holds the requesting page's
// promise open forever. Settle it now instead.
settleApproval(
id,
abandonedResult(
approval,
APPROVAL_WINDOW_FAILED_CODE,
APPROVAL_WINDOW_FAILED_MESSAGE,
),
);
return;
}
approval.windowId = win.id;
});
});
let currentWin = null;
try {
currentWin = await windowsGetLastFocused();
} catch {
// Nothing focused to centre on. The window still opens, at whatever
// position the browser picks.
}
const opts = {
url: popupUrl,
type: "popup",
width: popupWidth,
height: popupHeight,
};
if (currentWin) {
opts.left = Math.round(
currentWin.left + (currentWin.width - popupWidth) / 2,
);
opts.top = Math.round(
currentWin.top + (currentWin.height - popupHeight) / 2,
);
}
let win = null;
try {
win = await windowsCreate(opts);
} catch (e) {
// The promise namespace reports the failure by rejecting where the
// callback namespace reported it by handing back no window; both land
// on the !win branch below, which settles the approval.
log.errorf("could not open the approval window:", e);
}
const approval = pendingApprovals[id];
if (!approval) {
// Settled while the window was opening — an address switch, say.
// Nothing is waiting on it, and a window showing an approval that no
// longer exists is not left on screen. The await above makes this a
// real race: writing the id back would resurrect a bare entry that
// nothing would ever resolve.
if (win) windowsRemove(win.id).catch(() => {});
return;
}
if (!win) {
// No window means no way to ever answer this approval, and an
// approval nothing can answer holds the requesting page's promise
// open forever. Settle it now instead.
settleApproval(
id,
abandonedResult(
approval,
APPROVAL_WINDOW_FAILED_CODE,
APPROVAL_WINDOW_FAILED_MESSAGE,
),
);
return;
}
approval.windowId = win.id;
}
// Open an approval popup and return a promise that resolves with the user decision.
@@ -397,12 +414,12 @@ function requestApproval(origin, hostname) {
const id = crypto.randomUUID();
pendingApprovals[id] = { id, origin, hostname, resolve };
if (actionApi && typeof actionApi.openPopup === "function") {
actionApi.setPopup({
if (actionNs && typeof actionNs.openPopup === "function") {
actionNs.setPopup({
popup: "src/popup/index.html?approval=" + id,
});
try {
const result = actionApi.openPopup();
const result = actionNs.openPopup();
if (result && typeof result.catch === "function") {
result.catch(() => openApprovalWindow(id));
}
@@ -469,7 +486,7 @@ function requestSignApproval(origin, hostname, signParams, approvedFrom) {
// Detect when an approval popup (browser-action) closes without a response.
// TX and sign approvals now use windows.create() and are handled by the
// windowsApi.onRemoved listener below, but we still handle site-connection
// windows.onRemoved listener below, but we still handle site-connection
// approval disconnects here.
runtime.onConnect.addListener((port) => {
if (port.name.startsWith("approval:")) {
@@ -883,24 +900,26 @@ async function handleSendTransaction(params, origin) {
}
// Broadcast chainChanged to all tabs when the network is switched.
function broadcastChainChanged(chainId) {
tabsApi.query({}, (tabs) => {
for (const tab of tabs) {
tabsApi.sendMessage(
tab.id,
{
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: chainId,
},
() => {
if (runtime.lastError) {
// expected for tabs without our content script
}
},
);
}
});
//
// Never rejects: its caller is an RPC handler that must answer the page
// whatever the browser made of the broadcast.
async function broadcastChainChanged(chainId) {
let tabs;
try {
tabs = await tabsQuery({});
} catch {
return;
}
for (const tab of tabs) {
// A tab with no content script has no receiver, and that is the
// ordinary case rather than a fault. The rejection it produces is the
// promise-shaped form of the runtime.lastError this used to read.
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT",
eventName: "chainChanged",
data: chainId,
}).catch(() => {});
}
}
// Broadcast accountsChanged to all tabs, respecting per-address permissions
@@ -921,41 +940,36 @@ async function broadcastAccountsChanged() {
);
if (!settleApproval(id, rejection)) continue;
if (approval.windowId) {
windowsApi.remove(approval.windowId, () => {
if (runtime.lastError) {
// window already closed
}
});
// Rejects when the window has already gone, which is a race the
// user wins routinely by closing it themselves.
windowsRemove(approval.windowId).catch(() => {});
}
}
resetPopupUrl();
const s = await getState();
const activeAddress = await getActiveAddress();
const allowed = activeAddress ? s.allowedSites[activeAddress] || [] : [];
tabsApi.query({}, (tabs) => {
for (const tab of tabs) {
const origin = tab.url ? new URL(tab.url).origin : "";
const hostname = extractHostname(origin);
const hasPermission =
activeAddress &&
(allowed.includes(hostname) ||
connectedSites[origin + ":" + activeAddress]);
tabsApi.sendMessage(
tab.id,
{
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: hasPermission ? [activeAddress] : [],
},
() => {
// Ignore errors for tabs without content script
if (runtime.lastError) {
// expected for tabs without our content script
}
},
);
}
});
let tabs;
try {
tabs = await tabsQuery({});
} catch {
return;
}
for (const tab of tabs) {
const origin = tab.url ? new URL(tab.url).origin : "";
const hostname = extractHostname(origin);
const hasPermission =
activeAddress &&
(allowed.includes(hostname) ||
connectedSites[origin + ":" + activeAddress]);
// Same as chainChanged above: a tab without our content script
// rejects, and that is expected rather than a fault.
tabsSendMessage(tab.id, {
type: "AUTISTMASK_EVENT",
eventName: "accountsChanged",
data: hasPermission ? [activeAddress] : [],
}).catch(() => {});
}
}
// Background balance refresh: every 60 seconds when the popup isn't open.
@@ -1050,8 +1064,8 @@ startBackgroundJobs();
// outcome to the page — and the window is recorded as gone, so that an attempt
// which then fails retryably settles instead of waiting in a window that no
// longer exists.
if (windowsApi && windowsApi.onRemoved) {
windowsApi.onRemoved.addListener((windowId) => {
if (windowsNs && windowsNs.onRemoved) {
windowsNs.onRemoved.addListener((windowId) => {
for (const [id, approval] of Object.entries(pendingApprovals)) {
if (approval.windowId !== windowId) continue;
const rejection = abandonedResult(

View File

@@ -1,12 +1,20 @@
// AutistMask content script — bridges between inpage (window.ethereum)
// and the background service worker via extension messaging.
const {
hasBrowserNamespace,
runtimeApi,
sendMessage,
storageGet,
storageSet,
} = require("../shared/browserApi");
// In Chrome (MV3), inpage.js runs as a MAIN-world content script declared
// in the manifest, so no injection is needed here. In Firefox (MV2), the
// "world" key is not supported, so we inject via a <script> tag.
if (typeof browser !== "undefined") {
if (hasBrowserNamespace()) {
const script = document.createElement("script");
script.src = browser.runtime.getURL("src/content/inpage.js");
script.src = runtimeApi().getURL("src/content/inpage.js");
script.onload = function () {
this.remove();
};
@@ -14,23 +22,27 @@ if (typeof browser !== "undefined") {
}
// Send the persisted EIP-6963 provider UUID to the inpage script.
// Generated once at install time and stored in chrome.storage.local.
(function sendProviderUuid() {
const storage =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
storage.get("eip6963Uuid", (items) => {
let uuid = items?.eip6963Uuid;
// Generated once at install time and stored in extension storage.
(async function sendProviderUuid() {
let uuid = null;
try {
const items = await storageGet("eip6963Uuid");
uuid = items?.eip6963Uuid;
if (!uuid) {
uuid = crypto.randomUUID();
storage.set({ eip6963Uuid: uuid });
await storageSet({ eip6963Uuid: uuid });
}
window.postMessage(
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
location.origin,
);
});
} catch {
// Storage was unavailable or refused the write. The announcement
// still has to go out — a provider that never announces is invisible
// to every EIP-6963 dApp — so it goes under a fresh uuid that this
// page load will not outlive.
if (!uuid) uuid = crypto.randomUUID();
}
window.postMessage(
{ type: "AUTISTMASK_PROVIDER_UUID", uuid },
location.origin,
);
})();
// Relay requests from the page to the background script
@@ -39,27 +51,31 @@ window.addEventListener("message", (event) => {
if (event.data?.type !== "AUTISTMASK_REQUEST") return;
const { id, method, params } = event.data;
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
runtime.sendMessage(
{ type: "AUTISTMASK_RPC", id, method, params, origin: location.origin },
(response) => {
sendMessage({
type: "AUTISTMASK_RPC",
id,
method,
params,
origin: location.origin,
})
.then((response) => {
if (response) {
window.postMessage(
{ type: "AUTISTMASK_RESPONSE", id, ...response },
"*",
);
}
},
);
})
.catch(() => {
// No receiver: the background context is gone. The page's promise
// stays pending, which is what it did before this was a promise
// at all; turning it into a rejection here is a change to what
// dApps see and belongs to its own issue.
});
});
// Listen for events pushed from the background (e.g. accountsChanged)
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
runtime.onMessage.addListener((msg) => {
runtimeApi().onMessage.addListener((msg) => {
if (msg.type === "AUTISTMASK_EVENT") {
window.postMessage(
{

View File

@@ -161,6 +161,12 @@ async function init() {
const params = new URLSearchParams(window.location.search);
const approvalId = params.get("approval");
if (approvalId) {
// Deliberately not awaited, and deliberately not .catch()ed. show()
// is async, so a throw past its first await surfaces as an unhandled
// rejection rather than an uncaught error — measured as still failing
// the run on both harnesses (Playwright `pageerror`, and the Firefox
// driver's console-service drain), so nothing is lost by leaving it
// on that path.
approval.show(approvalId);
showView("approve-site");
return;

View File

@@ -27,8 +27,7 @@ const { walletDefect } = require("../../shared/walletDefects");
const { describeSigningFailure } = require("../../shared/approvalVerify");
const txStatus = require("./txStatus");
const uniswap = require("../../shared/uniswap");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
const { notify, runtimeApi, sendMessage } = require("../../shared/browserApi");
const erc20Iface = new Interface(ERC20_ABI);
@@ -439,34 +438,41 @@ function showSignApproval(details) {
);
}
function show(id) {
// Awaited by nobody: the popup entry point calls this and moves on. It
// therefore has to absorb its own failure, and a background that cannot
// describe the approval is the same outcome as an approval that is gone.
async function show(id) {
approvalId = id;
runtime.connect({ name: "approval:" + id });
runtime.sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id }, (details) => {
if (!details) {
window.close();
return;
}
if (details.type === "tx") {
showTxApproval(details);
return;
}
if (details.type === "sign") {
showSignApproval(details);
return;
}
// Site connection approval
showPhishingWarning(
"approve-site-phishing-warning",
details.isPhishingDomain,
);
$("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml(
state.activeAddress,
);
attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice;
});
runtimeApi().connect({ name: "approval:" + id });
let details = null;
try {
details = await sendMessage({ type: "AUTISTMASK_GET_APPROVAL", id });
} catch {
details = null;
}
if (!details) {
window.close();
return;
}
if (details.type === "tx") {
showTxApproval(details);
return;
}
if (details.type === "sign") {
showSignApproval(details);
return;
}
// Site connection approval
showPhishingWarning(
"approve-site-phishing-warning",
details.isPhishingDomain,
);
$("approve-hostname").textContent = details.hostname;
$("approve-address").innerHTML = approvalAddressHtml(state.activeAddress);
attachCopyHandlers("view-approve-site");
$("approve-remember").checked = state.rememberSiteChoice;
}
let approvalId = null;
@@ -548,7 +554,7 @@ function init(ctx) {
$("btn-approve").addEventListener("click", () => {
const remember = $("approve-remember").checked;
runtime.sendMessage({
notify({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: true,
@@ -559,7 +565,7 @@ function init(ctx) {
$("btn-reject").addEventListener("click", () => {
const remember = $("approve-remember").checked;
runtime.sendMessage({
notify({
type: "AUTISTMASK_APPROVAL_RESPONSE",
id: approvalId,
approved: false,
@@ -648,29 +654,37 @@ function init(ctx) {
decryptedSecret = null;
}
runtime.sendMessage(payload, (response) => {
if (response && response.txHash) {
txStatus.showWait(pendingTxDetails, response.txHash);
return;
}
// A retryable failure leaves the approval pending in the
// background, so stay on this screen with a live button rather
// than sending the user to a dead end.
const outcome = describeSigningFailure(
response,
"The transaction could not be sent.",
);
if (outcome.retryable) {
showError("approve-tx-error", outcome.message);
setTxButtonBusy(false);
} else {
txStatus.showError(pendingTxDetails, null, outcome.message);
}
});
// A send that never reaches the background is reported to the user
// the same way a background that refused it is: describeSigningFailure
// turns a null response into the generic message below.
let response = null;
try {
response = await sendMessage(payload);
} catch {
response = null;
}
if (response && response.txHash) {
txStatus.showWait(pendingTxDetails, response.txHash);
return;
}
// A retryable failure leaves the approval pending in the
// background, so stay on this screen with a live button rather
// than sending the user to a dead end.
const outcome = describeSigningFailure(
response,
"The transaction could not be sent.",
);
if (outcome.retryable) {
showError("approve-tx-error", outcome.message);
setTxButtonBusy(false);
} else {
txStatus.showError(pendingTxDetails, null, outcome.message);
}
});
$("btn-reject-tx").addEventListener("click", () => {
runtime.sendMessage({
notify({
type: "AUTISTMASK_TX_RESPONSE",
id: approvalId,
approved: false,
@@ -764,26 +778,31 @@ function init(ctx) {
decryptedSecret = null;
}
runtime.sendMessage(payload, (response) => {
if (response && response.signature) {
window.close();
return;
}
// The button comes back only when the approval is still pending in
// the background; otherwise it stays disabled and the message says
// why, because a control that cannot succeed must not look like it
// can.
const outcome = describeSigningFailure(
response,
"The message could not be signed.",
);
showError("approve-sign-error", outcome.message);
if (outcome.retryable) setSignButtonBusy(false);
});
let response = null;
try {
response = await sendMessage(payload);
} catch {
response = null;
}
if (response && response.signature) {
window.close();
return;
}
// The button comes back only when the approval is still pending in
// the background; otherwise it stays disabled and the message says
// why, because a control that cannot succeed must not look like it
// can.
const outcome = describeSigningFailure(
response,
"The message could not be signed.",
);
showError("approve-sign-error", outcome.message);
if (outcome.retryable) setSignButtonBusy(false);
});
$("btn-reject-sign").addEventListener("click", () => {
runtime.sendMessage({
notify({
type: "AUTISTMASK_SIGN_RESPONSE",
id: approvalId,
approved: false,

View File

@@ -15,6 +15,7 @@ const {
pushCurrentView,
} = require("./helpers");
const { state, saveState, currentAddress } = require("../../shared/state");
const { notify } = require("../../shared/browserApi");
const {
updateSendBalance,
renderSendTokenSelect,
@@ -292,11 +293,7 @@ function render(ctx) {
state.activeAddress = addr;
await saveState();
render(ctx);
const runtime =
typeof browser !== "undefined"
? browser.runtime
: chrome.runtime;
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
}
});
});

View File

@@ -29,8 +29,7 @@ const {
GITEA_COMMIT_URL,
} = require("../../shared/buildInfo");
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
const { notify } = require("../../shared/browserApi");
let versionClickCount = 0;
let versionClickTimer = null;
@@ -61,7 +60,7 @@ function renderSiteList(containerId, siteMap, stateKey) {
}
}
await saveState();
runtime.sendMessage({ type: "AUTISTMASK_REMOVE_SITE" });
notify({ type: "AUTISTMASK_REMOVE_SITE" });
renderSiteList(containerId, state[key], key);
});
});

View File

@@ -19,6 +19,8 @@
// be bypassed on the scheduled tick — see backgroundRefresh() in
// src/background/index.js and updatePhishingList() in shared/phishingDomains.js.
const { alarmsApi } = require("./browserApi");
const BALANCE_REFRESH_ALARM = "autistmask-balance-refresh";
const PHISHING_REFRESH_ALARM = "autistmask-phishing-refresh";
@@ -26,14 +28,10 @@ const MIN_ALARM_PERIOD_MINUTES = 1;
const BALANCE_REFRESH_PERIOD_MINUTES = 1;
const PHISHING_REFRESH_PERIOD_MINUTES = 24 * 60;
// Resolved on use rather than captured at module load: the worker is torn
// alarmsApi() resolves on use rather than at module load: the worker is torn
// down and re-evaluated repeatedly, and tests install a stub after requiring
// the module.
function alarmsApi() {
if (typeof browser !== "undefined" && browser.alarms) return browser.alarms;
if (typeof chrome !== "undefined" && chrome.alarms) return chrome.alarms;
return null;
}
// this module. It returns null where the API is absent, which is why every
// entry point below degrades instead of throwing.
/**
* Create an alarm unless one with the requested period already exists.

262
src/shared/browserApi.js Normal file
View File

@@ -0,0 +1,262 @@
// The one place in this tree that names `browser` or `chrome`.
//
// The two targets do not agree on the namespace, and they disagree about the
// call shape only in which one is native. Chrome MV3 exposes `chrome.*`,
// where tabs, windows and messaging take a trailing callback and report
// failure through the global `chrome.runtime.lastError`. Firefox MV2 exposes
// `browser.*`, where those same methods return promises — but, measured on
// Firefox 153.0.3, it ALSO honours a trailing Chrome-style callback, returns
// no promise when one is given, and populates `browser.runtime.lastError`.
// The callback code that predated this module therefore ran on both, and
// https://git.eeqj.de/sneak/AutistMask/issues/153 was filed on the belief
// that it did not. This module exists for uniformity, not for repair: the
// tree used to resolve the namespace with a ternary at six call sites and
// then mix promise-form storage with callback-form messaging.
//
// The strategy is promises out, everywhere: one namespace, one call shape,
// composing with the `async` handlers in the background. Callers `await`;
// nothing outside this file has to know which browser it is running on.
//
// Two deliberate asymmetries, because they are what the browsers actually do
// rather than what a uniform-looking shim would pretend:
//
// - Storage is called in its PROMISE form on both namespaces.
// `chrome.storage.local.get()` returns a promise on MV3 and the popup
// already depends on that — src/shared/state.js has always awaited it.
// Wrapping it in a callback here would be a change, not a fix.
// - notify() sends without a callback. It is for a message whose answer
// nobody reads; appending a callback would only manufacture a
// lastError/rejection for a receiver that was never expected to reply.
//
// Everything is resolved on use rather than captured at module load. The MV3
// service worker is torn down and re-evaluated repeatedly, and the unit
// suite installs its stubs on `global.chrome` around a require().
// The extension API namespace, preferring `browser.*` where it exists.
//
// Whole-namespace, never per-method: mixing `browser.tabs` with
// `chrome.windows` would also mix promise and callback semantics inside a
// single call path, which is the bug this module exists to remove.
function extensionApi() {
if (typeof browser !== "undefined" && browser) return browser;
if (typeof chrome !== "undefined" && chrome) return chrome;
return null;
}
// True when the resolved namespace is the promise-flavoured one.
//
// It doubles as "this is the Gecko/MV2 build", which is a second question
// with the same answer and one real caller: src/content/index.js has to
// inject the inpage provider itself there, because MV2 has no
// `"world": "MAIN"` for a manifest-declared content script.
function hasBrowserNamespace() {
return typeof browser !== "undefined" && !!browser;
}
function namespaceMember(name) {
const api = extensionApi();
return (api && api[name]) || null;
}
function runtimeApi() {
return namespaceMember("runtime");
}
function tabsApi() {
return namespaceMember("tabs");
}
function windowsApi() {
return namespaceMember("windows");
}
function alarmsApi() {
return namespaceMember("alarms");
}
// The toolbar button. MV3 calls it `action`, MV2 calls it `browserAction`.
function actionApi() {
const api = extensionApi();
if (!api) return null;
return api.action || api.browserAction || null;
}
// `storage.local`, or null in a context that has no storage permission.
//
// Null rather than a throw for the one caller that genuinely degrades:
// src/shared/phishingDomains.js falls back to its vendored blocklist and does
// its own null check. Everything that reads or writes the wallet goes through
// storageGet()/storageSet(), which reject instead — see there.
function storageLocal() {
const storage = namespaceMember("storage");
return (storage && storage.local) || null;
}
// The callback-path error channel. Read only from inside an appended
// callback, i.e. only on the `chrome.*` path, where it is the sole way a
// failure is reported. The background's three explicit lastError checks are
// gone because invoke() turns it into a rejection before any caller sees it.
function lastError() {
const runtime = runtimeApi();
return (runtime && runtime.lastError) || null;
}
// Call `owner[method](...args)` and return a promise for its result.
//
// On the promise namespace the method already returns one. On the callback
// namespace the callback is appended here and lastError becomes a rejection,
// because a caller holding a promise has nowhere to check a global flag.
function invoke(owner, method, ...args) {
if (!owner || typeof owner[method] !== "function") {
return Promise.reject(
new Error(
"extension API " +
method +
"() is not available in this context",
),
);
}
if (hasBrowserNamespace()) {
try {
return Promise.resolve(owner[method](...args));
} catch (e) {
return Promise.reject(e);
}
}
return new Promise((resolve, reject) => {
owner[method](...args, (result) => {
const err = lastError();
if (err) reject(new Error(err.message || String(err)));
else resolve(result);
});
});
}
/**
* Send a message to the extension's own contexts and resolve with the reply.
*
* Rejects when nothing is listening, on both browsers. A caller that does not
* care must say so — see notify().
*
* @param {Object} message
* @returns {Promise<*>} the receiver's response.
*/
function sendMessage(message) {
return invoke(runtimeApi(), "sendMessage", message);
}
/**
* Send a message nobody is expected to answer, and swallow the fact that
* nobody did.
*
* @param {Object} message
* @returns {void}
*/
function notify(message) {
const runtime = runtimeApi();
if (!runtime || typeof runtime.sendMessage !== "function") return;
const result = runtime.sendMessage(message);
// MV3 hands back a promise for a one-argument send, and it rejects when
// the background is not listening. Unhandled, that surfaces as an error
// the e2e suites fail the run on.
if (result && typeof result.catch === "function") result.catch(() => {});
}
// These two carry the wallet. A missing `storage.local` has to reject and not
// default: resolving {} would make an existing wallet read back as no wallet,
// and resolving a no-op write would discard the user's state with nothing
// logged. A caller that wants to degrade takes storageLocal() directly.
function storageUnavailable(method) {
return Promise.reject(
new Error("extension storage.local is not available: " + method),
);
}
/**
* @param {string|string[]|Object} keys
* @returns {Promise<Object>} the stored items.
* @throws rejects where `storage.local` is absent.
*/
function storageGet(keys) {
const storage = storageLocal();
if (!storage) return storageUnavailable("get");
return Promise.resolve(storage.get(keys));
}
/**
* @param {Object} items
* @returns {Promise<void>}
* @throws rejects where `storage.local` is absent.
*/
function storageSet(items) {
const storage = storageLocal();
if (!storage) return storageUnavailable("set");
return Promise.resolve(storage.set(items));
}
/**
* @param {Object} queryInfo
* @returns {Promise<Array>} the matching tabs.
*/
function tabsQuery(queryInfo) {
return invoke(tabsApi(), "query", queryInfo);
}
/**
* Send a message to one tab's content script.
*
* Rejects for a tab that has no receiver, which is most of them. That
* rejection is the promise-shaped replacement for the runtime.lastError
* checks the broadcast helpers used to make, and callers ignore it the same
* way.
*
* @param {number} tabId
* @param {Object} message
* @returns {Promise<*>}
*/
function tabsSendMessage(tabId, message) {
return invoke(tabsApi(), "sendMessage", tabId, message);
}
/**
* @param {Object} createData
* @returns {Promise<Object>} the created window.
*/
function windowsCreate(createData) {
return invoke(windowsApi(), "create", createData);
}
/**
* @returns {Promise<Object>} the last focused window.
*/
function windowsGetLastFocused() {
return invoke(windowsApi(), "getLastFocused");
}
/**
* @param {number} windowId
* @returns {Promise<void>}
*/
function windowsRemove(windowId) {
return invoke(windowsApi(), "remove", windowId);
}
module.exports = {
actionApi,
alarmsApi,
extensionApi,
hasBrowserNamespace,
notify,
runtimeApi,
sendMessage,
storageGet,
storageLocal,
storageSet,
tabsApi,
tabsQuery,
tabsSendMessage,
windowsApi,
windowsCreate,
windowsGetLastFocused,
windowsRemove,
};

View File

@@ -18,6 +18,7 @@
// its own refresh — see updatePhishingList().
const vendoredConfig = require("./phishingBlocklist.json");
const { storageLocal } = require("./browserApi");
const BLOCKLIST_URL =
"https://raw.githubusercontent.com/MetaMask/eth-phishing-detect/main/src/config.json";
@@ -46,18 +47,10 @@ let lastAttemptTime = 0;
let fetchPromise = null;
let loadPromise = null;
// Resolved on use rather than captured at module load, so a test can install
// a stub after requiring the module and so the popup — which has no reason to
// touch the delta — does not fail to load where the API is absent.
function storageApi() {
if (typeof browser !== "undefined" && browser.storage) {
return browser.storage.local;
}
if (typeof chrome !== "undefined" && chrome.storage) {
return chrome.storage.local;
}
return null;
}
// storageLocal() resolves on use rather than at module load, so a test can
// install a stub after requiring this module, and it returns null where the
// API is absent — which is why the popup, with no reason to touch the delta,
// loads fine without it.
/**
* Sanitise a timestamp read back from storage.
@@ -86,7 +79,7 @@ function sanitizeTimestamp(value) {
* @returns {Promise<void>}
*/
async function loadDeltaFromStorage() {
const storage = storageApi();
const storage = storageLocal();
if (!storage) return;
try {
const result = await storage.get(DELTA_STORAGE_KEY);
@@ -122,7 +115,7 @@ function ensureDeltaLoaded() {
* @returns {Promise<void>}
*/
async function saveDeltaToStorage() {
const storage = storageApi();
const storage = storageLocal();
if (!storage) return;
try {
const data = {

View File

@@ -5,10 +5,7 @@ const { networkById } = require("./networks");
// Dependency-free constant module; safe to pull into a background bundle.
const { RESTORABLE_VIEWS } = require("../popup/restorableViews");
const storageApi =
typeof browser !== "undefined"
? browser.storage.local
: chrome.storage.local;
const { storageGet, storageSet } = require("./browserApi");
const DEFAULT_STATE = {
hasWallet: false,
@@ -114,11 +111,11 @@ async function saveState() {
viewData: state.viewData,
viewStack: state.viewStack,
};
await storageApi.set({ autistmask: persisted });
await storageSet({ autistmask: persisted });
}
async function loadState() {
const result = await storageApi.get("autistmask");
const result = await storageGet("autistmask");
if (result.autistmask) {
const saved = result.autistmask;
state.wallets = saved.wallets || [];

View File

@@ -1,6 +1,8 @@
// Wallet and address deletion state transitions, kept out of the views so the
// selection and broadcast rules are testable without a DOM.
const { notify } = require("./browserApi");
// Two records of the same address can be stored in different cases, so
// address equality is never a literal string comparison.
function sameAddress(a, b) {
@@ -144,9 +146,7 @@ function removeAddressFromState(state, walletIdx, addrIdx) {
// accountsChanged to connected sites. Same call shape as the address
// switch in the home view.
function broadcastActiveChanged() {
const runtime =
typeof browser !== "undefined" ? browser.runtime : chrome.runtime;
runtime.sendMessage({ type: "AUTISTMASK_ACTIVE_CHANGED" });
notify({ type: "AUTISTMASK_ACTIVE_CHANGED" });
}
module.exports = {