refactor: one shared extension-API module, and drive the dApp flows on Firefox (closes #153)
This commit was merged in pull request #281.
This commit is contained in:
@@ -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
262
src/shared/browserApi.js
Normal 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,
|
||||
};
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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 || [];
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user