1
0
Fork 0
firefox/browser/extensions/webcompat/lib/interventions.js
Daniel Baumann 5e9a113729
Adding upstream version 140.0.
Signed-off-by: Daniel Baumann <daniel.baumann@progress-linux.org>
2025-06-25 09:37:52 +02:00

511 lines
15 KiB
JavaScript

/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
"use strict";
/* globals browser, InterventionHelpers */
class Interventions {
constructor(availableInterventions, customFunctions) {
this.INTERVENTION_PREF = "perform_injections";
this._interventionsEnabled = true;
this._readyPromise = new Promise(done => (this._resolveReady = done));
this._availableInterventions = Object.entries(availableInterventions).map(
([id, obj]) => {
obj.id = id;
return obj;
}
);
this._customFunctions = customFunctions;
this._activeListenersPerIntervention = new Map();
this._contentScriptsPerIntervention = new Map();
}
ready() {
return this._readyPromise;
}
bindAboutCompatBroker(broker) {
this._aboutCompatBroker = broker;
}
bootup() {
browser.aboutConfigPrefs.onPrefChange.addListener(() => {
this.checkInterventionPref();
}, this.INTERVENTION_PREF);
this.checkInterventionPref();
}
async updateInterventions(_data) {
const data = structuredClone(_data);
await this.disableInterventions(data);
await this.enableInterventions(data);
for (const intervention of data) {
const { id } = intervention;
const i = this._availableInterventions.findIndex(v => v.id === id);
if (i > -1) {
this._availableInterventions[i] = intervention;
} else {
this._availableInterventions.push(intervention);
}
}
return data;
}
checkInterventionPref() {
navigator.locks.request("pref_check_lock", async () => {
const value = await browser.aboutConfigPrefs.getPref(
this.INTERVENTION_PREF
);
if (value === undefined) {
await browser.aboutConfigPrefs.setPref(this.INTERVENTION_PREF, true);
} else if (value === false) {
await this.disableInterventions();
} else {
await this.enableInterventions();
}
});
}
checkOverridePref() {
navigator.locks.request("pref_check_lock", async () => {
const value = await browser.aboutConfigPrefs.getPref(this.OVERRIDE_PREF);
if (value === undefined) {
await browser.aboutConfigPrefs.setPref(this.OVERRIDE_PREF, true);
} else if (value === false) {
await this.unregisterUAOverrides();
} else {
await this.registerUAOverrides();
}
});
}
getAvailableInterventions() {
return this._availableInterventions;
}
_getActiveInterventionById(whichId) {
return this._availableInterventions.find(({ id }) => id === whichId);
}
isEnabled() {
return this._interventionsEnabled;
}
async enableInterventions(whichInterventions = this._availableInterventions) {
return navigator.locks.request("intervention_lock", async () => {
await this._enableInterventionsNow(whichInterventions);
});
}
async disableInterventions(
whichInterventions = this._availableInterventions
) {
return navigator.locks.request("intervention_lock", async () => {
for (const config of whichInterventions) {
await this._disableInterventionNow(config);
}
this._interventionsEnabled = false;
this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
interventionsChanged: false,
});
});
}
#checkedPrefListeners = new Map();
#checkedPrefCache = new Map();
async onCheckedPrefChanged(pref) {
navigator.locks.request("pref_check_lock", async () => {
this.#checkedPrefCache.delete(pref);
const toRecheck = this._availableInterventions.filter(cfg =>
cfg.interventions.find(i => i.pref_check && pref in i.pref_check)
);
await this.updateInterventions(toRecheck);
});
}
async _check_for_needed_prefs(intervention) {
if (!intervention.pref_check) {
return true;
}
for (const pref of Object.keys(intervention.pref_check ?? {})) {
if (!this.#checkedPrefListeners.has(pref)) {
const listener = () => this.onCheckedPrefChanged(pref);
this.#checkedPrefListeners.set(pref, listener);
await browser.aboutConfigPrefs.onPrefChange.addListener(listener, pref);
}
}
for (const [pref, value] of Object.entries(intervention.pref_check ?? {})) {
if (!this.#checkedPrefCache.has(pref)) {
this.#checkedPrefCache.set(
pref,
await browser.aboutConfigPrefs.getPref(pref)
);
}
if (value !== this.#checkedPrefCache.get(pref)) {
return false;
}
}
return true;
}
async _enableInterventionsNow(whichInterventions) {
const skipped = [];
const channel = await browser.appConstants.getEffectiveUpdateChannel();
const version =
this.versionForTesting ??
(await browser.runtime.getBrowserInfo()).version;
const cleanVersion = parseFloat(version.match(/\d+(\.\d+)?/)[0]);
const { os } = await browser.runtime.getPlatformInfo();
this.currentPlatform = os;
for (const config of whichInterventions) {
for (const intervention of config.interventions) {
intervention.enabled = false;
if (!(await this._check_for_needed_prefs(intervention))) {
continue;
}
if (
await InterventionHelpers.shouldSkip(
intervention,
cleanVersion,
channel
)
) {
continue;
}
if (!(await InterventionHelpers.checkPlatformMatches(intervention))) {
continue;
}
intervention.enabled = true;
config.availableOnPlatform = true;
}
if (!config.availableOnPlatform) {
skipped.push(config.label);
continue;
}
try {
await this._enableInterventionNow(config);
} catch (e) {
console.error("Error enabling intervention(s) for", config.label, e);
}
}
if (skipped.length) {
console.warn(
"Skipping",
skipped.length,
"un-needed interventions",
skipped.sort()
);
}
this._interventionsEnabled = true;
this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({
interventionsChanged:
this._aboutCompatBroker.filterInterventions(whichInterventions),
});
this._resolveReady();
}
async enableIntervention(config) {
return navigator.locks.request("intervention_lock", async () => {
await this._enableInterventionNow(config);
});
}
async disableIntervention(config) {
return navigator.locks.request("intervention_lock", async () => {
await this._disableInterventionNow(config);
});
}
async _enableInterventionNow(config) {
if (config.active) {
return;
}
const { bugs, label } = config;
const blocks = Object.values(bugs)
.map(bug => bug.blocks)
.flat()
.filter(v => v !== undefined);
const matches = Object.values(bugs)
.map(bug => bug.matches)
.flat()
.filter(v => v !== undefined);
for (const intervention of config.interventions) {
if (!intervention.enabled) {
continue;
}
await this._changeCustomFuncs("enable", label, intervention, config);
if (intervention.content_scripts) {
await this._enableContentScripts(
config.id,
label,
intervention,
matches
);
}
await this._enableUAOverrides(label, intervention, matches);
await this._enableRequestBlocks(label, intervention, blocks);
}
if (!this._getActiveInterventionById(config.id)) {
this._availableInterventions.push(config);
console.info("Added webcompat intervention", config.id, config);
} else {
for (const [index, oldConfig] of this._availableInterventions.entries()) {
if (oldConfig.id === config.id && oldConfig !== config) {
console.info("Replaced webcompat intervention", oldConfig.id, config);
this._availableInterventions[index] = config;
}
}
}
config.active = true;
}
async _disableInterventionNow(_config) {
const config = this._getActiveInterventionById(_config?.id ?? _config);
if (!config) {
return;
}
const { active, label, interventions } = config;
if (!active) {
return;
}
for (const intervention of interventions) {
if (!intervention.enabled) {
continue;
}
await this._changeCustomFuncs("disable", label, intervention, config);
if (intervention.content_scripts) {
await this._disableContentScripts(label, intervention);
}
// This covers both request blocks and ua_string cases
const listeners = this._activeListenersPerIntervention.get(intervention);
if (listeners) {
for (const [name, listener] of Object.entries(listeners)) {
browser.webRequest[name].removeListener(listener);
}
this._activeListenersPerIntervention.delete(intervention);
}
}
config.active = false;
}
async _changeCustomFuncs(action, label, intervention, config) {
for (const [customFuncName, customFunc] of Object.entries(
this._customFunctions
)) {
if (customFuncName in intervention) {
for (const details of intervention[customFuncName]) {
try {
await customFunc[action](details, config);
} catch (e) {
console.trace(
`Error while calling custom function ${customFuncName}.${action} for ${label}:`,
e
);
}
}
}
}
}
async _enableUAOverrides(label, intervention, matches) {
if (!("ua_string" in intervention)) {
return;
}
let listeners = this._activeListenersPerIntervention.get(intervention);
if (!listeners) {
listeners = {};
this._activeListenersPerIntervention.set(intervention, listeners);
}
const listener = details => {
const { enabled, ua_string } = intervention;
// Don't actually override the UA for an experiment if the user is not
// part of the experiment (unless they force-enabed the override).
if (
enabled &&
(!intervention.experiment || intervention.permanentPrefEnabled === true)
) {
for (const header of details.requestHeaders) {
if (header.name.toLowerCase() !== "user-agent") {
continue;
}
// Don't override the UA if we're on a mobile device that has the
// "Request Desktop Site" mode enabled. The UA for the desktop mode
// is set inside Gecko with a simple string replace, so we can use
// that as a check, see https://searchfox.org/mozilla-central/rev/89d33e1c3b0a57a9377b4815c2f4b58d933b7c32/mobile/android/chrome/geckoview/GeckoViewSettingsChild.js#23-28
let isMobileWithDesktopMode =
this.currentPlatform == "android" &&
header.value.includes("X11; Linux x86_64");
if (isMobileWithDesktopMode) {
continue;
}
header.value = InterventionHelpers.applyUAChanges(
header.value,
ua_string
);
}
}
return { requestHeaders: details.requestHeaders };
};
browser.webRequest.onBeforeSendHeaders.addListener(
listener,
{ urls: matches },
["blocking", "requestHeaders"]
);
listeners.onBeforeSendHeaders = listener;
console.info(`Enabled UA override for ${label}`);
}
async _enableRequestBlocks(label, intervention, blocks) {
if (!blocks.length) {
return;
}
let listeners = this._activeListenersPerIntervention.get(intervention);
if (!listeners) {
listeners = {};
this._activeListenersPerIntervention.set(intervention, listeners);
}
const listener = () => {
return { cancel: true };
};
browser.webRequest.onBeforeRequest.addListener(listener, { urls: blocks }, [
"blocking",
]);
listeners.onBeforeRequest = listener;
console.info(`Blocking requests as specified for ${label}`);
}
async _enableContentScripts(bug, label, intervention, matches) {
const scriptsToReg = this._buildContentScriptRegistrations(
label,
intervention,
matches
);
this._contentScriptsPerIntervention.set(intervention, scriptsToReg);
// Try to avoid re-registering scripts already registered
// (e.g. if the webcompat background page is restarted
// after an extension process crash, after having registered
// the content scripts already once), but do not prevent
// to try registering them again if the getRegisteredContentScripts
// method returns an unexpected rejection.
const ids = scriptsToReg.map(s => s.id);
try {
const alreadyRegged = await browser.scripting.getRegisteredContentScripts(
{ ids }
);
const alreadyReggedIds = alreadyRegged.map(script => script.id);
const stillNeeded = scriptsToReg.filter(
({ id }) => !alreadyReggedIds.includes(id)
);
await browser.scripting.registerContentScripts(stillNeeded);
console.info(
`Registered still-not-active content scripts for ${label}`,
stillNeeded
);
} catch (e) {
try {
await browser.scripting.registerContentScripts(scriptsToReg);
console.debug(
`Registered all content scripts for ${label} after error registering just non-active ones`,
scriptsToReg,
e
);
} catch (e2) {
console.error(
`Error while registering content scripts for ${label}:`,
e2,
scriptsToReg
);
}
}
}
async _disableContentScripts(label, intervention) {
const contentScripts =
this._contentScriptsPerIntervention.get(intervention);
if (contentScripts) {
const ids = contentScripts.map(s => s.id);
await browser.scripting.unregisterContentScripts({ ids });
}
}
_buildContentScriptRegistrations(label, intervention, matches) {
const registration = {
id: `webcompat intervention for ${label}: ${JSON.stringify(intervention.content_scripts)}`,
matches,
persistAcrossSessions: false,
};
let { all_frames, css, js, run_at } = intervention.content_scripts;
if (!css && !js) {
console.error(`Missing js or css for content_script in ${label}`);
return [];
}
if (all_frames) {
registration.allFrames = true;
}
if (css) {
registration.css = css.map(item => {
if (item.includes("/")) {
return item;
}
return `injections/css/${item}`;
});
}
if (js) {
registration.js = js.map(item => {
if (item.includes("/")) {
return item;
}
return `injections/js/${item}`;
});
}
if (run_at) {
registration.runAt = run_at;
} else {
registration.runAt = "document_start";
}
return [registration];
}
}