// 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} 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} * @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} 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} the created window. */ function windowsCreate(createData) { return invoke(windowsApi(), "create", createData); } /** * @returns {Promise} the last focused window. */ function windowsGetLastFocused() { return invoke(windowsApi(), "getLastFocused"); } /** * @param {number} windowId * @returns {Promise} */ 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, };