Files
AutistMask/src/shared/browserApi.js
T
sneak d377b1f9fe
check / check (push) Failing after 2s
e2e / e2e-chrome (push) Failing after 2s
e2e / e2e-firefox (push) Failing after 2s
fix: open an approval window while another one has focus (closes #290)
The background centred each approval window on the last focused window,
which could be an earlier approval window still open. Headless Chrome
reports one as 1280x720, so the position came out where the browser refused
to create the window ("Bounds must be at least 50% within visible screen
space"), and the request failed with -32603 and no window. It now centres on
the last focused browser window.

Under load a test in the Chrome suite raised its prompt while the previous
test's window was still closing, and either hit that refusal or took the
closing window for its own. The runner now closes every approval window
between tests.

Model: opus-5-5
2026-10-05 03:37:47 +00:00

282 lines
9.5 KiB
JavaScript

// 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));
}
/**
* Erase stored keys. The one caller is the destructive reset on the recovery
* screen (src/popup/views/stateRecovery.js), which is the only way out of a
* profile no build can read; it rejects rather than defaulting for the same
* reason the two above do — a reset that silently did nothing would leave the
* user in the dead end they were promised an exit from.
*
* @param {string|string[]} keys
* @returns {Promise<void>}
* @throws rejects where `storage.local` is absent.
*/
function storageRemove(keys) {
const storage = storageLocal();
if (!storage) return storageUnavailable("remove");
return Promise.resolve(storage.remove(keys));
}
/**
* @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);
}
/**
* @param {Object} queryOptions which windows count, e.g. `windowTypes`.
* @returns {Promise<Object>} the last focused of those windows.
*/
function windowsGetLastFocused(queryOptions) {
return invoke(windowsApi(), "getLastFocused", queryOptions);
}
/**
* @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,
storageRemove,
storageSet,
tabsApi,
tabsQuery,
tabsSendMessage,
windowsApi,
windowsCreate,
windowsGetLastFocused,
windowsRemove,
};