ext-chrome-settings-overrides.js (20692B)
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 var { ExtensionPreferencesManager } = ChromeUtils.importESModule( 8 "resource://gre/modules/ExtensionPreferencesManager.sys.mjs" 9 ); 10 var { ExtensionParent } = ChromeUtils.importESModule( 11 "resource://gre/modules/ExtensionParent.sys.mjs" 12 ); 13 14 ChromeUtils.defineESModuleGetters(this, { 15 ExtensionControlledPopup: 16 "resource:///modules/ExtensionControlledPopup.sys.mjs", 17 ExtensionPermissions: "resource://gre/modules/ExtensionPermissions.sys.mjs", 18 ExtensionSettingsStore: 19 "resource://gre/modules/ExtensionSettingsStore.sys.mjs", 20 HomePage: "resource:///modules/HomePage.sys.mjs", 21 }); 22 23 const DEFAULT_SEARCH_STORE_TYPE = "default_search"; 24 const DEFAULT_SEARCH_SETTING_NAME = "defaultSearch"; 25 26 const HOMEPAGE_PREF = "browser.startup.homepage"; 27 const HOMEPAGE_PRIVATE_ALLOWED = 28 "browser.startup.homepage_override.privateAllowed"; 29 const HOMEPAGE_EXTENSION_CONTROLLED = 30 "browser.startup.homepage_override.extensionControlled"; 31 const HOMEPAGE_CONFIRMED_TYPE = "homepageNotification"; 32 const HOMEPAGE_SETTING_TYPE = "prefs"; 33 const HOMEPAGE_SETTING_NAME = "homepage_override"; 34 35 ChromeUtils.defineLazyGetter(this, "homepagePopup", () => { 36 return new ExtensionControlledPopup({ 37 confirmedType: HOMEPAGE_CONFIRMED_TYPE, 38 observerTopic: "browser-open-homepage-start", 39 popupnotificationId: "extension-homepage-notification", 40 settingType: HOMEPAGE_SETTING_TYPE, 41 settingKey: HOMEPAGE_SETTING_NAME, 42 descriptionId: "extension-homepage-notification-description", 43 descriptionMessageId: "homepageControlled.message", 44 learnMoreLink: "extension-home", 45 preferencesLocation: "home-homeOverride", 46 preferencesEntrypoint: "addon-manage-home-override", 47 async beforeDisableAddon(popup, win) { 48 // Disabling an add-on should remove the tabs that it has open, but we want 49 // to open the new homepage in this tab (which might get closed). 50 // 1. Replace the tab's URL with about:blank, wait for it to change 51 // 2. Now that this tab isn't associated with the add-on, disable the add-on 52 // 3. Trigger the browser's homepage method 53 let gBrowser = win.gBrowser; 54 let tab = gBrowser.selectedTab; 55 await replaceUrlInTab(gBrowser, tab, Services.io.newURI("about:blank")); 56 Services.prefs.addObserver(HOMEPAGE_PREF, async function prefObserver() { 57 Services.prefs.removeObserver(HOMEPAGE_PREF, prefObserver); 58 let loaded = waitForTabLoaded(tab); 59 win.BrowserCommands.home(); 60 await loaded; 61 // Manually trigger an event in case this is controlled again. 62 popup.open(); 63 }); 64 }, 65 }); 66 }); 67 68 // When the browser starts up it will trigger the observer topic we're expecting 69 // but that happens before our observer has been registered. To handle the 70 // startup case we need to check if the preferences are set to load the homepage 71 // and check if the homepage is active, then show the doorhanger in that case. 72 async function handleInitialHomepagePopup(extensionId, homepageUrl) { 73 // browser.startup.page == 1 is show homepage. 74 if ( 75 Services.prefs.getIntPref("browser.startup.page") == 1 && 76 windowTracker.topWindow 77 ) { 78 let { gBrowser } = windowTracker.topWindow; 79 let tab = gBrowser.selectedTab; 80 let currentUrl = gBrowser.currentURI.spec; 81 // When the first window is still loading the URL might be about:blank. 82 // Wait for that the actual page to load before checking the URL, unless 83 // the homepage is set to about:blank. 84 if (currentUrl != homepageUrl && currentUrl == "about:blank") { 85 await waitForTabLoaded(tab); 86 currentUrl = gBrowser.currentURI.spec; 87 } 88 // Once the page has loaded, if necessary and the active tab hasn't changed, 89 // then show the popup now. 90 if (currentUrl == homepageUrl && gBrowser.selectedTab == tab) { 91 homepagePopup.open(); 92 return; 93 } 94 } 95 homepagePopup.addObserver(extensionId); 96 } 97 98 /** 99 * Handles the homepage url setting for an extension. 100 * 101 * @param {object} extension 102 * The extension setting the hompage url. 103 * @param {string} homepageUrl 104 * The homepage url to set. 105 */ 106 async function handleHomepageUrl(extension, homepageUrl) { 107 // For new installs and enabling a disabled addon, we will show 108 // the prompt. We clear the confirmation in onDisabled and 109 // onUninstalled, so in either ADDON_INSTALL or ADDON_ENABLE it 110 // is already cleared, resulting in the prompt being shown if 111 // necessary the next time the homepage is shown. 112 113 // For localizing the homepageUrl, or otherwise updating the value 114 // we need to always set the setting here. 115 let inControl = await ExtensionPreferencesManager.setSetting( 116 extension.id, 117 "homepage_override", 118 homepageUrl 119 ); 120 121 if (inControl) { 122 Services.prefs.setBoolPref( 123 HOMEPAGE_PRIVATE_ALLOWED, 124 extension.privateBrowsingAllowed 125 ); 126 // Also set this now as an upgraded browser will need this. 127 Services.prefs.setBoolPref(HOMEPAGE_EXTENSION_CONTROLLED, true); 128 if (extension.startupReason == "APP_STARTUP") { 129 handleInitialHomepagePopup(extension.id, homepageUrl); 130 } else { 131 homepagePopup.addObserver(extension.id); 132 } 133 } 134 135 // We need to monitor permission change and update the preferences. 136 // eslint-disable-next-line mozilla/balanced-listeners 137 extension.on("add-permissions", async (ignoreEvent, permissions) => { 138 if (permissions.permissions.includes("internal:privateBrowsingAllowed")) { 139 let item = 140 await ExtensionPreferencesManager.getSetting("homepage_override"); 141 if (item && item.id == extension.id) { 142 Services.prefs.setBoolPref(HOMEPAGE_PRIVATE_ALLOWED, true); 143 } 144 } 145 }); 146 // eslint-disable-next-line mozilla/balanced-listeners 147 extension.on("remove-permissions", async (ignoreEvent, permissions) => { 148 if (permissions.permissions.includes("internal:privateBrowsingAllowed")) { 149 let item = 150 await ExtensionPreferencesManager.getSetting("homepage_override"); 151 if (item && item.id == extension.id) { 152 Services.prefs.setBoolPref(HOMEPAGE_PRIVATE_ALLOWED, false); 153 } 154 } 155 }); 156 } 157 158 // When an extension starts up, a search engine may asynchronously be 159 // registered, without blocking the startup. When an extension is 160 // uninstalled, we need to wait for this registration to finish 161 // before running the uninstallation handler. 162 // Map[extension id -> Promise] 163 var pendingSearchSetupTasks = new Map(); 164 165 this.chrome_settings_overrides = class extends ExtensionAPI { 166 static async processDefaultSearchSetting(action, id) { 167 await ExtensionSettingsStore.initialize(); 168 let item = ExtensionSettingsStore.getSetting( 169 DEFAULT_SEARCH_STORE_TYPE, 170 DEFAULT_SEARCH_SETTING_NAME, 171 id 172 ); 173 if (!item) { 174 return; 175 } 176 let control = await ExtensionSettingsStore.getLevelOfControl( 177 id, 178 DEFAULT_SEARCH_STORE_TYPE, 179 DEFAULT_SEARCH_SETTING_NAME 180 ); 181 item = ExtensionSettingsStore[action]( 182 id, 183 DEFAULT_SEARCH_STORE_TYPE, 184 DEFAULT_SEARCH_SETTING_NAME 185 ); 186 if (item && control == "controlled_by_this_extension") { 187 try { 188 let engine = Services.search.getEngineByName( 189 item.value || item.initialValue 190 ); 191 if (engine) { 192 await Services.search.setDefault( 193 engine, 194 action == "enable" 195 ? Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL 196 : Ci.nsISearchService.CHANGE_REASON_ADDON_UNINSTALL 197 ); 198 } 199 } catch (e) { 200 Cu.reportError(e); 201 } 202 } 203 } 204 205 static async removeEngine(id) { 206 try { 207 await Services.search.removeWebExtensionEngine(id); 208 } catch (e) { 209 Cu.reportError(e); 210 } 211 } 212 213 static removeSearchSettings(id) { 214 return Promise.all([ 215 this.processDefaultSearchSetting("removeSetting", id), 216 this.removeEngine(id), 217 ]); 218 } 219 220 static async onUninstall(id) { 221 let searchStartupPromise = pendingSearchSetupTasks.get(id); 222 if (searchStartupPromise) { 223 await searchStartupPromise.catch(Cu.reportError); 224 } 225 // Note: We do not have to manage the homepage setting here 226 // as it is managed by the ExtensionPreferencesManager. 227 return Promise.all([ 228 this.removeSearchSettings(id), 229 homepagePopup.clearConfirmation(id), 230 ]); 231 } 232 233 static async onUpdate(id, manifest) { 234 if (!manifest?.chrome_settings_overrides?.homepage) { 235 // New or changed values are handled during onManifest. 236 ExtensionPreferencesManager.removeSetting(id, "homepage_override"); 237 } 238 239 let search_provider = manifest?.chrome_settings_overrides?.search_provider; 240 241 if (!search_provider) { 242 // Remove setting and engine from search if necessary. 243 this.removeSearchSettings(id); 244 } else if (!search_provider.is_default) { 245 // Remove the setting, but keep the engine in search. 246 chrome_settings_overrides.processDefaultSearchSetting( 247 "removeSetting", 248 id 249 ); 250 } 251 } 252 253 static async onDisable(id) { 254 homepagePopup.clearConfirmation(id); 255 256 await chrome_settings_overrides.processDefaultSearchSetting("disable", id); 257 await chrome_settings_overrides.removeEngine(id); 258 } 259 260 async onManifestEntry() { 261 let { extension } = this; 262 let { manifest } = extension; 263 let homepageUrl = manifest.chrome_settings_overrides.homepage; 264 265 // If this is a page we ignore, just skip the homepage setting completely. 266 if (homepageUrl) { 267 const ignoreHomePageUrl = await HomePage.shouldIgnore(homepageUrl); 268 269 if (ignoreHomePageUrl) { 270 Glean.homepage.preferenceIgnore.record({ 271 value: "set_blocked_extension", 272 webExtensionId: extension.id, 273 }); 274 } else { 275 await handleHomepageUrl(extension, homepageUrl); 276 } 277 } 278 if (manifest.chrome_settings_overrides.search_provider) { 279 // Registering a search engine can potentially take a long while, 280 // or not complete at all (when Services.search.promiseInitialized is 281 // never resolved), so we are deliberately not awaiting the returned 282 // promise here. 283 let searchStartupPromise = 284 this.processSearchProviderManifestEntry().finally(() => { 285 if ( 286 pendingSearchSetupTasks.get(extension.id) === searchStartupPromise 287 ) { 288 pendingSearchSetupTasks.delete(extension.id); 289 // This is primarily for tests so that we know when an extension 290 // has finished initialising. 291 ExtensionParent.apiManager.emit("searchEngineProcessed", extension); 292 } 293 }); 294 295 // Save the promise so we can await at onUninstall. 296 pendingSearchSetupTasks.set(extension.id, searchStartupPromise); 297 } 298 } 299 300 async ensureSetting(engineName, disable = false) { 301 let { extension } = this; 302 // Ensure the addon always has a setting 303 await ExtensionSettingsStore.initialize(); 304 let item = ExtensionSettingsStore.getSetting( 305 DEFAULT_SEARCH_STORE_TYPE, 306 DEFAULT_SEARCH_SETTING_NAME, 307 extension.id 308 ); 309 if (!item) { 310 let defaultEngine = await Services.search.getDefault(); 311 item = await ExtensionSettingsStore.addSetting( 312 extension.id, 313 DEFAULT_SEARCH_STORE_TYPE, 314 DEFAULT_SEARCH_SETTING_NAME, 315 engineName, 316 () => defaultEngine.name 317 ); 318 // If there was no setting, we're fixing old behavior in this api. 319 // A lack of a setting would mean it was disabled before, disable it now. 320 disable = 321 disable || 322 ["ADDON_UPGRADE", "ADDON_DOWNGRADE", "ADDON_ENABLE"].includes( 323 extension.startupReason 324 ); 325 } 326 327 // Ensure the item is disabled (either if exists and is not default or if it does not 328 // exist yet). 329 if (disable) { 330 item = await ExtensionSettingsStore.disable( 331 extension.id, 332 DEFAULT_SEARCH_STORE_TYPE, 333 DEFAULT_SEARCH_SETTING_NAME 334 ); 335 } 336 return item; 337 } 338 339 async promptDefaultSearch(engineName) { 340 let { extension } = this; 341 // Don't ask if it is already the current engine 342 let engine = Services.search.getEngineByName(engineName); 343 let defaultEngine = await Services.search.getDefault(); 344 if (defaultEngine.name == engine.name) { 345 return; 346 } 347 // Ensures the setting exists and is disabled. If the 348 // user somehow bypasses the prompt, we do not want this 349 // setting enabled for this extension. 350 await this.ensureSetting(engineName, true); 351 352 let subject = { 353 wrappedJSObject: { 354 // This is a hack because we don't have the browser of 355 // the actual install. This means the popup might show 356 // in a different window. Will be addressed in a followup bug. 357 // As well, we still notify if no topWindow exists to support 358 // testing from xpcshell. 359 browser: windowTracker.topWindow?.gBrowser.selectedBrowser, 360 id: extension.id, 361 name: extension.name, 362 icon: extension.getPreferredIcon(32), 363 currentEngine: defaultEngine.name, 364 newEngine: engineName, 365 async respond(allow) { 366 if (allow) { 367 await chrome_settings_overrides.processDefaultSearchSetting( 368 "enable", 369 extension.id 370 ); 371 await Services.search.setDefault( 372 Services.search.getEngineByName(engineName), 373 Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL 374 ); 375 } 376 // For testing 377 Services.obs.notifyObservers( 378 null, 379 "webextension-defaultsearch-prompt-response" 380 ); 381 }, 382 }, 383 }; 384 Services.obs.notifyObservers(subject, "webextension-defaultsearch-prompt"); 385 } 386 387 async processSearchProviderManifestEntry() { 388 let { extension } = this; 389 let { manifest } = extension; 390 let searchProvider = manifest.chrome_settings_overrides.search_provider; 391 392 // If we're not being requested to be set as default, then all we need 393 // to do is to add the engine to the service. The search service can cope 394 // with receiving added engines before it is initialised, so we don't have 395 // to wait for it. Search Service will also prevent overriding a builtin 396 // engine appropriately. 397 if (!searchProvider.is_default) { 398 await this.addSearchEngine(); 399 return; 400 } 401 402 await Services.search.promiseInitialized; 403 if (!this.extension) { 404 Cu.reportError( 405 `Extension shut down before search provider was registered` 406 ); 407 return; 408 } 409 410 let engineName = searchProvider.name.trim(); 411 let result = await Services.search.maybeSetAndOverrideDefault(extension); 412 // This will only be set to true when the specified engine is a config 413 // engine, or when it is an allowed add-on defined in the list stored in 414 // SearchDefaultOverrideAllowlistHandler. 415 if (result.canChangeToConfigEngine) { 416 await this.setDefault(engineName, true); 417 } 418 if (!result.canInstallEngine) { 419 // This extension is overriding a config search engine, so we don't 420 // add its engine as well. 421 return; 422 } 423 await this.addSearchEngine(); 424 if (extension.startupReason === "ADDON_INSTALL") { 425 await this.promptDefaultSearch(engineName); 426 } else { 427 // Needs to be called every time to handle reenabling. 428 await this.setDefault(engineName); 429 } 430 } 431 432 async setDefault(engineName, skipEnablePrompt = false) { 433 let { extension } = this; 434 435 if (extension.startupReason === "ADDON_INSTALL") { 436 // We should only get here if an extension is setting a config search 437 // engine to default and we are ignoring the addons other engine settings. 438 // In this case we do not show the prompt to the user. 439 let item = await this.ensureSetting(engineName); 440 await Services.search.setDefault( 441 Services.search.getEngineByName(item.value), 442 Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL 443 ); 444 } else if ( 445 ["ADDON_UPGRADE", "ADDON_DOWNGRADE", "ADDON_ENABLE"].includes( 446 extension.startupReason 447 ) 448 ) { 449 // We would be called for every extension being enabled, we should verify 450 // that it has control and only then set it as default 451 let control = await ExtensionSettingsStore.getLevelOfControl( 452 extension.id, 453 DEFAULT_SEARCH_STORE_TYPE, 454 DEFAULT_SEARCH_SETTING_NAME 455 ); 456 457 // Check for an inconsistency between the value returned by getLevelOfcontrol 458 // and the current engine actually set. 459 if ( 460 control === "controlled_by_this_extension" && 461 Services.search.defaultEngine.name !== engineName 462 ) { 463 // Check for and fix any inconsistency between the extensions settings storage 464 // and the current engine actually set. If settings claims the extension is default 465 // but the search service claims otherwise, select what the search service claims 466 // (See Bug 1767550). 467 const allSettings = ExtensionSettingsStore.getAllSettings( 468 DEFAULT_SEARCH_STORE_TYPE, 469 DEFAULT_SEARCH_SETTING_NAME 470 ); 471 for (const setting of allSettings) { 472 if (setting.value !== Services.search.defaultEngine.name) { 473 await ExtensionSettingsStore.disable( 474 setting.id, 475 DEFAULT_SEARCH_STORE_TYPE, 476 DEFAULT_SEARCH_SETTING_NAME 477 ); 478 } 479 } 480 control = await ExtensionSettingsStore.getLevelOfControl( 481 extension.id, 482 DEFAULT_SEARCH_STORE_TYPE, 483 DEFAULT_SEARCH_SETTING_NAME 484 ); 485 } 486 487 if (control === "controlled_by_this_extension") { 488 await Services.search.setDefault( 489 Services.search.getEngineByName(engineName), 490 Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL 491 ); 492 } else if (control === "controllable_by_this_extension") { 493 if (skipEnablePrompt) { 494 // For overriding config engines, we don't prompt, so set 495 // the default straight away. 496 await chrome_settings_overrides.processDefaultSearchSetting( 497 "enable", 498 extension.id 499 ); 500 await Services.search.setDefault( 501 Services.search.getEngineByName(engineName), 502 Ci.nsISearchService.CHANGE_REASON_ADDON_INSTALL 503 ); 504 } else if (extension.startupReason == "ADDON_ENABLE") { 505 // This extension has precedence, but is not in control. Ask the user. 506 await this.promptDefaultSearch(engineName); 507 } 508 } 509 } 510 } 511 512 async addSearchEngine() { 513 let { extension } = this; 514 try { 515 await Services.search.addEnginesFromExtension(extension); 516 } catch (e) { 517 Cu.reportError(e); 518 return false; 519 } 520 return true; 521 } 522 }; 523 524 ExtensionPreferencesManager.addSetting("homepage_override", { 525 prefNames: [ 526 HOMEPAGE_PREF, 527 HOMEPAGE_EXTENSION_CONTROLLED, 528 HOMEPAGE_PRIVATE_ALLOWED, 529 ], 530 // ExtensionPreferencesManager will call onPrefsChanged when control changes 531 // and it updates the preferences. We are passed the item from 532 // ExtensionSettingsStore that details what is in control. If there is an id 533 // then control has changed to an extension, if there is no id then control 534 // has been returned to the user. 535 async onPrefsChanged(item) { 536 if (item.id) { 537 homepagePopup.addObserver(item.id); 538 539 let policy = ExtensionParent.WebExtensionPolicy.getByID(item.id); 540 let allowed = policy && policy.privateBrowsingAllowed; 541 if (!policy) { 542 // We'll generally hit this path during safe mode changes. 543 let perms = await ExtensionPermissions.get(item.id); 544 allowed = perms.permissions.includes("internal:privateBrowsingAllowed"); 545 } 546 Services.prefs.setBoolPref(HOMEPAGE_PRIVATE_ALLOWED, allowed); 547 Services.prefs.setBoolPref(HOMEPAGE_EXTENSION_CONTROLLED, true); 548 } else { 549 homepagePopup.removeObserver(); 550 551 Services.prefs.clearUserPref(HOMEPAGE_PRIVATE_ALLOWED); 552 Services.prefs.clearUserPref(HOMEPAGE_EXTENSION_CONTROLLED); 553 } 554 }, 555 setCallback(value) { 556 // Setting the pref will result in onPrefsChanged being called, which 557 // will then set HOMEPAGE_PRIVATE_ALLOWED. We want to ensure that this 558 // pref will be set/unset as apropriate. 559 return { 560 [HOMEPAGE_PREF]: value, 561 [HOMEPAGE_EXTENSION_CONTROLLED]: !!value, 562 [HOMEPAGE_PRIVATE_ALLOWED]: false, 563 }; 564 }, 565 });