Bug 1397809 - Part 1: Convert New Tab doorhanger to a generic class r?aswan r?gijs draft
authorMark Striemer <mstriemer@mozilla.com>
Tue, 19 Dec 2017 13:56:04 -0600
changeset 783755 30498b346b64ca492a3ef61fe9fb653fc5836aac
parent 781780 36c6771b7413273a59c5c5c0d49064e9ab8fa54f
child 783756 ba991c2b0b6d234c99d292b5845e88d92ae288c7
push id106768
push userbmo:mstriemer@mozilla.com
push dateTue, 17 Apr 2018 16:39:48 +0000
reviewersaswan, gijs
bugs1397809
milestone61.0a1
Bug 1397809 - Part 1: Convert New Tab doorhanger to a generic class r?aswan r?gijs MozReview-Commit-ID: 40RwrXjtsJJ
browser/components/customizableui/content/panelUI.inc.xul
browser/components/extensions/ExtensionControlledPopup.jsm
browser/components/extensions/ExtensionPopups.jsm
browser/components/extensions/ext-browser.json
browser/components/extensions/moz.build
browser/components/extensions/parent/ext-url-overrides.js
browser/components/extensions/test/browser/browser-common.ini
browser/components/extensions/test/browser/browser_ExtensionControlledPopup.js
browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js
browser/themes/shared/customizableui/panelUI.inc.css
toolkit/components/extensions/ExtensionUtils.jsm
--- a/browser/components/customizableui/content/panelUI.inc.xul
+++ b/browser/components/customizableui/content/panelUI.inc.xul
@@ -697,17 +697,19 @@
 <panel id="extension-notification-panel"
        role="group"
        type="arrow"
        hidden="true"
        flip="slide"
        position="bottomcenter topright"
        tabspecific="true">
   <popupnotification id="extension-new-tab-notification"
+                     class="extension-controlled-notification"
                      popupid="extension-new-tab"
+                     hidden="true"
                      label="&newTabControlled.header.message;"
                      buttonlabel="&newTabControlled.keepButton.label;"
                      buttonaccesskey="&newTabControlled.keepButton.accesskey;"
                      secondarybuttonlabel="&newTabControlled.disableButton.label;"
                      secondarybuttonaccesskey="&newTabControlled.disableButton.accesskey;"
                      closebuttonhidden="true"
                      dropmarkerhidden="true"
                      checkboxhidden="true">
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/ExtensionControlledPopup.jsm
@@ -0,0 +1,245 @@
+/* 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/. */
+/* exported ExtensionControlledPopup */
+
+"use strict";
+
+/*
+ * @fileOverview
+ * This module exports a class that can be used to handle displaying a popup
+ * doorhanger with a primary action to not show a popup for this extension again
+ * and a secondary action to disable the extension.
+ *
+ * The original purpose of the popup was to notify users of an extension that has
+ * changed the New Tab or homepage. Users would see this popup the first time they
+ * view those pages after a change to the setting in each session until they confirm
+ * the change by triggering the primary action.
+ */
+
+var EXPORTED_SYMBOLS = ["ExtensionControlledPopup"];
+
+ChromeUtils.import("resource://gre/modules/Services.jsm");
+ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+ChromeUtils.defineModuleGetter(this, "AddonManager",
+                               "resource://gre/modules/AddonManager.jsm");
+ChromeUtils.defineModuleGetter(this, "BrowserUtils",
+                               "resource://gre/modules/BrowserUtils.jsm");
+ChromeUtils.defineModuleGetter(this, "CustomizableUI",
+                               "resource:///modules/CustomizableUI.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionSettingsStore",
+                               "resource://gre/modules/ExtensionSettingsStore.jsm");
+
+let {makeWidgetId} = ExtensionUtils;
+
+XPCOMUtils.defineLazyGetter(this, "strBundle", function() {
+  return Services.strings.createBundle("chrome://global/locale/extensions.properties");
+});
+
+class ExtensionControlledPopup {
+  /* Provide necessary options for the popup.
+   *
+   * @param {object} opts Options for configuring popup.
+   * @param {string} opts.confirmedType
+   *                 The type to use for storing a user's confirmation in
+   *                 ExtensionSettingsStore.
+   * @param {string} opts.observerTopic
+   *                 An observer topic to trigger the popup on with Services.obs.
+   * @param {string} opts.popupnotificationId
+   *                 The id for the popupnotification element in the markup. This
+   *                 element should be defined in panelUI.inc.xul.
+   * @param {string} opts.settingType
+   *                 The setting type to check in ExtensionSettingsStore to retrieve
+   *                 the controlling extension.
+   * @param {string} opts.settingKey
+   *                 The setting key to check in ExtensionSettingsStore to retrieve
+   *                 the controlling extension.
+   * @param {string} opts.descriptionId
+   *                 The id of the element where the description should be displayed.
+   * @param {string} opts.descriptionMessageId
+   *                 The message id to be used for the description. The translated
+   *                 string will have the add-on's name and icon injected into it.
+   * @param {string} opts.learnMoreMessageId
+   *                 The message id to be used for the text of a "learn more" link which
+   *                 will be placed after the description.
+   * @param {string} opts.learnMoreLink
+   *                 The name of the SUMO page to link to, this is added to
+   *                 app.support.baseURL.
+   * @param {function} opts.onObserverAdded
+   *                   A callback that is triggered when an observer is registered to
+   *                   trigger the popup on the next observerTopic.
+   * @param {function} opts.onObserverRemoved
+   *                   A callback that is triggered when the observer is removed,
+   *                   either because the popup is opening or it was explicitly
+   *                   cancelled by calling removeObserver.
+   * @param {function} opts.beforeDisableAddon
+   *                   A function that is called before disabling an extension when the
+   *                   user decides to disable the extension. If this function is async
+   *                   then the extension won't be disabled until it is fulfilled.
+   */
+  constructor(opts) {
+    this.confirmedType = opts.confirmedType;
+    this.observerTopic = opts.observerTopic;
+    this.popupnotificationId = opts.popupnotificationId;
+    this.settingType = opts.settingType;
+    this.settingKey = opts.settingKey;
+    this.descriptionId = opts.descriptionId;
+    this.descriptionMessageId = opts.descriptionMessageId;
+    this.learnMoreMessageId = opts.learnMoreMessageId;
+    this.learnMoreLink = opts.learnMoreLink;
+    this.onObserverAdded = opts.onObserverAdded;
+    this.onObserverRemoved = opts.onObserverRemoved;
+    this.beforeDisableAddon = opts.beforeDisableAddon;
+    this.observerRegistered = false;
+  }
+
+  get topWindow() {
+    return Services.wm.getMostRecentWindow("navigator:browser");
+  }
+
+  userHasConfirmed(id) {
+    let setting = ExtensionSettingsStore.getSetting(this.confirmedType, id);
+    return !!(setting && setting.value);
+  }
+
+  async setConfirmation(id) {
+    await ExtensionSettingsStore.initialize();
+    return ExtensionSettingsStore.addSetting(
+      id, this.confirmedType, id, true, () => false);
+  }
+
+  async clearConfirmation(id) {
+    await ExtensionSettingsStore.initialize();
+    return ExtensionSettingsStore.removeSetting(id, this.confirmedType, id);
+  }
+
+  observe(subject, topic, data) {
+    // Remove the observer here so we don't get multiple open() calls if we get
+    // multiple observer events in quick succession.
+    this.removeObserver();
+
+    // Do this work in an idle callback to avoid interfering with new tab performance tracking.
+    this.topWindow.requestIdleCallback(() => this.open());
+  }
+
+  removeObserver() {
+    if (this.observerRegistered) {
+      Services.obs.removeObserver(this, this.observerTopic);
+      this.observerRegistered = false;
+      if (this.onObserverRemoved) {
+        this.onObserverRemoved();
+      }
+    }
+  }
+
+  async addObserver(extensionId) {
+    await ExtensionSettingsStore.initialize();
+
+    if (!this.observerRegistered && !this.userHasConfirmed(extensionId)) {
+      Services.obs.addObserver(this, this.observerTopic);
+      this.observerRegistered = true;
+      if (this.onObserverAdded) {
+        this.onObserverAdded();
+      }
+    }
+  }
+
+  async open() {
+    await ExtensionSettingsStore.initialize();
+
+    // Remove the observer since it would open the same dialog again the next time
+    // the observer event fires.
+    this.removeObserver();
+
+    let item = ExtensionSettingsStore.getSetting(
+      this.settingType, this.settingKey);
+
+    // The item should have an extension and the user shouldn't have confirmed
+    // the change here, but just to be sure check that it is still controlled
+    // and the user hasn't already confirmed the change.
+    // If there is no id, then the extension is no longer in control.
+    if (!item || !item.id || this.userHasConfirmed(item.id)) {
+      return;
+    }
+
+    // Find the elements we need.
+    let win = this.topWindow;
+    let doc = win.document;
+    let panel = doc.getElementById("extension-notification-panel");
+    let popupnotification = doc.getElementById(this.popupnotificationId);
+
+    if (!popupnotification) {
+      throw new Error(`No popupnotification found for id "${this.popupnotificationId}"`);
+    }
+
+    let addon = await AddonManager.getAddonByID(item.id);
+    this.populateDescription(doc, addon);
+
+    // Setup the command handler.
+    let handleCommand = async (event) => {
+      panel.hidePopup();
+
+      if (event.originalTarget.getAttribute("anonid") == "button") {
+        // Main action is to keep changes.
+        await this.setConfirmation(item.id);
+      } else {
+        // Secondary action is to restore settings.
+        await this.beforeDisableAddon(this);
+        addon.userDisabled = true;
+      }
+      win.gURLBar.focus();
+    };
+    panel.addEventListener("command", handleCommand);
+    panel.addEventListener("popuphidden", () => {
+      popupnotification.hidden = true;
+      panel.removeEventListener("command", handleCommand);
+    }, {once: true});
+
+    // Look for a browserAction on the toolbar.
+    let action = CustomizableUI.getWidget(
+      `${makeWidgetId(item.id)}-browser-action`);
+    if (action) {
+      action = action.areaType == "toolbar" && action.forWindow(win).node;
+    }
+
+    // Anchor to a toolbar browserAction if found, otherwise use the menu button.
+    let anchor = doc.getAnonymousElementByAttribute(
+      action || doc.getElementById("PanelUI-menu-button"),
+      "class", "toolbarbutton-icon");
+    panel.hidden = false;
+    popupnotification.hidden = false;
+    panel.openPopup(anchor);
+  }
+
+  getAddonDetails(doc, addon) {
+    const defaultIcon = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+
+    let image = doc.createElement("image");
+    image.setAttribute("src", addon.iconURL || defaultIcon);
+    image.classList.add("extension-controlled-icon");
+
+    let addonDetails = doc.createDocumentFragment();
+    addonDetails.appendChild(image);
+    addonDetails.appendChild(doc.createTextNode(" " + addon.name));
+
+    return addonDetails;
+  }
+
+  populateDescription(doc, addon) {
+    let description = doc.getElementById(this.descriptionId);
+    description.textContent = "";
+
+    let addonDetails = this.getAddonDetails(doc, addon);
+    let message = strBundle.GetStringFromName(this.descriptionMessageId);
+    description.appendChild(
+      BrowserUtils.getLocalizedFragment(doc, message, addonDetails));
+
+    let link = doc.createElement("label");
+    link.setAttribute("class", "learnMore text-link");
+    link.href = Services.urlFormatter.formatURLPref("app.support.baseURL") + this.learnMoreLink;
+    link.textContent = strBundle.GetStringFromName(this.learnMoreMessageId);
+    description.appendChild(link);
+  }
+}
--- a/browser/components/extensions/ExtensionPopups.jsm
+++ b/browser/components/extensions/ExtensionPopups.jsm
@@ -17,30 +17,25 @@ ChromeUtils.defineModuleGetter(this, "Ex
 ChromeUtils.defineModuleGetter(this, "setTimeout",
                                "resource://gre/modules/Timer.jsm");
 
 ChromeUtils.import("resource://gre/modules/AppConstants.jsm");
 ChromeUtils.import("resource://gre/modules/ExtensionUtils.jsm");
 
 var {
   DefaultWeakMap,
+  makeWidgetId,
   promiseEvent,
 } = ExtensionUtils;
 
 
 const POPUP_LOAD_TIMEOUT_MS = 200;
 
 const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul";
 
-function makeWidgetId(id) {
-  id = id.toLowerCase();
-  // FIXME: This allows for collisions.
-  return id.replace(/[^a-z0-9_-]/g, "_");
-}
-
 function promisePopupShown(popup) {
   return new Promise(resolve => {
     if (popup.state == "open") {
       resolve();
     } else {
       popup.addEventListener("popupshown", function(event) {
         resolve();
       }, {once: true});
--- a/browser/components/extensions/ext-browser.json
+++ b/browser/components/extensions/ext-browser.json
@@ -168,16 +168,17 @@
     "paths": [
       ["tabs"]
     ]
   },
   "urlOverrides": {
     "url": "chrome://browser/content/parent/ext-url-overrides.js",
     "schema": "chrome://browser/content/schemas/url_overrides.json",
     "scopes": ["addon_parent"],
+    "events": ["uninstall"],
     "manifest": ["chrome_url_overrides"],
     "paths": [
       ["urlOverrides"]
     ]
   },
   "windows": {
     "url": "chrome://browser/content/parent/ext-windows.js",
     "schema": "chrome://browser/content/schemas/windows.json",
--- a/browser/components/extensions/moz.build
+++ b/browser/components/extensions/moz.build
@@ -9,16 +9,17 @@ with Files("**"):
 
 JAR_MANIFESTS += ['jar.mn']
 
 EXTRA_COMPONENTS += [
     'extensions-browser.manifest',
 ]
 
 EXTRA_JS_MODULES += [
+    'ExtensionControlledPopup.jsm',
     'ExtensionPopups.jsm',
     'ParseBreakpadSymbols-worker.js',
     'ParseCppFiltSymbols-worker.js',
     'ParseNMSymbols-worker.js',
     'ParseSymbols.jsm',
 ]
 
 DIRS += ['schemas']
--- a/browser/components/extensions/parent/ext-url-overrides.js
+++ b/browser/components/extensions/parent/ext-url-overrides.js
@@ -1,54 +1,31 @@
 /* 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";
 
 ChromeUtils.defineModuleGetter(this, "AddonManager",
                                "resource://gre/modules/AddonManager.jsm");
-ChromeUtils.defineModuleGetter(this, "BrowserUtils",
-                               "resource://gre/modules/BrowserUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "CustomizableUI",
                                "resource:///modules/CustomizableUI.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionControlledPopup",
+                               "resource:///modules/ExtensionControlledPopup.jsm");
 ChromeUtils.defineModuleGetter(this, "ExtensionSettingsStore",
                                "resource://gre/modules/ExtensionSettingsStore.jsm");
 
 XPCOMUtils.defineLazyServiceGetter(this, "aboutNewTabService",
                                    "@mozilla.org/browser/aboutnewtab-service;1",
                                    "nsIAboutNewTabService");
 
 const STORE_TYPE = "url_overrides";
 const NEW_TAB_SETTING_NAME = "newTabURL";
 const NEW_TAB_CONFIRMED_TYPE = "newTabNotification";
 
-XPCOMUtils.defineLazyGetter(this, "strBundle", function() {
-  return Services.strings.createBundle("chrome://global/locale/extensions.properties");
-});
-
-function userWasNotified(extensionId) {
-  let setting = ExtensionSettingsStore.getSetting(NEW_TAB_CONFIRMED_TYPE, extensionId);
-  return setting && setting.value;
-}
-
-function getAddonDetails(doc, addon) {
-  const defaultIcon = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
-
-  let image = doc.createElement("image");
-  image.setAttribute("src", addon.iconURL || defaultIcon);
-  image.classList.add("extension-controlled-icon");
-
-  let addonDetails = doc.createDocumentFragment();
-  addonDetails.appendChild(image);
-  addonDetails.appendChild(doc.createTextNode(" " + addon.name));
-
-  return addonDetails;
-}
-
 function replaceUrlInTab(gBrowser, tab, url) {
   let loaded = new Promise(resolve => {
     windowTracker.addListener("progress", {
       onLocationChange(browser, webProgress, request, locationURI, flags) {
         if (webProgress.isTopLevel
             && browser.ownerGlobal.gBrowser.getTabForBrowser(browser) == tab
             && locationURI.spec == url) {
           windowTracker.removeListener(this);
@@ -58,131 +35,72 @@ function replaceUrlInTab(gBrowser, tab, 
     });
   });
   gBrowser.loadURI(url, {
     flags: Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY,
   });
   return loaded;
 }
 
-async function handleNewTabOpened() {
-  // We don't need to open the doorhanger again until the controlling add-on changes.
-  // eslint-disable-next-line no-use-before-define
-  removeNewTabObserver();
 
-  let item = ExtensionSettingsStore.getSetting(STORE_TYPE, NEW_TAB_SETTING_NAME);
-
-  if (!item || !item.id || userWasNotified(item.id)) {
-    return;
-  }
-
-  // Find the elements we need.
-  let win = windowTracker.getCurrentWindow({});
-  let doc = win.document;
-  let panel = doc.getElementById("extension-notification-panel");
-  let addon = await AddonManager.getAddonByID(item.id);
-
-  let description = doc.getElementById("extension-new-tab-notification-description");
-  while (description.firstChild) {
-    description.firstChild.remove();
-  }
-  let message = strBundle.GetStringFromName("newTabControlled.message2");
-  let addonDetails = getAddonDetails(doc, addon);
-  description.appendChild(
-    BrowserUtils.getLocalizedFragment(doc, message, addonDetails));
-
-  // Add the Learn more link to the description.
-  let link = doc.createElement("label");
-  link.setAttribute("class", "learnMore text-link");
-  link.href = Services.urlFormatter.formatURLPref("app.support.baseURL") + "extension-home";
-  link.textContent = strBundle.GetStringFromName("newTabControlled.learnMore");
-  description.appendChild(link);
-
-  // Setup the command handler.
-  let handleCommand = async (event) => {
-    if (event.originalTarget.getAttribute("anonid") == "button") {
-      // Main action is to keep changes.
-      await ExtensionSettingsStore.addSetting(
-        item.id, NEW_TAB_CONFIRMED_TYPE, item.id, true, () => false);
-    } else {
-      // Secondary action is to restore settings. Disabling an add-on should remove
-      // the tabs that it has open, but we want to open the new New Tab in this tab.
-      //   1. Replace the tab's URL with about:blank, wait for it to change
-      //   2. Now that this tab isn't associated with the add-on, disable the add-on
-      //   3. Replace the tab's URL with the new New Tab URL
-      ExtensionSettingsStore.removeSetting(NEW_TAB_CONFIRMED_TYPE, item.id);
-      let gBrowser = win.gBrowser;
+XPCOMUtils.defineLazyGetter(this, "newTabPopup", () => {
+  return new ExtensionControlledPopup({
+    confirmedType: NEW_TAB_CONFIRMED_TYPE,
+    observerTopic: "browser-open-newtab-start",
+    popupnotificationId: "extension-new-tab-notification",
+    settingType: STORE_TYPE,
+    settingKey: NEW_TAB_SETTING_NAME,
+    descriptionId: "extension-new-tab-notification-description",
+    descriptionMessageId: "newTabControlled.message2",
+    learnMoreMessageId: "newTabControlled.learnMore",
+    learnMoreLink: "extension-home",
+    onObserverAdded() {
+      aboutNewTabService.willNotifyUser = true;
+    },
+    onObserverRemoved() {
+      aboutNewTabService.willNotifyUser = false;
+    },
+    async beforeDisableAddon(popup) {
+      // ExtensionControlledPopup will disable the add-on once this function completes.
+      // Disabling an add-on should remove the tabs that it has open, but we want
+      // to open the new New Tab in this tab (which might get closed).
+      //   1. Replace the tab's URL with about:blank
+      //   2. Return control to ExtensionControlledPopup once about:blank has loaded
+      //   3. Once the New Tab URL has changed, replace the tab's URL with the new New Tab URL
+      let gBrowser = windowTracker.topWindow.gBrowser;
       let tab = gBrowser.selectedTab;
       await replaceUrlInTab(gBrowser, tab, "about:blank");
       Services.obs.addObserver({
         async observe() {
           await replaceUrlInTab(gBrowser, tab, aboutNewTabService.newTabURL);
-          handleNewTabOpened();
+          // Now that the New Tab is loading, try to open the popup again. This
+          // will only open the popup if a new extension is controlling the New Tab.
+          popup.open();
           Services.obs.removeObserver(this, "newtab-url-changed");
         },
       }, "newtab-url-changed");
-
-      addon.userDisabled = true;
-    }
-    panel.hidePopup();
-    win.gURLBar.focus();
-  };
-  panel.addEventListener("command", handleCommand);
-  panel.addEventListener("popuphidden", () => {
-    panel.removeEventListener("command", handleCommand);
-  }, {once: true});
-
-  // Look for a browserAction on the toolbar.
-  let action = CustomizableUI.getWidget(
-    `${global.makeWidgetId(item.id)}-browser-action`);
-  if (action) {
-    action = action.areaType == "toolbar" && action.forWindow(win).node;
-  }
-
-  // Anchor to a toolbar browserAction if found, otherwise use the menu button.
-  let anchor = doc.getAnonymousElementByAttribute(
-    action || doc.getElementById("PanelUI-menu-button"),
-    "class", "toolbarbutton-icon");
-  panel.hidden = false;
-  panel.openPopup(anchor);
-}
-
-let newTabOpenedListener = {
-  observe(subject, topic, data) {
-    // Do this work in an idle callback to avoid interfering with new tab performance tracking.
-    windowTracker
-      .getCurrentWindow({})
-      .requestIdleCallback(handleNewTabOpened);
-  },
-};
-
-function removeNewTabObserver() {
-  if (aboutNewTabService.willNotifyUser) {
-    Services.obs.removeObserver(newTabOpenedListener, "browser-open-newtab-start");
-    aboutNewTabService.willNotifyUser = false;
-  }
-}
-
-function addNewTabObserver(extensionId) {
-  if (!aboutNewTabService.willNotifyUser && extensionId && !userWasNotified(extensionId)) {
-    Services.obs.addObserver(newTabOpenedListener, "browser-open-newtab-start");
-    aboutNewTabService.willNotifyUser = true;
-  }
-}
+    },
+  });
+});
 
 function setNewTabURL(extensionId, url) {
   if (extensionId) {
-    addNewTabObserver(extensionId);
+    newTabPopup.addObserver(extensionId);
   } else {
-    removeNewTabObserver();
+    newTabPopup.removeObserver();
   }
   aboutNewTabService.newTabURL = url;
 }
 
 this.urlOverrides = class extends ExtensionAPI {
+  static onUninstall(id) {
+    // TODO: This can be removed once bug 1438364 is fixed and all data is cleaned up.
+    newTabPopup.clearConfirmation(id);
+  }
+
   processNewTabSetting(action) {
     let {extension} = this;
     let item = ExtensionSettingsStore[action](extension.id, STORE_TYPE, NEW_TAB_SETTING_NAME);
     if (item) {
       setNewTabURL(item.id, item.value || item.initialValue);
     }
   }
 
@@ -191,24 +109,20 @@ this.urlOverrides = class extends Extens
     let {manifest} = extension;
 
     await ExtensionSettingsStore.initialize();
 
     if (manifest.chrome_url_overrides.newtab) {
       // Set up the shutdown code for the setting.
       extension.callOnClose({
         close: () => {
-          if (extension.shutdownReason == "ADDON_DISABLE"
-              || extension.shutdownReason == "ADDON_UNINSTALL") {
-            ExtensionSettingsStore.removeSetting(
-              extension.id, NEW_TAB_CONFIRMED_TYPE, extension.id);
-          }
           switch (extension.shutdownReason) {
             case "ADDON_DISABLE":
               this.processNewTabSetting("disable");
+              newTabPopup.clearConfirmation(extension.id);
               break;
 
             // We can remove the setting on upgrade or downgrade because it will be
             // added back in when the manifest is re-read. This will cover the case
             // where a new version of an add-on removes the manifest key.
             case "ADDON_DOWNGRADE":
             case "ADDON_UPGRADE":
             case "ADDON_UNINSTALL":
--- a/browser/components/extensions/test/browser/browser-common.ini
+++ b/browser/components/extensions/test/browser/browser-common.ini
@@ -33,16 +33,17 @@ support-files =
   serviceWorker.js
   searchSuggestionEngine.xml
   searchSuggestionEngine.sjs
   ../../../../../toolkit/components/extensions/test/mochitest/head_webrequest.js
   ../../../../../toolkit/components/extensions/test/mochitest/redirection.sjs
   ../../../../../toolkit/components/reader/test/readerModeNonArticle.html
   ../../../../../toolkit/components/reader/test/readerModeArticle.html
 
+[browser_ExtensionControlledPopup.js]
 [browser_ext_addon_debugging_netmonitor.js]
 [browser_ext_browserAction_area.js]
 [browser_ext_browserAction_experiment.js]
 [browser_ext_browserAction_context.js]
 [browser_ext_browserAction_contextMenu.js]
 # bug 1369197
 skip-if = os == 'linux'
 [browser_ext_browserAction_disabled.js]
new file mode 100644
--- /dev/null
+++ b/browser/components/extensions/test/browser/browser_ExtensionControlledPopup.js
@@ -0,0 +1,222 @@
+/* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
+/* vim: set sts=2 sw=2 et tw=80: */
+/* global sinon */
+
+"use strict";
+Services.scriptloader.loadSubScript("resource://testing-common/sinon-2.3.2.js");
+
+registerCleanupFunction(() => {
+  delete window.sinon;
+});
+
+ChromeUtils.defineModuleGetter(this, "ExtensionSettingsStore",
+                               "resource://gre/modules/ExtensionSettingsStore.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionControlledPopup",
+                               "resource:///modules/ExtensionControlledPopup.jsm");
+
+function createMarkup(doc) {
+  let panel = doc.getElementById("extension-notification-panel");
+  let popupnotification = doc.createElement("popupnotification");
+  let attributes = {
+    id: "extension-controlled-notification",
+    class: "extension-controlled-notification",
+    popupid: "extension-controlled",
+    hidden: "true",
+    label: "ExtControlled",
+    buttonlabel: "Keep Changes",
+    buttonaccesskey: "K",
+    secondarybuttonlabel: "Restore Settings",
+    secondarybuttonaccesskey: "R",
+    closebuttonhidden: "true",
+    dropmarkerhidden: "true",
+    checkboxhidden: "true",
+  };
+  Object.entries(attributes).forEach(([key, value]) => {
+    popupnotification.setAttribute(key, value);
+  });
+  let content = doc.createElement("popupnotificationcontent");
+  content.setAttribute("orient", "vertical");
+  let description = doc.createElement("description");
+  description.setAttribute("id", "extension-controlled-description");
+  content.appendChild(description);
+  popupnotification.appendChild(content);
+  panel.appendChild(popupnotification);
+
+  registerCleanupFunction(function removePopup() {
+    popupnotification.remove();
+  });
+
+  return {panel, popupnotification};
+}
+
+/*
+ * This function is a unit test for ExtensionControlledPopup. It is also tested
+ * where it is being used (currently New Tab and homepage). An empty extension
+ * is used along with the expected markup as an example.
+ */
+add_task(async function testExtensionControlledPopup() {
+  let id = "ext-controlled@mochi.test";
+  let extension = ExtensionTestUtils.loadExtension({
+    manifest: {
+      applications: {gecko: {id}},
+      name: "Ext Controlled",
+    },
+    // We need to be able to find the extension using AddonManager.
+    useAddonManager: "temporary",
+  });
+
+  await extension.startup();
+  let addon = await AddonManager.getAddonByID(id);
+  await ExtensionSettingsStore.initialize();
+
+  let confirmedType = "extension-controlled-confirmed";
+  let onObserverAdded = sinon.spy();
+  let onObserverRemoved = sinon.spy();
+  let observerTopic = "extension-controlled-event";
+  let beforeDisableAddon = sinon.spy();
+  let settingType = "extension-controlled";
+  let settingKey = "some-key";
+  let popup = new ExtensionControlledPopup({
+    confirmedType,
+    observerTopic,
+    popupnotificationId: "extension-controlled-notification",
+    settingType,
+    settingKey,
+    descriptionId: "extension-controlled-description",
+    descriptionMessageId: "newTabControlled.message2",
+    learnMoreMessageId: "newTabControlled.learnMore",
+    learnMoreLink: "extension-controlled",
+    onObserverAdded,
+    onObserverRemoved,
+    beforeDisableAddon,
+  });
+
+  let doc = Services.wm.getMostRecentWindow("navigator:browser").document;
+  let {panel, popupnotification} = createMarkup(doc);
+
+  function openPopupWithEvent() {
+    let popupShown = promisePopupShown(panel);
+    Services.obs.notifyObservers(null, observerTopic);
+    return popupShown;
+  }
+
+  function closePopupWithAction(action, extensionId) {
+    let done;
+    if (action == "ignore") {
+      panel.hidePopup();
+    } else {
+      if (action == "button") {
+        done = TestUtils.waitForCondition(() => {
+          return ExtensionSettingsStore.getSetting(confirmedType, id, id).value;
+        });
+      } else if (action == "secondarybutton") {
+        done = awaitEvent("shutdown", id);
+      }
+      doc.getAnonymousElementByAttribute(
+        popupnotification, "anonid", action).click();
+    }
+    return done;
+  }
+
+  // No callbacks are initially called.
+  ok(!onObserverAdded.called, "No observer has been added");
+  ok(!onObserverRemoved.called, "No observer has been removed");
+  ok(!beforeDisableAddon.called, "Settings have not been restored");
+
+  // Add the setting and observer.
+  await ExtensionSettingsStore.addSetting(id, settingType, settingKey, "controlled", () => "init");
+  await popup.addObserver(id);
+
+  // Ensure the panel isn't open.
+  ok(onObserverAdded.called, "Observing the event");
+  onObserverAdded.reset();
+  ok(!onObserverRemoved.called, "Observing the event");
+  ok(!beforeDisableAddon.called, "Settings have not been restored");
+  ok(panel.getAttribute("panelopen") != "true", "The panel is closed");
+  is(popupnotification.hidden, true, "The popup is hidden");
+  is(addon.userDisabled, false, "The extension is enabled");
+  is(await popup.userHasConfirmed(id), false, "The user is not initially confirmed");
+
+  // The popup should opened based on the observer event.
+  await openPopupWithEvent();
+
+  ok(!onObserverAdded.called, "Only one observer has been registered");
+  ok(onObserverRemoved.called, "The observer was removed");
+  onObserverRemoved.reset();
+  ok(!beforeDisableAddon.called, "Settings have not been restored");
+  is(panel.getAttribute("panelopen"), "true", "The panel is open");
+  is(popupnotification.hidden, false, "The popup content is visible");
+  is(await popup.userHasConfirmed(id), false, "The user has not confirmed yet");
+
+  // Verify the description is populated.
+  let description = doc.getElementById("extension-controlled-description");
+  is(description.textContent,
+     "An extension,  Ext Controlled, changed the page you see when you open a new tab.Learn more",
+     "The extension name is in the description");
+  let link = description.querySelector("label");
+  is(link.href, "http://127.0.0.1:8888/support-dummy/extension-controlled",
+     "The link has the href set from learnMoreLink");
+
+  // Force close the popup, as if a user clicked away from it.
+  await closePopupWithAction("ignore");
+
+  // Nothing was recorded, but we won't show it again.
+  ok(!onObserverAdded.called, "The observer hasn't changed");
+  ok(!onObserverRemoved.called, "The observer hasn't changed");
+  is(await popup.userHasConfirmed(id), false, "The user has not confirmed");
+  is(addon.userDisabled, false, "The extension is still enabled");
+
+  // Force add the observer again to keep changes.
+  await popup.addObserver(id);
+  ok(onObserverAdded.called, "The observer was added again");
+  onObserverAdded.reset();
+  ok(!onObserverRemoved.called, "The observer is still registered");
+  is(await popup.userHasConfirmed(id), false, "The user has not confirmed");
+
+  // Wait for popup.
+  await openPopupWithEvent();
+
+  // Keep the changes.
+  await closePopupWithAction("button");
+
+  // The observer is removed, but the notification is saved.
+  ok(!onObserverAdded.called, "The observer wasn't added");
+  ok(onObserverRemoved.called, "The observer was removed");
+  onObserverRemoved.reset();
+  is(await popup.userHasConfirmed(id), true, "The user has confirmed");
+  is(addon.userDisabled, false, "The extension is still enabled");
+
+  // Adding the observer again for this add-on won't work, since it is
+  // confirmed.
+  await popup.addObserver(id);
+  ok(!onObserverAdded.called, "The observer isn't added");
+  ok(!onObserverRemoved.called, "The observer isn't removed");
+  is(await popup.userHasConfirmed(id), true, "The user has confirmed");
+
+  // Clear that the user was notified.
+  await popup.clearConfirmation(id);
+  is(await popup.userHasConfirmed(id), false, "The user confirmation has been cleared");
+
+  // Force add the observer again to restore changes.
+  await popup.addObserver(id);
+  ok(onObserverAdded.called, "The observer was added a third time");
+  onObserverAdded.reset();
+  ok(!onObserverRemoved.called, "The observer is still active");
+  ok(!beforeDisableAddon.called, "We haven't disabled the add-on yet");
+  is(await popup.userHasConfirmed(id), false, "The user has not confirmed");
+
+  // Wait for popup.
+  await openPopupWithEvent();
+
+  // Restore the settings.
+  await closePopupWithAction("secondarybutton");
+
+  // The observer is removed and the add-on is now disabled.
+  ok(!onObserverAdded.called, "There is no observer");
+  ok(onObserverRemoved.called, "The observer has been removed");
+  ok(beforeDisableAddon.called, "The beforeDisableAddon callback was fired");
+  is(await popup.userHasConfirmed(id), false, "The user has not confirmed");
+  is(addon.userDisabled, true, "The extension is now disabled");
+
+  await extension.unload();
+});
--- a/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js
+++ b/browser/components/extensions/test/browser/browser_ext_url_overrides_newtab.js
@@ -179,16 +179,17 @@ add_task(async function test_new_tab_ign
 
 add_task(async function test_new_tab_keep_settings() {
   await ExtensionSettingsStore.initialize();
   let notification = getNewTabDoorhanger();
   let panel = notification.closest("panel");
   let extensionId = "newtabkeep@mochi.test";
   let manifest = {
     version: "1.0",
+    name: "New Tab Add-on",
     applications: {gecko: {id: extensionId}},
     chrome_url_overrides: {newtab: "keep.html"},
   };
   let files = {
     "keep.html": '<script src="newtab.js"></script><h1 id="extension-new-tab">New Tab!</h1>',
     "newtab.js": () => { window.onload = browser.test.sendMessage("newtab"); },
   };
   let extension = ExtensionTestUtils.loadExtension({
@@ -197,34 +198,41 @@ add_task(async function test_new_tab_kee
     useAddonManager: "permanent",
   });
 
   ok(panel.getAttribute("panelopen") != "true",
      "The notification panel is initially closed");
 
   await extension.startup();
 
+
   // Simulate opening the New Tab as a user would.
   let popupShown = promisePopupShown(panel);
   BrowserOpenTab();
   await extension.awaitMessage("newtab");
   await popupShown;
 
   // Ensure the panel is open and the setting isn't saved yet.
   is(panel.getAttribute("panelopen"), "true",
      "The notification panel is open after opening New Tab");
   is(getNotificationSetting(extensionId), null,
      "The New Tab notification is not set for this extension");
   is(panel.anchorNode.closest("toolbarbutton").id, "PanelUI-menu-button",
      "The doorhanger is anchored to the menu icon");
+  is(panel.querySelector("description").textContent,
+     "An extension,  New Tab Add-on, changed the page you see when you open a new tab.Learn more",
+     "The description includes the add-on name");
 
   // Click the Keep Changes button.
-  let popupHidden = promisePopupHidden(panel);
+  let confirmationSaved = TestUtils.waitForCondition(() => {
+    return ExtensionSettingsStore.getSetting(
+      "newTabNotification", extensionId, extensionId).value;
+  });
   clickKeepChanges(notification);
-  await popupHidden;
+  await confirmationSaved;
 
   // Ensure panel is closed and setting is updated.
   ok(panel.getAttribute("panelopen") != "true",
      "The notification panel is closed after click");
   is(getNotificationSetting(extensionId).value, true,
      "The New Tab notification is set after keeping the changes");
 
   // Close the first tab and open another new tab.
@@ -253,16 +261,20 @@ add_task(async function test_new_tab_kee
   ok(panel.getAttribute("panelopen") != "true",
      "The notification panel is closed after click");
   is(getNotificationSetting(extensionId).value, true,
      "The New Tab notification is set after keeping the changes");
 
   BrowserTestUtils.removeTab(gBrowser.selectedTab);
   await upgradedExtension.unload();
   await extension.unload();
+
+  let confirmation = ExtensionSettingsStore.getSetting(
+    "newTabNotification", extensionId, extensionId);
+  is(confirmation, null, "The confirmation has been cleaned up");
 });
 
 add_task(async function test_new_tab_restore_settings() {
   await ExtensionSettingsStore.initialize();
   let notification = getNewTabDoorhanger();
   let panel = notification.closest("panel");
   let extensionId = "newtabrestore@mochi.test";
   let extension = ExtensionTestUtils.loadExtension({
--- a/browser/themes/shared/customizableui/panelUI.inc.css
+++ b/browser/themes/shared/customizableui/panelUI.inc.css
@@ -315,37 +315,37 @@ panelview:not([mainview]) .toolbarbutton
   padding: 4px 0;
 }
 
 /* START notification popups for extension controlled content */
 #extension-notification-panel > .panel-arrowcontainer > .panel-arrowcontent {
   padding: 0;
 }
 
-#extension-new-tab-notification > .popup-notification-body-container > .popup-notification-body {
+.extension-controlled-notification > .popup-notification-body-container > .popup-notification-body {
   width: 30em;
 }
 
-#extension-new-tab-notification > .popup-notification-body-container > .popup-notification-body > hbox > vbox > .popup-notification-description {
+.extension-controlled-notification > .popup-notification-body-container > .popup-notification-body > hbox > vbox > .popup-notification-description {
   font-size: 1.3em;
   font-weight: lighter;
 }
 
-#extension-new-tab-notification-description {
+.extension-controlled-notification {
   margin-bottom: 0;
 }
 
-#extension-new-tab-notification-description > .extension-controlled-icon {
+.extension-controlled-notification > popupnotificationcontent > description > .extension-controlled-icon {
   height: 16px;
   width: 16px;
   vertical-align: bottom;
 }
 
-#extension-new-tab-notification > .popup-notification-body-container > .popup-notification-body > .popup-notification-warning,
-#extension-new-tab-notification > .popup-notification-body-container > .popup-notification-icon {
+.extension-controlled-notification > .popup-notification-body-container > .popup-notification-body > .popup-notification-warning,
+.extension-controlled-notification > .popup-notification-body-container > .popup-notification-icon {
   display: none;
 }
 /* END notification popups for extension controlled content */
 
 #appMenu-popup > .panel-arrowcontainer > .panel-arrowcontent,
 panel[photon] > .panel-arrowcontainer > .panel-arrowcontent {
   padding: 0;
 }
--- a/toolkit/components/extensions/ExtensionUtils.jsm
+++ b/toolkit/components/extensions/ExtensionUtils.jsm
@@ -660,27 +660,34 @@ function checkLoadURL(url, principal, op
                                   Services.io.newURI(url),
                                   flags);
   } catch (e) {
     return false;
   }
   return true;
 }
 
+function makeWidgetId(id) {
+  id = id.toLowerCase();
+  // FIXME: This allows for collisions.
+  return id.replace(/[^a-z0-9_-]/g, "_");
+}
+
 var ExtensionUtils = {
   checkLoadURL,
   defineLazyGetter,
   flushJarCache,
   getConsole,
   getInnerWindowID,
   getMessageManager,
   getUniqueId,
   filterStack,
   getWinUtils,
   instanceOf,
+  makeWidgetId,
   normalizeTime,
   promiseDocumentIdle,
   promiseDocumentLoaded,
   promiseDocumentReady,
   promiseEvent,
   promiseObserved,
   runSafeSyncWithoutClone,
   withHandlingUserInput,