ExtensionControlledPopup.sys.mjs (16282B)
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 /** 6 * @file 7 * This module exports a class that can be used to handle displaying a popup 8 * doorhanger with a primary action to not show a popup for this extension again 9 * and a secondary action disables the addon, or brings the user to their settings. 10 * 11 * The original purpose of the popup was to notify users of an extension that has 12 * changed the New Tab or homepage. Users would see this popup the first time they 13 * view those pages after a change to the setting in each session until they confirm 14 * the change by triggering the primary action. 15 */ 16 17 import { ExtensionCommon } from "resource://gre/modules/ExtensionCommon.sys.mjs"; 18 19 const lazy = {}; 20 21 ChromeUtils.defineESModuleGetters(lazy, { 22 AddonManager: "resource://gre/modules/AddonManager.sys.mjs", 23 BrowserUIUtils: "resource:///modules/BrowserUIUtils.sys.mjs", 24 CustomizableUI: 25 "moz-src:///browser/components/customizableui/CustomizableUI.sys.mjs", 26 ExtensionSettingsStore: 27 "resource://gre/modules/ExtensionSettingsStore.sys.mjs", 28 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 29 }); 30 31 let { makeWidgetId } = ExtensionCommon; 32 33 ChromeUtils.defineLazyGetter(lazy, "strBundle", function () { 34 return Services.strings.createBundle( 35 "chrome://global/locale/extensions.properties" 36 ); 37 }); 38 39 const PREF_BRANCH_INSTALLED_ADDON = "extensions.installedDistroAddon."; 40 41 ChromeUtils.defineLazyGetter(lazy, "distributionAddonsList", function () { 42 let addonList = Services.prefs 43 .getChildList(PREF_BRANCH_INSTALLED_ADDON) 44 .map(id => id.replace(PREF_BRANCH_INSTALLED_ADDON, "")); 45 return new Set(addonList); 46 }); 47 48 export class ExtensionControlledPopup { 49 /** 50 * Provide necessary options for the popup. 51 * 52 * @param {object} opts Options for configuring popup. 53 * @param {string} opts.confirmedType 54 * The type to use for storing a user's confirmation in 55 * ExtensionSettingsStore. 56 * @param {string} opts.observerTopic 57 * An observer topic to trigger the popup on with Services.obs. If the 58 * doorhanger should appear on a specific window include it as the 59 * subject in the observer event. 60 * @param {string} opts.popupnotificationId 61 * The id for the popupnotification element in the markup. This 62 * element should be defined in panelUI.inc.xhtml. 63 * @param {string} opts.settingType 64 * The setting type to check in ExtensionSettingsStore to retrieve 65 * the controlling extension. 66 * @param {string} opts.settingKey 67 * The setting key to check in ExtensionSettingsStore to retrieve 68 * the controlling extension. 69 * @param {string} opts.descriptionId 70 * The id of the element where the description should be displayed. 71 * @param {string} opts.descriptionMessageId 72 * The message id to be used for the description. The translated 73 * string will have the add-on's name and icon injected into it. 74 * @param {string} opts.getLocalizedDescription 75 * A function to get the localized message string. This 76 * function is passed doc, message and addonDetails (the 77 * add-on's icon and name). If not provided, then the add-on's 78 * icon and name are added to the description. 79 * @param {string} opts.learnMoreLink 80 * The name of the SUMO page to link to, this is added to 81 * app.support.baseURL. 82 * @param {string} [opts.preferencesLocation] 83 * If included, the name of the preferences tab that will be opened 84 * by the secondary action. If not included, the secondary option will 85 * disable the addon. 86 * @param {string} [opts.preferencesEntrypoint] 87 * The entrypoint to pass to preferences telemetry. 88 * @param {Function} opts.onObserverAdded 89 * A callback that is triggered when an observer is registered to 90 * trigger the popup on the next observerTopic. 91 * @param {Function} opts.onObserverRemoved 92 * A callback that is triggered when the observer is removed, 93 * either because the popup is opening or it was explicitly 94 * cancelled by calling removeObserver. 95 * @param {Function} opts.beforeDisableAddon 96 * A function that is called before disabling an extension when the 97 * user decides to disable the extension. If this function is async 98 * then the extension won't be disabled until it is fulfilled. 99 * This function gets two arguments, the ExtensionControlledPopup 100 * instance for the panel and the window that the popup appears on. 101 */ 102 constructor(opts) { 103 this.confirmedType = opts.confirmedType; 104 this.observerTopic = opts.observerTopic; 105 this.popupnotificationId = opts.popupnotificationId; 106 this.settingType = opts.settingType; 107 this.settingKey = opts.settingKey; 108 this.descriptionId = opts.descriptionId; 109 this.descriptionMessageId = opts.descriptionMessageId; 110 this.getLocalizedDescription = opts.getLocalizedDescription; 111 this.learnMoreLink = opts.learnMoreLink; 112 this.preferencesLocation = opts.preferencesLocation; 113 this.preferencesEntrypoint = opts.preferencesEntrypoint; 114 this.onObserverAdded = opts.onObserverAdded; 115 this.onObserverRemoved = opts.onObserverRemoved; 116 this.beforeDisableAddon = opts.beforeDisableAddon; 117 this.observerRegistered = false; 118 } 119 120 get topWindow() { 121 return Services.wm.getMostRecentWindow("navigator:browser"); 122 } 123 124 userHasConfirmed(id) { 125 // We don't show a doorhanger for distribution installed add-ons. 126 if (lazy.distributionAddonsList.has(id)) { 127 return true; 128 } 129 let setting = lazy.ExtensionSettingsStore.getSetting( 130 this.confirmedType, 131 id 132 ); 133 return !!(setting && setting.value); 134 } 135 136 async setConfirmation(id) { 137 await lazy.ExtensionSettingsStore.initialize(); 138 return lazy.ExtensionSettingsStore.addSetting( 139 id, 140 this.confirmedType, 141 id, 142 true, 143 () => false 144 ); 145 } 146 147 async clearConfirmation(id) { 148 await lazy.ExtensionSettingsStore.initialize(); 149 return lazy.ExtensionSettingsStore.removeSetting( 150 id, 151 this.confirmedType, 152 id 153 ); 154 } 155 156 observe(subject) { 157 // Remove the observer here so we don't get multiple open() calls if we get 158 // multiple observer events in quick succession. 159 this.removeObserver(); 160 161 let targetWindow; 162 // Some notifications (e.g. browser-open-newtab-start) do not have a window subject. 163 if (subject && subject.document) { 164 targetWindow = subject; 165 } 166 167 // Do this work in an idle callback to avoid interfering with new tab performance tracking. 168 this.topWindow.requestIdleCallback(() => this.open(targetWindow)); 169 } 170 171 removeObserver() { 172 if (this.observerRegistered) { 173 Services.obs.removeObserver(this, this.observerTopic); 174 this.observerRegistered = false; 175 if (this.onObserverRemoved) { 176 this.onObserverRemoved(); 177 } 178 } 179 } 180 181 async addObserver(extensionId) { 182 await lazy.ExtensionSettingsStore.initialize(); 183 184 if (!this.observerRegistered && !this.userHasConfirmed(extensionId)) { 185 Services.obs.addObserver(this, this.observerTopic); 186 this.observerRegistered = true; 187 if (this.onObserverAdded) { 188 this.onObserverAdded(); 189 } 190 } 191 } 192 193 // The extensionId will be looked up in ExtensionSettingsStore if it is not 194 // provided using this.settingType and this.settingKey. 195 async open(targetWindow, extensionId) { 196 await lazy.ExtensionSettingsStore.initialize(); 197 198 // Remove the observer since it would open the same dialog again the next time 199 // the observer event fires. 200 this.removeObserver(); 201 202 if (!extensionId) { 203 let item = lazy.ExtensionSettingsStore.getSetting( 204 this.settingType, 205 this.settingKey 206 ); 207 extensionId = item && item.id; 208 } 209 210 let win = targetWindow || this.topWindow; 211 let isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate(win); 212 if ( 213 isPrivate && 214 extensionId && 215 !WebExtensionPolicy.getByID(extensionId).privateBrowsingAllowed 216 ) { 217 return; 218 } 219 220 // The item should have an extension and the user shouldn't have confirmed 221 // the change here, but just to be sure check that it is still controlled 222 // and the user hasn't already confirmed the change. 223 // If there is no id, then the extension is no longer in control. 224 if (!extensionId || this.userHasConfirmed(extensionId)) { 225 return; 226 } 227 228 // If the window closes while waiting for focus, this might reject/throw, 229 // and we should stop trying to show the popup. 230 try { 231 await this._ensureWindowReady(win); 232 } catch (ex) { 233 return; 234 } 235 236 // Find the elements we need. 237 let doc = win.document; 238 let panel = ExtensionControlledPopup._getAndMaybeCreatePanel(doc); 239 let popupnotification = doc.getElementById(this.popupnotificationId); 240 let urlBarWasFocused = win.gURLBar.focused; 241 242 if (!popupnotification) { 243 throw new Error( 244 `No popupnotification found for id "${this.popupnotificationId}"` 245 ); 246 } 247 248 let elementsToTranslate = panel.querySelectorAll("[data-lazy-l10n-id]"); 249 if (elementsToTranslate.length) { 250 win.MozXULElement.insertFTLIfNeeded("browser/appMenuNotifications.ftl"); 251 for (let el of elementsToTranslate) { 252 el.setAttribute("data-l10n-id", el.getAttribute("data-lazy-l10n-id")); 253 el.removeAttribute("data-lazy-l10n-id"); 254 } 255 await win.document.l10n.translateFragment(panel); 256 } 257 let addon = await lazy.AddonManager.getAddonByID(extensionId); 258 this.populateDescription(doc, addon); 259 260 // Setup the buttoncommand handler. 261 let handleButtonCommand = async event => { 262 event.preventDefault(); 263 panel.hidePopup(); 264 265 // Main action is to keep changes. 266 await this.setConfirmation(extensionId); 267 268 // If the page this is appearing on is the New Tab page then the URL bar may 269 // have been focused when the doorhanger stole focus away from it. Once an 270 // action is taken the focus state should be restored to what the user was 271 // expecting. 272 if (urlBarWasFocused) { 273 win.gURLBar.focus(); 274 } 275 }; 276 let handleSecondaryButtonCommand = async event => { 277 event.preventDefault(); 278 panel.hidePopup(); 279 280 if (this.preferencesLocation) { 281 // Secondary action opens Preferences, if a preferencesLocation option is included. 282 let options = this.Entrypoint 283 ? { urlParams: { entrypoint: this.Entrypoint } } 284 : {}; 285 win.openPreferences(this.preferencesLocation, options); 286 } else { 287 // Secondary action is to restore settings. 288 if (this.beforeDisableAddon) { 289 await this.beforeDisableAddon(this, win); 290 } 291 await addon.disable(); 292 } 293 294 if (urlBarWasFocused) { 295 win.gURLBar.focus(); 296 } 297 }; 298 panel.addEventListener("buttoncommand", handleButtonCommand); 299 panel.addEventListener( 300 "secondarybuttoncommand", 301 handleSecondaryButtonCommand 302 ); 303 panel.addEventListener( 304 "popuphidden", 305 () => { 306 popupnotification.hidden = true; 307 panel.removeEventListener("buttoncommand", handleButtonCommand); 308 panel.removeEventListener( 309 "secondarybuttoncommand", 310 handleSecondaryButtonCommand 311 ); 312 }, 313 { once: true } 314 ); 315 316 // Look for a browserAction on the toolbar. 317 let action = lazy.CustomizableUI.getWidget( 318 `${makeWidgetId(extensionId)}-browser-action` 319 ); 320 if (action) { 321 action = 322 action.areaType == "toolbar" && 323 action 324 .forWindow(win) 325 .node.querySelector(".unified-extensions-item-action-button"); 326 } 327 328 // Anchor to a toolbar browserAction if found, otherwise use the extensions 329 // button. 330 const anchor = action || doc.getElementById("unified-extensions-button"); 331 332 if (this.learnMoreLink) { 333 const learnMoreURL = 334 Services.urlFormatter.formatURLPref("app.support.baseURL") + 335 this.learnMoreLink; 336 popupnotification.setAttribute("learnmoreurl", learnMoreURL); 337 } else { 338 // In practice this isn't really needed because each of the 339 // controlled popups use its own popupnotification instance 340 // and they always have an learnMoreURL. 341 popupnotification.removeAttribute("learnmoreurl"); 342 } 343 popupnotification.show(); 344 if (anchor?.id == "unified-extensions-button") { 345 const { gUnifiedExtensions } = anchor.ownerGlobal; 346 gUnifiedExtensions.recordButtonTelemetry("extension_controlled_setting"); 347 gUnifiedExtensions.ensureButtonShownBeforeAttachingPanel(panel); 348 } 349 panel.openPopup(anchor); 350 } 351 352 getAddonDetails(doc, addon) { 353 const defaultIcon = "chrome://mozapps/skin/extensions/extensionGeneric.svg"; 354 355 let image = doc.createXULElement("image"); 356 image.setAttribute("src", addon.iconURL || defaultIcon); 357 image.classList.add("extension-controlled-icon"); 358 359 let addonDetails = doc.createDocumentFragment(); 360 addonDetails.appendChild(image); 361 addonDetails.appendChild(doc.createTextNode(" " + addon.name)); 362 363 return addonDetails; 364 } 365 366 populateDescription(doc, addon) { 367 let description = doc.getElementById(this.descriptionId); 368 description.textContent = ""; 369 370 let addonDetails = this.getAddonDetails(doc, addon); 371 let message = lazy.strBundle.GetStringFromName(this.descriptionMessageId); 372 if (this.getLocalizedDescription) { 373 description.appendChild( 374 this.getLocalizedDescription(doc, message, addonDetails) 375 ); 376 } else { 377 description.appendChild( 378 lazy.BrowserUIUtils.getLocalizedFragment(doc, message, addonDetails) 379 ); 380 } 381 } 382 383 async _ensureWindowReady(win) { 384 if (win.closed) { 385 throw new Error("window is closed"); 386 } 387 let promises = []; 388 let listenersToRemove = []; 389 function promiseEvent(type) { 390 promises.push( 391 new Promise(resolve => { 392 let listener = () => { 393 win.removeEventListener(type, listener); 394 resolve(); 395 }; 396 win.addEventListener(type, listener); 397 listenersToRemove.push([type, listener]); 398 }) 399 ); 400 } 401 let { focusedWindow, activeWindow } = Services.focus; 402 if (activeWindow != win) { 403 promiseEvent("activate"); 404 } 405 if (focusedWindow) { 406 // We may have focused a non-remote child window, find the browser window: 407 let { rootTreeItem } = focusedWindow.docShell; 408 rootTreeItem.QueryInterface(Ci.nsIDocShell); 409 focusedWindow = rootTreeItem.docViewer.DOMDocument.defaultView; 410 } 411 if (focusedWindow != win) { 412 promiseEvent("focus"); 413 } 414 if (promises.length) { 415 let unloadListener; 416 let unloadPromise = new Promise((resolve, reject) => { 417 unloadListener = () => { 418 for (let [type, listener] of listenersToRemove) { 419 win.removeEventListener(type, listener); 420 } 421 reject(new Error("window unloaded")); 422 }; 423 win.addEventListener("unload", unloadListener, { once: true }); 424 }); 425 try { 426 let allPromises = Promise.all(promises); 427 await Promise.race([allPromises, unloadPromise]); 428 } finally { 429 win.removeEventListener("unload", unloadListener); 430 } 431 } 432 } 433 434 static _getAndMaybeCreatePanel(doc) { 435 // // Lazy load the extension-notification panel the first time we need to display it. 436 let template = doc.getElementById("extensionNotificationTemplate"); 437 if (template) { 438 template.replaceWith(template.content); 439 } 440 441 return doc.getElementById("extension-notification-panel"); 442 } 443 }