Bug 1397809 - Part 2: Add a doorhanger when an extension changes the homepage r?aswan r?gijs draft
authorMark Striemer <mstriemer@mozilla.com>
Thu, 11 Jan 2018 18:59:32 -0600
changeset 783756 ba991c2b0b6d234c99d292b5845e88d92ae288c7
parent 783755 30498b346b64ca492a3ef61fe9fb653fc5836aac
push id106768
push userbmo:mstriemer@mozilla.com
push dateTue, 17 Apr 2018 16:39:48 +0000
reviewersaswan, gijs
bugs1397809
milestone61.0a1
Bug 1397809 - Part 2: Add a doorhanger when an extension changes the homepage r?aswan r?gijs MozReview-Commit-ID: CwZ3E32xROS
browser/base/content/browser.js
browser/components/customizableui/content/panelUI.inc.xul
browser/components/extensions/ExtensionControlledPopup.jsm
browser/components/extensions/parent/.eslintrc.js
browser/components/extensions/parent/ext-browser.js
browser/components/extensions/parent/ext-chrome-settings-overrides.js
browser/components/extensions/parent/ext-url-overrides.js
browser/components/extensions/test/browser/browser_ext_chrome_settings_overrides_home.js
browser/locales/en-US/chrome/browser/browser.dtd
toolkit/components/extensions/ExtensionPreferencesManager.jsm
toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js
toolkit/locales/en-US/chrome/global/extensions.properties
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -2209,42 +2209,58 @@ var BrowserHome = BrowserGoHome;
 function BrowserGoHome(aEvent) {
   if (aEvent && "button" in aEvent &&
       aEvent.button == 2) // right-click: do nothing
     return;
 
   var homePage = gHomeButton.getHomePage();
   var where = whereToOpenLink(aEvent, false, true);
   var urls;
+  var notifyObservers;
 
   // Home page should open in a new tab when current tab is an app tab
   if (where == "current" &&
       gBrowser &&
       gBrowser.selectedTab.pinned)
     where = "tab";
 
   // openTrustedLinkIn in utilityOverlay.js doesn't handle loading multiple pages
   switch (where) {
   case "current":
     loadOneOrMoreURIs(homePage, Services.scriptSecurityManager.getSystemPrincipal());
     gBrowser.selectedBrowser.focus();
+    notifyObservers = true;
     break;
   case "tabshifted":
   case "tab":
     urls = homePage.split("|");
     var loadInBackground = getBoolPref("browser.tabs.loadBookmarksInBackground", false);
+    // The homepage observer event should only be triggered when the homepage opens
+    // in the foreground. This is mostly to support the homepage changed by extension
+    // doorhanger which doesn't currently support background pages. This may change in
+    // bug 1438396.
+    notifyObservers = !loadInBackground;
     gBrowser.loadTabs(urls, {
       inBackground: loadInBackground,
       triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(),
     });
     break;
   case "window":
+    // OpenBrowserWindow will trigger the observer event, so no need to do so here.
+    notifyObservers = false;
     OpenBrowserWindow();
     break;
   }
+  if (notifyObservers) {
+    // A notification for when a user has triggered their homepage. This is used
+    // to display a doorhanger explaining that an extension has modified the
+    // homepage, if necessary. Observers are only notified if the homepage
+    // becomes the active page.
+    Services.obs.notifyObservers(null, "browser-open-homepage-start");
+  }
 }
 
 function loadOneOrMoreURIs(aURIString, aTriggeringPrincipal) {
   // we're not a browser window, pass the URI string to a new browser window
   if (window.location.href != getBrowserURL()) {
     window.openDialog(getBrowserURL(), "_blank", "all,dialog=no", aURIString);
     return;
   }
@@ -2318,16 +2334,19 @@ function openLocation() {
 }
 
 function BrowserOpenTab(event) {
   // A notification intended to be useful for modular peformance tracking
   // starting as close as is reasonably possible to the time when the user
   // expressed the intent to open a new tab.  Since there are a lot of
   // entry points, this won't catch every single tab created, but most
   // initiated by the user should go through here.
+  //
+  // This is also used to notify a user that an extension has changed the
+  // New Tab page.
   Services.obs.notifyObservers(null, "browser-open-newtab-start");
 
   let where = "tab";
   let relatedToCurrent = false;
 
   if (event) {
     where = whereToOpenLink(event, false, true);
 
@@ -4288,16 +4307,23 @@ function OpenBrowserWindow(options) {
     win = window.openDialog("chrome://browser/content/", "_blank", "chrome,all,dialog=no" + extraFeatures, defaultArgs, charsetArg);
   } else {
     // forget about the charset information.
     win = window.openDialog("chrome://browser/content/", "_blank", "chrome,all,dialog=no" + extraFeatures, defaultArgs);
   }
 
   win.addEventListener("MozAfterPaint", () => {
     TelemetryStopwatch.finish("FX_NEW_WINDOW_MS", telemetryObj);
+    if (Services.prefs.getIntPref("browser.startup.page") == 1
+        && defaultArgs == handler.startPage) {
+      // A notification for when a user has triggered their homepage. This is used
+      // to display a doorhanger explaining that an extension has modified the
+      // homepage, if necessary.
+      Services.obs.notifyObservers(win, "browser-open-homepage-start");
+    }
   }, {once: true});
 
   return win;
 }
 
 /**
  * Update the global flag that tracks whether or not any edit UI (the Edit menu,
  * edit-related items in the context menu, and edit-related toolbar buttons
--- a/browser/components/customizableui/content/panelUI.inc.xul
+++ b/browser/components/customizableui/content/panelUI.inc.xul
@@ -712,9 +712,25 @@
                      secondarybuttonaccesskey="&newTabControlled.disableButton.accesskey;"
                      closebuttonhidden="true"
                      dropmarkerhidden="true"
                      checkboxhidden="true">
     <popupnotificationcontent orient="vertical">
       <description id="extension-new-tab-notification-description"/>
     </popupnotificationcontent>
   </popupnotification>
+  <popupnotification id="extension-homepage-notification"
+                     class="extension-controlled-notification"
+                     popupid="extension-homepage"
+                     hidden="true"
+                     label="&homepageControlled.header.message;"
+                     buttonlabel="&homepageControlled.keepButton.label;"
+                     buttonaccesskey="&homepageControlled.keepButton.accesskey;"
+                     secondarybuttonlabel="&homepageControlled.disableButton.label;"
+                     secondarybuttonaccesskey="&homepageControlled.disableButton.accesskey;"
+                     closebuttonhidden="true"
+                     dropmarkerhidden="true"
+                     checkboxhidden="true">
+    <popupnotificationcontent orient="vertical">
+      <description id="extension-homepage-notification-description"/>
+    </popupnotificationcontent>
+  </popupnotification>
 </panel>
--- a/browser/components/extensions/ExtensionControlledPopup.jsm
+++ b/browser/components/extensions/ExtensionControlledPopup.jsm
@@ -41,17 +41,19 @@ XPCOMUtils.defineLazyGetter(this, "strBu
 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.
+   *                 An observer topic to trigger the popup on with Services.obs. If the
+   *                 doorhanger should appear on a specific window include it as the
+   *                 subject in the observer event.
    * @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
@@ -73,16 +75,18 @@ class ExtensionControlledPopup {
    * @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.
+   *                   This function gets two arguments, the ExtensionControlledPopup
+   *                   instance for the panel and the window that the popup appears on.
    */
   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;
@@ -116,17 +120,17 @@ class ExtensionControlledPopup {
   }
 
   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());
+    this.topWindow.requestIdleCallback(() => this.open(subject));
   }
 
   removeObserver() {
     if (this.observerRegistered) {
       Services.obs.removeObserver(this, this.observerTopic);
       this.observerRegistered = false;
       if (this.onObserverRemoved) {
         this.onObserverRemoved();
@@ -141,17 +145,17 @@ class ExtensionControlledPopup {
       Services.obs.addObserver(this, this.observerTopic);
       this.observerRegistered = true;
       if (this.onObserverAdded) {
         this.onObserverAdded();
       }
     }
   }
 
-  async open() {
+  async open(targetWindow) {
     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);
@@ -160,20 +164,21 @@ class ExtensionControlledPopup {
     // 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 win = targetWindow || this.topWindow;
     let doc = win.document;
     let panel = doc.getElementById("extension-notification-panel");
     let popupnotification = doc.getElementById(this.popupnotificationId);
+    let urlBarWasFocused = win.gURLBar.focused;
 
     if (!popupnotification) {
       throw new Error(`No popupnotification found for id "${this.popupnotificationId}"`);
     }
 
     let addon = await AddonManager.getAddonByID(item.id);
     this.populateDescription(doc, addon);
 
@@ -181,20 +186,27 @@ class ExtensionControlledPopup {
     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);
+        await this.beforeDisableAddon(this, win);
         addon.userDisabled = true;
       }
-      win.gURLBar.focus();
+
+      // If the page this is appearing on is the New Tab page then the URL bar may
+      // have been focused when the doorhanger stole focus away from it. Once an
+      // action is taken the focus state should be restored to what the user was
+      // expecting.
+      if (urlBarWasFocused) {
+        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.
--- a/browser/components/extensions/parent/.eslintrc.js
+++ b/browser/components/extensions/parent/.eslintrc.js
@@ -15,14 +15,16 @@ module.exports = {
     "getTargetTabIdForToolbox": true,
     "getToolboxEvalOptions": true,
     "isContainerCookieStoreId": true,
     "isPrivateCookieStoreId": true,
     "isValidCookieStoreId": true,
     "makeWidgetId": true,
     "openOptionsPage": true,
     "pageActionFor": true,
+    "replaceUrlInTab": true,
     "sidebarActionFor": true,
     "tabGetSender": true,
     "tabTracker": true,
+    "waitForTabLoaded": true,
     "windowTracker": true,
   },
 };
--- a/browser/components/extensions/parent/ext-browser.js
+++ b/browser/components/extensions/parent/ext-browser.js
@@ -93,16 +93,39 @@ global.openOptionsPage = (extension) => 
 };
 
 global.makeWidgetId = id => {
   id = id.toLowerCase();
   // FIXME: This allows for collisions.
   return id.replace(/[^a-z0-9_-]/g, "_");
 };
 
+global.waitForTabLoaded = (tab, url) => {
+  return new Promise(resolve => {
+    windowTracker.addListener("progress", {
+      onLocationChange(browser, webProgress, request, locationURI, flags) {
+        if (webProgress.isTopLevel
+            && browser.ownerGlobal.gBrowser.getTabForBrowser(browser) == tab
+            && (!url || locationURI.spec == url)) {
+          windowTracker.removeListener("progress", this);
+          resolve();
+        }
+      },
+    });
+  });
+};
+
+global.replaceUrlInTab = (gBrowser, tab, url) => {
+  let loaded = waitForTabLoaded(tab, url);
+  gBrowser.loadURI(url, {
+    flags: Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY,
+  });
+  return loaded;
+};
+
 // Manages tab-specific context data, and dispatching tab select events
 // across all windows.
 global.TabContext = class extends EventEmitter {
   constructor(getDefaults, extension) {
     super();
 
     this.extension = extension;
     this.getDefaults = getDefaults;
--- a/browser/components/extensions/parent/ext-chrome-settings-overrides.js
+++ b/browser/components/extensions/parent/ext-chrome-settings-overrides.js
@@ -1,25 +1,34 @@
 /* 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/. */
 
-/* globals windowTracker */
-
 "use strict";
 
+ChromeUtils.defineModuleGetter(this, "AddonManager",
+                               "resource://gre/modules/AddonManager.jsm");
+ChromeUtils.defineModuleGetter(this, "BrowserUtils",
+                               "resource://gre/modules/BrowserUtils.jsm");
 ChromeUtils.defineModuleGetter(this, "ExtensionPreferencesManager",
                                "resource://gre/modules/ExtensionPreferencesManager.jsm");
 ChromeUtils.defineModuleGetter(this, "ExtensionSettingsStore",
                                "resource://gre/modules/ExtensionSettingsStore.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionControlledPopup",
+                               "resource:///modules/ExtensionControlledPopup.jsm");
 
 const DEFAULT_SEARCH_STORE_TYPE = "default_search";
 const DEFAULT_SEARCH_SETTING_NAME = "defaultSearch";
 const ENGINE_ADDED_SETTING_NAME = "engineAdded";
 
+const HOMEPAGE_PREF = "browser.startup.homepage";
+const HOMEPAGE_CONFIRMED_TYPE = "homepageNotification";
+const HOMEPAGE_SETTING_TYPE = "prefs";
+const HOMEPAGE_SETTING_NAME = "homepage_override";
+
 // This promise is used to wait for the search service to be initialized.
 // None of the code in this module requests that initialization. It is assumed
 // that it is started at some point. If tests start to fail because this
 // promise never resolves, that's likely the cause.
 const searchInitialized = () => {
   if (Services.search.isInitialized) {
     return;
   }
@@ -31,16 +40,75 @@ const searchInitialized = () => {
       }
 
       Services.obs.removeObserver(observer, SEARCH_SERVICE_TOPIC);
       resolve();
     }, SEARCH_SERVICE_TOPIC);
   });
 };
 
+XPCOMUtils.defineLazyGetter(this, "homepagePopup", () => {
+  return new ExtensionControlledPopup({
+    confirmedType: HOMEPAGE_CONFIRMED_TYPE,
+    observerTopic: "browser-open-homepage-start",
+    popupnotificationId: "extension-homepage-notification",
+    settingType: HOMEPAGE_SETTING_TYPE,
+    settingKey: HOMEPAGE_SETTING_NAME,
+    descriptionId: "extension-homepage-notification-description",
+    descriptionMessageId: "homepageControlled.message",
+    learnMoreMessageId: "homepageControlled.learnMore",
+    learnMoreLink: "extension-home",
+    async beforeDisableAddon(popup, win) {
+      // Disabling an add-on should remove the tabs that it has open, but we want
+      // to open the new homepage in this tab (which might get closed).
+      //   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. Trigger the browser's homepage method
+      let gBrowser = win.gBrowser;
+      let tab = gBrowser.selectedTab;
+      await replaceUrlInTab(gBrowser, tab, "about:blank");
+      Services.prefs.addObserver(HOMEPAGE_PREF, async function prefObserver() {
+        Services.prefs.removeObserver(HOMEPAGE_PREF, prefObserver);
+        let loaded = waitForTabLoaded(tab);
+        win.BrowserGoHome();
+        await loaded;
+        // Manually trigger an event in case this is controlled again.
+        popup.open();
+      });
+    },
+  });
+});
+
+// When the browser starts up it will trigger the observer topic we're expecting
+// but that happens before our observer has been registered. To handle the
+// startup case we need to check if the preferences are set to load the homepage
+// and check if the homepage is active, then show the doorhanger in that case.
+async function handleInitialHomepagePopup(extensionId, homepageUrl) {
+  // browser.startup.page == 1 is show homepage.
+  if (Services.prefs.getIntPref("browser.startup.page") == 1) {
+    let {gBrowser} = windowTracker.topWindow;
+    let tab = gBrowser.selectedTab;
+    let currentUrl = gBrowser.currentURI.spec;
+    // When the first window is still loading the URL might be about:blank.
+    // Wait for that the actual page to load before checking the URL, unless
+    // the homepage is set to about:blank.
+    if (currentUrl != homepageUrl && currentUrl == "about:blank") {
+      await waitForTabLoaded(tab);
+      currentUrl = gBrowser.currentURI.spec;
+    }
+    // Once the page has loaded, if necessary and the active tab hasn't changed,
+    // then show the popup now.
+    if (currentUrl == homepageUrl && gBrowser.selectedTab == tab) {
+      homepagePopup.open();
+      return;
+    }
+  }
+  homepagePopup.addObserver(extensionId);
+}
+
 this.chrome_settings_overrides = class extends ExtensionAPI {
   static async processDefaultSearchSetting(action, id) {
     await ExtensionSettingsStore.initialize();
     let item = ExtensionSettingsStore.getSetting(DEFAULT_SEARCH_STORE_TYPE, DEFAULT_SEARCH_SETTING_NAME);
     if (!item) {
       return;
     }
     if (Services.search.currentEngine.name != item.value &&
@@ -86,17 +154,20 @@ this.chrome_settings_overrides = class e
       this.processDefaultSearchSetting("removeSetting", id),
       this.removeEngine(id),
     ]);
   }
 
   static onUninstall(id) {
     // Note: We do not have to deal with homepage here as it is managed by
     // the ExtensionPreferencesManager.
-    return this.removeSearchSettings(id);
+    return Promise.all([
+      this.removeSearchSettings(id),
+      homepagePopup.clearConfirmation(id),
+    ]);
   }
 
   static onUpdate(id, manifest) {
     let haveHomepage = manifest && manifest.chrome_settings_overrides &&
                        manifest.chrome_settings_overrides.homepage;
     if (!haveHomepage) {
       ExtensionPreferencesManager.removeSetting(id, "homepage_override");
     }
@@ -108,19 +179,44 @@ this.chrome_settings_overrides = class e
     }
   }
 
   async onManifestEntry(entryName) {
     let {extension} = this;
     let {manifest} = extension;
 
     await ExtensionSettingsStore.initialize();
-    if (manifest.chrome_settings_overrides.homepage) {
-      ExtensionPreferencesManager.setSetting(extension.id, "homepage_override",
-                                             manifest.chrome_settings_overrides.homepage);
+
+    let homepageUrl = manifest.chrome_settings_overrides.homepage;
+
+    if (homepageUrl) {
+      let inControl;
+      if (extension.startupReason == "ADDON_INSTALL") {
+        inControl = await ExtensionPreferencesManager.setSetting(
+          extension.id, "homepage_override", homepageUrl);
+      } else {
+        let item = await ExtensionPreferencesManager.getSetting("homepage_override");
+        inControl = item.id == extension.id;
+      }
+      // We need to add the listener here too since onPrefsChanged won't trigger on a
+      // restart (the prefs are already set).
+      if (inControl) {
+        if (extension.startupReason == "APP_STARTUP") {
+          handleInitialHomepagePopup(extension.id, homepageUrl);
+        } else {
+          homepagePopup.addObserver(extension.id);
+        }
+      }
+      extension.callOnClose({
+        close: () => {
+          if (extension.shutdownReason == "ADDON_DISABLE") {
+            homepagePopup.clearConfirmation(extension.id);
+          }
+        },
+      });
     }
     if (manifest.chrome_settings_overrides.search_provider) {
       await searchInitialized();
       extension.callOnClose({
         close: () => {
           if (extension.shutdownReason == "ADDON_DISABLE") {
             chrome_settings_overrides.processDefaultSearchSetting("disable", extension.id);
             chrome_settings_overrides.removeEngine(extension.id);
@@ -233,16 +329,28 @@ this.chrome_settings_overrides = class e
       return false;
     }
     return true;
   }
 };
 
 ExtensionPreferencesManager.addSetting("homepage_override", {
   prefNames: [
-    "browser.startup.homepage",
+    HOMEPAGE_PREF,
   ],
+  // ExtensionPreferencesManager will call onPrefsChanged when control changes
+  // and it updates the preferences. We are passed the item from
+  // ExtensionSettingsStore that details what is in control. If there is an id
+  // then control has changed to an extension, if there is no id then control
+  // has been returned to the user.
+  onPrefsChanged(item) {
+    if (item.id) {
+      homepagePopup.addObserver(item.id);
+    } else {
+      homepagePopup.removeObserver();
+    }
+  },
   setCallback(value) {
     return {
-      "browser.startup.homepage": value,
+      [HOMEPAGE_PREF]: value,
     };
   },
 });
--- a/browser/components/extensions/parent/ext-url-overrides.js
+++ b/browser/components/extensions/parent/ext-url-overrides.js
@@ -16,36 +16,16 @@ ChromeUtils.defineModuleGetter(this, "Ex
 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";
 
-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);
-          resolve();
-        }
-      },
-    });
-  });
-  gBrowser.loadURI(url, {
-    flags: Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY,
-  });
-  return loaded;
-}
-
-
 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",
@@ -53,24 +33,24 @@ XPCOMUtils.defineLazyGetter(this, "newTa
     learnMoreMessageId: "newTabControlled.learnMore",
     learnMoreLink: "extension-home",
     onObserverAdded() {
       aboutNewTabService.willNotifyUser = true;
     },
     onObserverRemoved() {
       aboutNewTabService.willNotifyUser = false;
     },
-    async beforeDisableAddon(popup) {
+    async beforeDisableAddon(popup, win) {
       // 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 gBrowser = win.gBrowser;
       let tab = gBrowser.selectedTab;
       await replaceUrlInTab(gBrowser, tab, "about:blank");
       Services.obs.addObserver({
         async observe() {
           await replaceUrlInTab(gBrowser, tab, aboutNewTabService.newTabURL);
           // 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();
--- a/browser/components/extensions/test/browser/browser_ext_chrome_settings_overrides_home.js
+++ b/browser/components/extensions/test/browser/browser_ext_chrome_settings_overrides_home.js
@@ -1,15 +1,17 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 
 "use strict";
 
 ChromeUtils.defineModuleGetter(this, "AddonManager",
                                "resource://gre/modules/AddonManager.jsm");
+ChromeUtils.defineModuleGetter(this, "ExtensionSettingsStore",
+                               "resource://gre/modules/ExtensionSettingsStore.jsm");
 ChromeUtils.defineModuleGetter(this, "Services",
                                "resource://gre/modules/Services.jsm");
 
 // Named this way so they correspond to the extensions
 const HOME_URI_2 = "http://example.com/";
 const HOME_URI_3 = "http://example.org/";
 const HOME_URI_4 = "http://example.net/";
 
@@ -19,16 +21,21 @@ const NOT_CONTROLLABLE = "not_controllab
 
 const HOMEPAGE_URL_PREF = "browser.startup.homepage";
 
 const getHomePageURL = () => {
   return Services.prefs.getComplexValue(
     HOMEPAGE_URL_PREF, Ci.nsIPrefLocalizedString).data;
 };
 
+function isConfirmed(id) {
+  let item = ExtensionSettingsStore.getSetting("homepageNotification", id);
+  return !!(item && item.value);
+}
+
 add_task(async function test_multiple_extensions_overriding_home_page() {
   let defaultHomePage = getHomePageURL();
 
   function background() {
     browser.test.onMessage.addListener(async msg => {
       switch (msg) {
         case "checkHomepage":
           let homepage = await browser.browserSettings.homepageOverride.get({});
@@ -285,8 +292,134 @@ add_task(async function test_multiple() 
   await prefPromise;
 
   is(getHomePageURL(),
      "https://mozilla.org/%7Chttps://developer.mozilla.org/%7Chttps://addons.mozilla.org/",
      "The homepage encodes | so only one homepage is allowed");
 
   await extension.unload();
 });
+
+add_task(async function test_doorhanger_homepage_button() {
+  let defaultHomePage = getHomePageURL();
+  // These extensions are temporarily loaded so that the AddonManager can see
+  // them and the extension's shutdown handlers are called.
+  let ext1 = ExtensionTestUtils.loadExtension({
+    manifest: {"chrome_settings_overrides": {"homepage": "ext1.html"}},
+    files: {"ext1.html": "<h1>1</h1>"},
+    useAddonManager: "temporary",
+  });
+  let ext2 = ExtensionTestUtils.loadExtension({
+    manifest: {"chrome_settings_overrides": {"homepage": "ext2.html"}},
+    files: {"ext2.html": "<h1>2</h1>"},
+    useAddonManager: "temporary",
+  });
+
+  let panel = document.getElementById("extension-notification-panel");
+  let popupnotification = document.getElementById("extension-homepage-notification");
+
+  await ext1.startup();
+  await ext2.startup();
+
+  let popupShown = promisePopupShown(panel);
+  BrowserGoHome();
+  await BrowserTestUtils.browserLoaded(gBrowser.selectedBrowser);
+  await popupShown;
+
+  ok(gURLBar.value.endsWith("ext2.html"), "ext2 is in control");
+
+  // Click Restore Settings.
+  let popupHidden = promisePopupHidden(panel);
+  let prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF);
+  document.getAnonymousElementByAttribute(
+    popupnotification, "anonid", "secondarybutton").click();
+  await prefPromise;
+  await popupHidden;
+
+  // Expect a new doorhanger for the next extension.
+  await promisePopupShown(panel);
+
+  ok(gURLBar.value.endsWith("ext1.html"), "ext1 is in control");
+
+  // Click Restore Settings again.
+  popupHidden = promisePopupHidden(panel);
+  prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF);
+  document.getAnonymousElementByAttribute(popupnotification, "anonid", "secondarybutton").click();
+  await popupHidden;
+  await prefPromise;
+
+  is(getHomePageURL(), defaultHomePage, "The homepage is set back to default");
+
+  await ext1.unload();
+  await ext2.unload();
+});
+
+add_task(async function test_doorhanger_new_window() {
+  // These extensions are temporarily loaded so that the AddonManager can see
+  // them and the extension's shutdown handlers are called.
+  let ext1Id = "ext1@mochi.test";
+  let ext1 = ExtensionTestUtils.loadExtension({
+    manifest: {
+      chrome_settings_overrides: {"homepage": "ext1.html"},
+      applications: {
+        gecko: {id: ext1Id},
+      },
+      name: "Ext1",
+    },
+    files: {"ext1.html": "<h1>1</h1>"},
+    useAddonManager: "temporary",
+  });
+  let ext2 = ExtensionTestUtils.loadExtension({
+    manifest: {
+      chrome_settings_overrides: {homepage: "ext2.html"},
+      name: "Ext2",
+    },
+    files: {"ext2.html": "<h1>2</h1>"},
+    useAddonManager: "temporary",
+  });
+
+  await ext1.startup();
+  await ext2.startup();
+
+  await SpecialPowers.pushPrefEnv({set: [["browser.startup.page", 1]]});
+
+  let windowOpenedPromise = BrowserTestUtils.waitForNewWindow();
+  let win = OpenBrowserWindow();
+  await windowOpenedPromise;
+  await BrowserTestUtils.browserLoaded(win.gBrowser.selectedBrowser);
+  let doc = win.document;
+  let description = doc.getElementById("extension-homepage-notification-description");
+  let panel = doc.getElementById("extension-notification-panel");
+  await promisePopupShown(panel);
+
+  ok(win.gURLBar.value.endsWith("ext2.html"), "ext2 is in control");
+  is(description.textContent,
+     "An extension,  Ext2, changed what you see when you open your homepage and new windows.Learn more",
+     "The extension name is in the popup");
+
+  // Click Restore Settings.
+  let popupHidden = promisePopupHidden(panel);
+  let prefPromise = promisePrefChangeObserved(HOMEPAGE_URL_PREF);
+  let popupnotification = doc.getElementById("extension-homepage-notification");
+  doc.getAnonymousElementByAttribute(popupnotification, "anonid", "secondarybutton").click();
+  await prefPromise;
+  await popupHidden;
+
+  // Expect a new doorhanger for the next extension.
+  await promisePopupShown(panel);
+
+  ok(win.gURLBar.value.endsWith("ext1.html"), "ext1 is in control");
+  is(description.textContent,
+     "An extension,  Ext1, changed what you see when you open your homepage and new windows.Learn more",
+     "The extension name is in the popup");
+
+  // Click Keep Changes.
+  doc.getAnonymousElementByAttribute(popupnotification, "anonid", "button").click();
+  await TestUtils.waitForCondition(() => isConfirmed(ext1Id));
+
+  ok(getHomePageURL().endsWith("ext1.html"), "The homepage is still the set");
+
+  await BrowserTestUtils.closeWindow(win);
+  await ext1.unload();
+  await ext2.unload();
+
+  ok(!isConfirmed(ext1Id), "The confirmation is cleaned up on uninstall");
+});
--- a/browser/locales/en-US/chrome/browser/browser.dtd
+++ b/browser/locales/en-US/chrome/browser/browser.dtd
@@ -979,16 +979,23 @@ you can use these alternative items. Oth
 <!ENTITY updateRestart.panelUI.label2 "Restart to update &brandShorterName;">
 
 <!ENTITY newTabControlled.header.message "Your New Tab has changed.">
 <!ENTITY newTabControlled.keepButton.label "Keep Changes">
 <!ENTITY newTabControlled.keepButton.accesskey "K">
 <!ENTITY newTabControlled.disableButton.label "Disable Extension">
 <!ENTITY newTabControlled.disableButton.accesskey "D">
 
+<!ENTITY homepageControlled.message "An extension has changed what you see as your home page. You can restore your settings if you do not want this change.">
+<!ENTITY homepageControlled.header.message "Your home page has changed.">
+<!ENTITY homepageControlled.keepButton.label "Keep Changes">
+<!ENTITY homepageControlled.keepButton.accesskey "K">
+<!ENTITY homepageControlled.disableButton.label "Disable Extension">
+<!ENTITY homepageControlled.disableButton.accesskey "D">
+
 <!ENTITY pageActionButton.tooltip "Page actions">
 <!ENTITY pageAction.addToUrlbar.label "Add to Address Bar">
 <!ENTITY pageAction.removeFromUrlbar.label "Remove from Address Bar">
 <!ENTITY pageAction.allowInUrlbar.label "Show in Address Bar">
 <!ENTITY pageAction.disallowInUrlbar.label "Don’t Show in Address Bar">
 <!ENTITY pageAction.manageExtension.label "Manage Extension…">
 
 <!ENTITY pageAction.sendTabToDevice.label "Send Tab to Device">
--- a/toolkit/components/extensions/ExtensionPreferencesManager.jsm
+++ b/toolkit/components/extensions/ExtensionPreferencesManager.jsm
@@ -76,30 +76,39 @@ function initialValueCallback() {
   return initialValue;
 }
 
 /**
  * Loops through a set of prefs, either setting or resetting them.
  *
  * @param {Object} setting
  *        An object that represents a setting, which will have a setCallback
- *        property.
+ *        property. If a onPrefsChanged function is provided it will be called
+ *        with item when the preferences change.
  * @param {Object} item
  *        An object that represents an item handed back from the setting store
  *        from which the new pref values can be calculated.
 */
 function setPrefs(setting, item) {
   let prefs = item.initialValue || setting.setCallback(item.value);
+  let changed = false;
   for (let pref in prefs) {
     if (prefs[pref] === undefined) {
-      Preferences.reset(pref);
-    } else {
+      if (Preferences.isSet(pref)) {
+        changed = true;
+        Preferences.reset(pref);
+      }
+    } else if (Preferences.get(pref) != prefs[pref]) {
       Preferences.set(pref, prefs[pref]);
+      changed = true;
     }
   }
+  if (changed && typeof setting.onPrefsChanged == "function") {
+    setting.onPrefsChanged(item);
+  }
 }
 
 /**
  * Commits a change to a setting and conditionally sets preferences.
  *
  * If the change to the setting causes a different extension to gain
  * control of the pref (or removes all extensions with control over the pref)
  * then the prefs should be updated, otherwise they should not be.
--- a/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js
+++ b/toolkit/components/extensions/test/xpcshell/test_ext_extensionPreferencesManager.js
@@ -16,16 +16,18 @@ const {
   promiseShutdownManager,
   promiseStartupManager,
 } = AddonTestUtils;
 
 AddonTestUtils.init(this);
 
 createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "42");
 
+let lastSetPref;
+
 const STORE_TYPE = "prefs";
 
 // Test settings to use with the preferences manager.
 const SETTINGS = {
   multiple_prefs: {
     prefNames: ["my.pref.1", "my.pref.2", "my.pref.3"],
 
     initalValues: ["value1", "value2", "value3"],
@@ -45,16 +47,20 @@ const SETTINGS = {
 
   singlePref: {
     prefNames: [
       "my.single.pref",
     ],
 
     initalValues: ["value1"],
 
+    onPrefsChanged(item) {
+      lastSetPref = item;
+    },
+
     valueFn(pref, value) {
       return value;
     },
 
     setCallback(value) {
       return {[this.prefNames[0]]: this.valueFn(null, value)};
     },
   },
@@ -72,16 +78,25 @@ for (let setting in SETTINGS) {
 }
 
 function checkPrefs(settingObj, value, msg) {
   for (let pref of settingObj.prefNames) {
     equal(Preferences.get(pref), settingObj.valueFn(pref, value), msg);
   }
 }
 
+function checkOnPrefsChanged(setting, value, msg) {
+  if (value) {
+    deepEqual(lastSetPref, value, msg);
+    lastSetPref = null;
+  } else {
+    ok(!lastSetPref, msg);
+  }
+}
+
 add_task(async function test_preference_manager() {
   await promiseStartupManager();
 
   // Create an array of test framework extension wrappers to install.
   let testExtensions = [
     ExtensionTestUtils.loadExtension({
       useAddonManager: "temporary",
       manifest: {},
@@ -100,76 +115,124 @@ add_task(async function test_preference_
   // test framework extension wrappers.
   let extensions = testExtensions.map(extension => extension.extension);
 
   for (let setting in SETTINGS) {
     let settingObj = SETTINGS[setting];
     let newValue1 = "newValue1";
     let levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(
       extensions[1].id, setting);
+    if (settingObj.onPrefsChanged) {
+      checkOnPrefsChanged(setting, null, "onPrefsChanged has not been called yet");
+    }
     equal(levelOfControl, "controllable_by_this_extension",
           "getLevelOfControl returns correct levelOfControl with no settings set.");
 
     let prefsChanged = await ExtensionPreferencesManager.setSetting(
       extensions[1].id, setting, newValue1);
     ok(prefsChanged, "setSetting returns true when the pref(s) have been set.");
     checkPrefs(settingObj, newValue1,
                "setSetting sets the prefs for the first extension.");
+    if (settingObj.onPrefsChanged) {
+      checkOnPrefsChanged(
+        setting, {id: extensions[1].id, value: newValue1, key: setting},
+        "onPrefsChanged is called when pref changes");
+    }
     levelOfControl = await ExtensionPreferencesManager.getLevelOfControl(extensions[1].id, setting);
     equal(
       levelOfControl,
       "controlled_by_this_extension",
       "getLevelOfControl returns correct levelOfControl when a pref has been set.");
 
     let checkSetting = await ExtensionPreferencesManager.getSetting(setting);
     equal(checkSetting.value, newValue1, "getSetting returns the expected value.");
 
     let newValue2 = "newValue2";
     prefsChanged = await ExtensionPreferencesManager.setSetting(extensions[0].id, setting, newValue2);
     ok(!prefsChanged, "setSetting returns false when the pref(s) have not been set.");
     checkPrefs(settingObj, newValue1,
                "setSetting does not set the pref(s) for an earlier extension.");
+    if (settingObj.onPrefsChanged) {
+      checkOnPrefsChanged(
+        setting, null, "onPrefsChanged isn't called without control change");
+    }
 
     prefsChanged = await ExtensionPreferencesManager.disableSetting(extensions[0].id, setting);
     ok(!prefsChanged, "disableSetting returns false when the pref(s) have not been set.");
     checkPrefs(settingObj, newValue1,
                "disableSetting does not change the pref(s) for the non-top extension.");
+    if (settingObj.onPrefsChanged) {
+      checkOnPrefsChanged(
+        setting, null, "onPrefsChanged isn't called without control change on disable");
+    }
 
     prefsChanged = await ExtensionPreferencesManager.enableSetting(extensions[0].id, setting);
     ok(!prefsChanged, "enableSetting returns false when the pref(s) have not been set.");
     checkPrefs(settingObj, newValue1,
                "enableSetting does not change the pref(s) for the non-top extension.");
+    if (settingObj.onPrefsChanged) {
+      checkOnPrefsChanged(
+        setting, null, "onPrefsChanged isn't called without control change on enable");
+    }
 
     prefsChanged = await ExtensionPreferencesManager.removeSetting(extensions[0].id, setting);
     ok(!prefsChanged, "removeSetting returns false when the pref(s) have not been set.");
     checkPrefs(settingObj, newValue1,
                "removeSetting does not change the pref(s) for the non-top extension.");
+    if (settingObj.onPrefsChanged) {
+      checkOnPrefsChanged(
+        setting, null, "onPrefsChanged isn't called without control change on remove");
+    }
 
     prefsChanged = await ExtensionPreferencesManager.setSetting(extensions[0].id, setting, newValue2);
     ok(!prefsChanged, "setSetting returns false when the pref(s) have not been set.");
     checkPrefs(settingObj, newValue1,
                "setSetting does not set the pref(s) for an earlier extension.");
+    if (settingObj.onPrefsChanged) {
+      checkOnPrefsChanged(
+        setting, null, "onPrefsChanged isn't called without control change again");
+    }
 
     prefsChanged = await ExtensionPreferencesManager.disableSetting(extensions[1].id, setting);
     ok(prefsChanged, "disableSetting returns true when the pref(s) have been set.");
     checkPrefs(settingObj, newValue2,
                "disableSetting sets the pref(s) to the next value when disabling the top extension.");
+    if (settingObj.onPrefsChanged) {
+      checkOnPrefsChanged(
+        setting, {id: extensions[0].id, key: setting, value: newValue2},
+        "onPrefsChanged is called when control changes on disable");
+    }
 
     prefsChanged = await ExtensionPreferencesManager.enableSetting(extensions[1].id, setting);
     ok(prefsChanged, "enableSetting returns true when the pref(s) have been set.");
     checkPrefs(settingObj, newValue1,
                "enableSetting sets the pref(s) to the previous value(s).");
+    if (settingObj.onPrefsChanged) {
+      checkOnPrefsChanged(
+        setting, {id: extensions[1].id, key: setting, value: newValue1},
+        "onPrefsChanged is called when control changes on enable");
+    }
 
     prefsChanged = await ExtensionPreferencesManager.removeSetting(extensions[1].id, setting);
     ok(prefsChanged, "removeSetting returns true when the pref(s) have been set.");
     checkPrefs(settingObj, newValue2,
                "removeSetting sets the pref(s) to the next value when removing the top extension.");
+    if (settingObj.onPrefsChanged) {
+      checkOnPrefsChanged(
+        setting, {id: extensions[0].id, key: setting, value: newValue2},
+        "onPrefsChanged is called when control changes on remove");
+    }
 
     prefsChanged = await ExtensionPreferencesManager.removeSetting(extensions[0].id, setting);
     ok(prefsChanged, "removeSetting returns true when the pref(s) have been set.");
+    if (settingObj.onPrefsChanged) {
+      checkOnPrefsChanged(
+        setting, {key: setting, initialValue: {"my.single.pref": "value1"}},
+        "onPrefsChanged is called when control is entirely removed");
+    }
     for (let i = 0; i < settingObj.prefNames.length; i++) {
       equal(Preferences.get(settingObj.prefNames[i]), settingObj.initalValues[i],
             "removeSetting sets the pref(s) to the initial value(s) when removing the last extension.");
     }
 
     checkSetting = await ExtensionPreferencesManager.getSetting(setting);
     equal(checkSetting, null, "getSetting returns null when nothing has been set.");
   }
--- a/toolkit/locales/en-US/chrome/global/extensions.properties
+++ b/toolkit/locales/en-US/chrome/global/extensions.properties
@@ -28,8 +28,12 @@ uninstall.confirmation.message = The extension “%S” is requesting to be uninstalled. What would you like to do?
 uninstall.confirmation.button-0.label = Uninstall
 uninstall.confirmation.button-1.label = Keep Installed
 
 saveaspdf.saveasdialog.title = Save As
 
 #LOCALIZATION NOTE (newTabControlled.message2) %S is the icon and name of the extension which updated the New Tab page.
 newTabControlled.message2 = An extension, %S, changed the page you see when you open a new tab.
 newTabControlled.learnMore = Learn more
+
+#LOCALIZATION NOTE (homepageControlled.message) %S is the icon and name of the extension which updated the homepage.
+homepageControlled.message = An extension, %S, changed what you see when you open your homepage and new windows.
+homepageControlled.learnMore = Learn more