--- 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,