interventions.js (18605B)
1 /* This Source Code Form is subject to the terms of the Mozilla Public 2 * License, v. 2.0. If a copy of the MPL was not distributed with this 3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ 4 5 "use strict"; 6 7 /* globals browser, InterventionHelpers */ 8 9 const debugLoggingPrefValue = browser.aboutConfigPrefs.getPref( 10 "disable_debug_logging" 11 ); 12 let debugLog = function () { 13 if (debugLoggingPrefValue !== true) { 14 console.debug.apply(this, arguments); 15 } 16 }; 17 18 class Interventions { 19 constructor(availableInterventions, customFunctions) { 20 this._originalInterventions = availableInterventions; 21 22 this.INTERVENTION_PREF = "enable_interventions"; 23 24 this._interventionsEnabled = true; 25 26 this._readyPromise = new Promise(done => (this._resolveReady = done)); 27 28 this._disabledPrefListeners = {}; 29 30 this._availableInterventions = this._reformatSourceJSON( 31 availableInterventions 32 ); 33 this._customFunctions = customFunctions; 34 35 this._activeListenersPerIntervention = new Map(); 36 this._contentScriptsPerIntervention = new Map(); 37 } 38 39 _reformatSourceJSON(availableInterventions) { 40 return Object.entries(availableInterventions).map(([id, obj]) => { 41 obj.id = id; 42 return obj; 43 }); 44 } 45 46 async onRemoteSettingsUpdate(updatedInterventions) { 47 const oldReadyPromise = this._readyPromise; 48 this._readyPromise = new Promise(done => (this._resolveReady = done)); 49 await oldReadyPromise; 50 this._updateInterventions(updatedInterventions); 51 } 52 53 async _updateInterventions(updatedInterventions) { 54 await this.disableInterventions(); 55 this._availableInterventions = 56 this._reformatSourceJSON(updatedInterventions); 57 await this.enableInterventions(); 58 } 59 60 async _resetToDefaultInterventions() { 61 await this._updateInterventions(this._originalInterventions); 62 } 63 64 ready() { 65 return this._readyPromise; 66 } 67 68 bindAboutCompatBroker(broker) { 69 this._aboutCompatBroker = broker; 70 } 71 72 bootup() { 73 browser.aboutConfigPrefs.onPrefChange.addListener(() => { 74 this.checkInterventionPref(); 75 }, this.INTERVENTION_PREF); 76 this.checkInterventionPref(); 77 } 78 79 async updateInterventions(_data) { 80 const data = structuredClone(_data); 81 await this.disableInterventions(data); 82 await this.enableInterventions(data); 83 for (const intervention of data) { 84 const { id } = intervention; 85 const i = this._availableInterventions.findIndex(v => v.id === id); 86 if (i > -1) { 87 this._availableInterventions[i] = intervention; 88 } else { 89 this._availableInterventions.push(intervention); 90 } 91 } 92 return data; 93 } 94 95 checkInterventionPref() { 96 navigator.locks.request("pref_check_lock", async () => { 97 const value = browser.aboutConfigPrefs.getPref(this.INTERVENTION_PREF); 98 if (value === undefined) { 99 await browser.aboutConfigPrefs.setPref(this.INTERVENTION_PREF, true); 100 } else if (value === false) { 101 await this.disableInterventions(); 102 } else { 103 await this.enableInterventions(); 104 } 105 }); 106 } 107 108 getAvailableInterventions() { 109 return this._availableInterventions; 110 } 111 112 _getActiveInterventionById(whichId) { 113 return this._availableInterventions.find(({ id }) => id === whichId); 114 } 115 116 isEnabled() { 117 return this._interventionsEnabled; 118 } 119 120 async enableInterventions(whichInterventions = this._availableInterventions) { 121 return navigator.locks.request("intervention_lock", async () => { 122 await this._enableInterventionsNow(whichInterventions); 123 }); 124 } 125 126 async disableInterventions( 127 whichInterventions = this._availableInterventions 128 ) { 129 return navigator.locks.request("intervention_lock", async () => { 130 for (const config of whichInterventions) { 131 const disabling_pref_listener = this._disabledPrefListeners[config.id]; 132 if (disabling_pref_listener) { 133 browser.aboutConfigPrefs.onPrefChange.removeListener( 134 disabling_pref_listener 135 ); 136 delete this._disabledPrefListeners[config.id]; 137 } 138 139 await this._disableInterventionNow(config); 140 } 141 142 this._interventionsEnabled = false; 143 this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ 144 interventionsChanged: false, 145 }); 146 }); 147 } 148 149 #checkedPrefListeners = new Map(); 150 #checkedPrefCache = new Map(); 151 152 async onCheckedPrefChanged(pref) { 153 navigator.locks.request("pref_check_lock", async () => { 154 this.#checkedPrefCache.delete(pref); 155 const toRecheck = this._availableInterventions.filter(cfg => 156 cfg.interventions.find(i => i.pref_check && pref in i.pref_check) 157 ); 158 await this.updateInterventions(toRecheck); 159 }); 160 } 161 162 _check_for_needed_prefs(intervention) { 163 if (!intervention.pref_check) { 164 return true; 165 } 166 for (const pref of Object.keys(intervention.pref_check ?? {})) { 167 if (!this.#checkedPrefListeners.has(pref)) { 168 const listener = () => this.onCheckedPrefChanged(pref); 169 this.#checkedPrefListeners.set(pref, listener); 170 browser.aboutConfigPrefs.onPrefChange.addListener(listener, pref); 171 } 172 } 173 for (const [pref, value] of Object.entries(intervention.pref_check ?? {})) { 174 if (!this.#checkedPrefCache.has(pref)) { 175 this.#checkedPrefCache.set( 176 pref, 177 browser.aboutConfigPrefs.getPref(pref) 178 ); 179 } 180 if (value !== this.#checkedPrefCache.get(pref)) { 181 return false; 182 } 183 } 184 return true; 185 } 186 187 async _enableInterventionsNow(whichInterventions) { 188 // resolveReady may change while we're updating 189 const resolveReady = this._resolveReady; 190 191 const skipped = []; 192 193 const channel = await browser.appConstants.getEffectiveUpdateChannel(); 194 const version = 195 this.versionForTesting ?? 196 (await browser.runtime.getBrowserInfo()).version; 197 const cleanVersion = parseFloat(version.match(/\d+(\.\d+)?/)[0]); 198 199 const os = await InterventionHelpers.getOS(); 200 this.currentPlatform = os; 201 202 const customFunctionNames = new Set(Object.keys(this._customFunctions)); 203 204 const contentScriptsToRegister = []; 205 for (const config of whichInterventions) { 206 config.active = false; 207 208 if (config.isMissingFiles) { 209 skipped.push(config.label); 210 continue; 211 } 212 213 config.DISABLING_PREF = `disabled_interventions.${config.id}`; 214 const disabledPrefListener = () => { 215 navigator.locks.request("pref_check_lock", async () => { 216 const value = browser.aboutConfigPrefs.getPref(config.DISABLING_PREF); 217 if (value === true) { 218 await this.disableIntervention(config); 219 debugLog( 220 `Webcompat intervention for ${config.label} disabled by pref` 221 ); 222 } else { 223 await this.enableIntervention(config); 224 debugLog( 225 `Webcompat intervention for ${config.label} enabled by pref` 226 ); 227 } 228 this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ 229 interventionsChanged: 230 this._aboutCompatBroker.filterInterventions(whichInterventions), 231 }); 232 }); 233 }; 234 this._disabledPrefListeners[config.id] = disabledPrefListener; 235 browser.aboutConfigPrefs.onPrefChange.addListener( 236 disabledPrefListener, 237 config.DISABLING_PREF 238 ); 239 240 const disablingPrefValue = browser.aboutConfigPrefs.getPref( 241 config.DISABLING_PREF 242 ); 243 244 for (const intervention of config.interventions) { 245 intervention.enabled = false; 246 if (!this._check_for_needed_prefs(intervention)) { 247 continue; 248 } 249 if ( 250 InterventionHelpers.shouldSkip(intervention, cleanVersion, channel) 251 ) { 252 continue; 253 } 254 if ( 255 InterventionHelpers.isMissingCustomFunctions( 256 intervention, 257 customFunctionNames 258 ) 259 ) { 260 continue; 261 } 262 if (!(await InterventionHelpers.checkPlatformMatches(intervention))) { 263 // special case: allow platforms=[] to indicate "disabled by default" 264 if ( 265 intervention.platforms && 266 !intervention.platforms.length && 267 !intervention.not_platforms 268 ) { 269 config.availableOnPlatform = true; 270 } 271 continue; 272 } 273 intervention.enabled = true; 274 config.availableOnPlatform = true; 275 } 276 277 if (!config.availableOnPlatform) { 278 skipped.push(config.label); 279 continue; 280 } 281 if (disablingPrefValue === true) { 282 skipped.push(config.label); 283 continue; 284 } 285 286 try { 287 contentScriptsToRegister.push( 288 ...(await this._enableInterventionNow(config)) 289 ); 290 } catch (e) { 291 console.error("Error enabling intervention(s) for", config.label, e); 292 } 293 } 294 this._registerContentScripts(contentScriptsToRegister); 295 296 if (skipped.length) { 297 debugLog( 298 "Skipping", 299 skipped.length, 300 "un-needed interventions", 301 skipped.sort() 302 ); 303 } 304 305 this._interventionsEnabled = true; 306 this._aboutCompatBroker.portsToAboutCompatTabs.broadcast({ 307 interventionsChanged: 308 this._aboutCompatBroker.filterInterventions(whichInterventions), 309 }); 310 311 resolveReady(); 312 } 313 314 async enableIntervention(config, force = false) { 315 return navigator.locks.request("intervention_lock", async () => { 316 await this._enableInterventionNow(config, { 317 force, 318 registerContentScripts: true, 319 }); 320 }); 321 } 322 323 async disableIntervention(config) { 324 return navigator.locks.request("intervention_lock", async () => { 325 await this._disableInterventionNow(config); 326 }); 327 } 328 329 async _enableInterventionNow(config, options = {}) { 330 const { force = false, registerContentScripts } = options; 331 if (config.active) { 332 return []; 333 } 334 335 const { bugs, label } = config; 336 const blocks = Object.values(bugs) 337 .map(bug => bug.blocks) 338 .flat() 339 .filter(v => v !== undefined); 340 const matches = Object.values(bugs) 341 .map(bug => bug.matches) 342 .flat() 343 .filter(v => v !== undefined); 344 345 let somethingWasEnabled = false; 346 let contentScriptsToRegister = []; 347 for (const intervention of config.interventions) { 348 if (!intervention.enabled && !force) { 349 continue; 350 } 351 352 await this._changeCustomFuncs("enable", label, intervention, config); 353 if (intervention.content_scripts) { 354 const contentScriptsForIntervention = 355 this._buildContentScriptRegistrations(label, intervention, matches); 356 this._contentScriptsPerIntervention.set( 357 intervention, 358 contentScriptsForIntervention 359 ); 360 contentScriptsToRegister.push(...contentScriptsForIntervention); 361 } 362 await this._enableUAOverrides(label, intervention, matches); 363 await this._enableRequestBlocks(label, intervention, blocks); 364 somethingWasEnabled = true; 365 intervention.enabled = true; 366 } 367 if (registerContentScripts) { 368 this._registerContentScripts(contentScriptsToRegister); 369 } 370 371 if (!this._getActiveInterventionById(config.id)) { 372 this._availableInterventions.push(config); 373 debugLog("Added webcompat intervention", config.id, config); 374 } else { 375 for (const [index, oldConfig] of this._availableInterventions.entries()) { 376 if (oldConfig.id === config.id && oldConfig !== config) { 377 debugLog("Replaced webcompat intervention", oldConfig.id, config); 378 this._availableInterventions[index] = config; 379 } 380 } 381 } 382 383 config.active = somethingWasEnabled; 384 return contentScriptsToRegister; 385 } 386 387 async _disableInterventionNow(_config) { 388 const config = this._getActiveInterventionById(_config?.id ?? _config); 389 if (!config) { 390 return; 391 } 392 393 const { active, label, interventions } = config; 394 395 if (!active) { 396 return; 397 } 398 399 for (const intervention of interventions) { 400 if (!intervention.enabled) { 401 continue; 402 } 403 404 await this._changeCustomFuncs("disable", label, intervention, config); 405 if (intervention.content_scripts) { 406 await this._disableContentScripts(label, intervention); 407 } 408 409 // This covers both request blocks and ua_string cases 410 const listeners = this._activeListenersPerIntervention.get(intervention); 411 if (listeners) { 412 for (const [name, listener] of Object.entries(listeners)) { 413 browser.webRequest[name].removeListener(listener); 414 } 415 this._activeListenersPerIntervention.delete(intervention); 416 } 417 } 418 419 config.active = false; 420 } 421 422 async _changeCustomFuncs(action, label, intervention, config) { 423 for (const [customFuncName, customFunc] of Object.entries( 424 this._customFunctions 425 )) { 426 if (customFuncName in intervention) { 427 for (const details of intervention[customFuncName]) { 428 try { 429 await customFunc[action](details, config); 430 } catch (e) { 431 console.trace( 432 `Error while calling custom function ${customFuncName}.${action} for ${label}:`, 433 e 434 ); 435 } 436 } 437 } 438 } 439 } 440 441 async _enableUAOverrides(label, intervention, matches) { 442 if (!("ua_string" in intervention)) { 443 return; 444 } 445 446 let listeners = this._activeListenersPerIntervention.get(intervention); 447 if (!listeners) { 448 listeners = {}; 449 this._activeListenersPerIntervention.set(intervention, listeners); 450 } 451 452 const listener = details => { 453 const { enabled, ua_string } = intervention; 454 455 // Don't actually override the UA for an experiment if the user is not 456 // part of the experiment (unless they force-enabed the override). 457 if ( 458 enabled && 459 (!intervention.experiment || intervention.permanentPrefEnabled === true) 460 ) { 461 for (const header of details.requestHeaders) { 462 if (header.name.toLowerCase() !== "user-agent") { 463 continue; 464 } 465 466 // Don't override the UA if we're on a mobile device that has the 467 // "Request Desktop Site" mode enabled. The UA for the desktop mode 468 // is set inside Gecko with a simple string replace, so we can use 469 // that as a check, see https://searchfox.org/mozilla-central/rev/89d33e1c3b0a57a9377b4815c2f4b58d933b7c32/mobile/android/chrome/geckoview/GeckoViewSettingsChild.js#23-28 470 let isMobileWithDesktopMode = 471 this.currentPlatform == "android" && 472 header.value.includes("X11; Linux x86_64"); 473 if (isMobileWithDesktopMode) { 474 continue; 475 } 476 477 header.value = InterventionHelpers.applyUAChanges( 478 header.value, 479 ua_string 480 ); 481 } 482 } 483 return { requestHeaders: details.requestHeaders }; 484 }; 485 486 browser.webRequest.onBeforeSendHeaders.addListener( 487 listener, 488 { urls: matches }, 489 ["blocking", "requestHeaders"] 490 ); 491 492 listeners.onBeforeSendHeaders = listener; 493 494 debugLog(`Enabled UA override for ${label}`); 495 } 496 497 async _enableRequestBlocks(label, intervention, blocks) { 498 if (!blocks.length) { 499 return; 500 } 501 502 let listeners = this._activeListenersPerIntervention.get(intervention); 503 if (!listeners) { 504 listeners = {}; 505 this._activeListenersPerIntervention.set(intervention, listeners); 506 } 507 508 const listener = () => { 509 return { cancel: true }; 510 }; 511 512 browser.webRequest.onBeforeRequest.addListener(listener, { urls: blocks }, [ 513 "blocking", 514 ]); 515 516 listeners.onBeforeRequest = listener; 517 debugLog(`Blocking requests as specified for ${label}`); 518 } 519 520 async _registerContentScripts(scriptsToReg) { 521 // Try to avoid re-registering scripts already registered 522 // (e.g. if the webcompat background page is restarted 523 // after an extension process crash, after having registered 524 // the content scripts already once), but do not prevent 525 // to try registering them again if the getRegisteredContentScripts 526 // method returns an unexpected rejection. 527 528 const ids = scriptsToReg.map(s => s.id); 529 if (!ids.length) { 530 return; 531 } 532 try { 533 const alreadyRegged = await browser.scripting.getRegisteredContentScripts( 534 { ids } 535 ); 536 const alreadyReggedIds = alreadyRegged.map(script => script.id); 537 const stillNeeded = scriptsToReg.filter( 538 ({ id }) => !alreadyReggedIds.includes(id) 539 ); 540 await browser.scripting.registerContentScripts(stillNeeded); 541 debugLog( 542 `Registered still-not-active webcompat content scripts`, 543 stillNeeded 544 ); 545 } catch (e) { 546 try { 547 await browser.scripting.registerContentScripts(scriptsToReg); 548 debugLog( 549 `Registered all webcompat content scripts after error registering just non-active ones`, 550 scriptsToReg, 551 e 552 ); 553 } catch (e2) { 554 console.error( 555 `Error while registering webcompat content scripts:`, 556 e2, 557 scriptsToReg 558 ); 559 } 560 } 561 } 562 563 async _disableContentScripts(label, intervention) { 564 const contentScripts = 565 this._contentScriptsPerIntervention.get(intervention); 566 if (contentScripts) { 567 for (const id of contentScripts.map(s => s.id)) { 568 try { 569 await browser.scripting.unregisterContentScripts({ ids: [id] }); 570 } catch (_) {} 571 } 572 } 573 } 574 575 _buildContentScriptRegistrations(label, intervention, matches) { 576 const registration = { 577 id: `webcompat intervention for ${label}: ${JSON.stringify(intervention.content_scripts)}`, 578 matches, 579 persistAcrossSessions: false, 580 }; 581 582 let { all_frames, css, js, run_at } = intervention.content_scripts; 583 if (!css && !js) { 584 console.error(`Missing js or css for content_script in ${label}`); 585 return []; 586 } 587 if (all_frames) { 588 registration.allFrames = true; 589 } 590 if (css) { 591 registration.css = css.map(item => { 592 if (item.includes("/")) { 593 return item; 594 } 595 return `injections/css/${item}`; 596 }); 597 } 598 if (js) { 599 registration.js = js.map(item => { 600 if (item.includes("/")) { 601 return item; 602 } 603 return `injections/js/${item}`; 604 }); 605 } 606 if (run_at) { 607 registration.runAt = run_at; 608 } else { 609 registration.runAt = "document_start"; 610 } 611 612 return [registration]; 613 } 614 }