Bug 1397809 - Part 1: Convert New Tab doorhanger to a generic class r=aswan,Gijs
authorMark Striemer <mstriemer@mozilla.com>
Tue, 19 Dec 2017 13:56:04 -0600
changeset 467658 0ff9b41cb0dd31c398ca6715563550b2e36469e0
parent 467657 2e462060cbedc258c3cfbb7aba14e22fd13cf854
child 467659 8f31c3153ea07a9d39425ea00506f97d4d4fed38
push id9165
push userasasaki@mozilla.com
push dateThu, 26 Apr 2018 21:04:54 +0000
treeherdermozilla-beta@064c3804de2e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan, Gijs
bugs1397809
milestone61.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1397809 - Part 1: Convert New Tab doorhanger to a generic class r=aswan,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,