r; } 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 = 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(); } }); } 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) { const disabling_pref_listener = this._disabledPrefListeners[config.id]; if (disabling_pref_listener) { browser.aboutConfigPrefs.onPrefChange.removeListener( disabling_pref_listener ); delete this._disabledPrefListeners[config.id]; } 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); }); } _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); 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, browser.aboutConfigPrefs.getPref(pref) ); } if (value !== this.#checkedPrefCache.get(pref)) { return false; } } return true; } async _enableInterventionsNow(whichInterventions) { // resolveReady may change while we're updating const resolveReady = this._resolveReady; 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 InterventionHelpers.getOS(); this.currentPlatform = os; const customFunctionNames = new Set(Object.keys(this._customFunctions)); const contentScriptsToRegister = []; for (const config of whichInterventions) { config.active = false; if (config.isMissingFiles) { skipped.push(config.label); continue; } config.DISABLING_PREF = `disabled_interventions.${config.id}`; const disabledPrefListener = () => { navigator.locks.request("pref_check_lock", async () => { const value = browser.aboutConfigPrefs.getPref(config.DISABLING_PREF); if (value === true) { await this.disableIntervention(config); debugLog( `Webcompat intervention for ${config.label} disabled by pref` ); } else { await this.enableIntervention(config); debugLog( `Webcompat intervention for ${config.label} enabled by pref` ); } this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ interventionsChanged: this._aboutCompatBroker.filterInterventions(whichInterventions), }); }); }; this._disabledPrefListeners[config.id] = disabledPrefListener; browser.aboutConfigPrefs.onPrefChange.addListener( disabledPrefListener, config.DISABLING_PREF ); const disablingPrefValue = browser.aboutConfigPrefs.getPref( config.DISABLING_PREF ); for (const intervention of config.interventions) { intervention.enabled = false; if (!this._check_for_needed_prefs(intervention)) { continue; } if ( InterventionHelpers.shouldSkip(intervention, cleanVersion, channel) ) { continue; } if ( InterventionHelpers.isMissingCustomFunctions( intervention, customFunctionNames ) ) { continue; } if (!(await InterventionHelpers.checkPlatformMatches(intervention))) { // special case: allow platforms=[] to indicate "disabled by default" if ( intervention.platforms && !intervention.platforms.length && !intervention.not_platforms ) { config.availableOnPlatform = true; } continue; } intervention.enabled = true; config.availableOnPlatform = true; } if (!config.availableOnPlatform) { skipped.push(config.label); continue; } if (disablingPrefValue === true) { skipped.push(config.label); continue; } try { contentScriptsToRegister.push( ...(await this._enableInterventionNow(config)) ); } catch (e) { console.error("Error enabling intervention(s) for", config.label, e); } } this._registerContentScripts(contentScriptsToRegister); if (skipped.length) { debugLog( "Skipping", skipped.length, "un-needed interventions", skipped.sort() ); } this._interventionsEnabled = true; this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ interventionsChanged: this._aboutCompatBroker.filterInterventions(whichInterventions), }); resolveReady(); } async enableIntervention(config, force = false) { return navigator.locks.request("intervention_lock", async () => { await this._enableInterventionNow(config, { force, registerContentScripts: true, }); }); } async disableIntervention(config) { return navigator.locks.request("intervention_lock", async () => { await this._disableInterventionNow(config); }); } async _enableInterventionNow(config, options = {}) { const { force = false, registerContentScripts } = options; 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); let somethingWasEnabled = false; let contentScriptsToRegister = []; for (const intervention of config.interventions) { if (!intervention.enabled && !force) { continue; } await this._changeCustomFuncs("enable", label, intervention, config); if (intervention.content_scripts) { const contentScriptsForIntervention = this._buildContentScriptRegistrations(label, intervention, matches); this._contentScriptsPerIntervention.set( intervention, contentScriptsForIntervention ); contentScriptsToRegister.push(...contentScriptsForIntervention); } await this._enableUAOverrides(label, intervention, matches); await this._enableRequestBlocks(label, intervention, blocks); somethingWasEnabled = true; intervention.enabled = true; } if (registerContentScripts) { this._registerContentScripts(contentScriptsToRegister); } if (!this._getActiveInterventionById(config.id)) { this._availableInterventions.push(config); debugLog("Added webcompat intervention", config.id, config); } else { for (const [index, oldConfig] of this._availableInterventions.entries()) { if (oldConfig.id === config.id && oldConfig !== config) { debugLog("Replaced webcompat intervention", oldConfig.id, config); this._availableInterventions[index] = config; } } } config.active = somethingWasEnabled; return contentScriptsToRegister; } 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; debugLog(`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; debugLog(`Blocking requests as specified for ${label}`); } async _registerContentScripts(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); if (!ids.length) { return; } 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); debugLog( `Registered still-not-active webcompat content scripts`, stillNeeded ); } catch (e) { try { await browser.scripting.registerContentScripts(scriptsToReg); debugLog( `Registered all webcompat content scripts after error registering just non-active ones`, scriptsToReg, e ); } catch (e2) { console.error( `Error while registering webcompat content scripts:`, e2, scriptsToReg ); } } } async _disableContentScripts(label, intervention) { const contentScripts = this._contentScriptsPerIntervention.get(intervention); if (contentScripts) { for (const id of contentScripts.map(s => s.id)) { try { await browser.scripting.unregisterContentScripts({ ids: [id] }); } catch (_) {} } } } _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]; } } PK