Bug 1317363 Implement the new sideloading flow r=florian,rhelmer
authorAndrew Swan <aswan@mozilla.com>
Wed, 18 Jan 2017 18:16:19 -0800
changeset 377300 7aa76c31395d239acef1a28ee03d18849ec4dee7
parent 377299 77dc13c9bb350d1f9ee4b16f50cf3c3bb3dabebb
child 377301 b172f492f29ff072fe0c16b5d21856222f65bdd7
push id1419
push userjlund@mozilla.com
push dateMon, 10 Apr 2017 20:44:07 +0000
treeherdermozilla-release@5e6801b73ef6 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersflorian, rhelmer
bugs1317363
milestone53.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 1317363 Implement the new sideloading flow r=florian,rhelmer MozReview-Commit-ID: JgloWKYAhlK
browser/base/content/browser-addons.js
browser/base/content/browser.js
browser/base/content/popup-notifications.inc
browser/base/content/test/general/browser.ini
browser/base/content/test/general/browser_extension_permissions.js
browser/base/content/test/general/browser_extension_sideloading.js
browser/components/customizableui/content/panelUI.inc.xul
browser/components/nsBrowserGlue.js
browser/modules/ExtensionsUI.jsm
browser/themes/shared/addons/addon-badge.svg
browser/themes/shared/customizableui/panelUI.inc.css
browser/themes/shared/jar.inc.mn
toolkit/mozapps/extensions/internal/XPIProviderUtils.js
--- a/browser/base/content/browser-addons.js
+++ b/browser/base/content/browser-addons.js
@@ -464,16 +464,72 @@ const gXPInstallObserver = {
   },
   _removeProgressNotification(aBrowser) {
     let notification = PopupNotifications.getNotification("addon-progress", aBrowser);
     if (notification)
       notification.remove();
   }
 };
 
+const gExtensionsNotifications = {
+  initialized: false,
+  init() {
+    this.updateAlerts();
+    this.boundUpdate = this.updateAlerts.bind(this);
+    ExtensionsUI.on("change", this.boundUpdate);
+    this.initialized = true;
+  },
+
+  uninit() {
+    // uninit() can race ahead of init() in some cases, if that happens,
+    // we have no handler to remove.
+    if (!this.initialized) {
+      return;
+    }
+    ExtensionsUI.off("change", this.boundUpdate);
+  },
+
+  updateAlerts() {
+    let sideloaded = ExtensionsUI.sideloaded;
+    if (sideloaded.size == 0) {
+      gMenuButtonBadgeManager.removeBadge(gMenuButtonBadgeManager.BADGEID_ADDONS);
+    } else {
+      gMenuButtonBadgeManager.addBadge(gMenuButtonBadgeManager.BADGEID_ADDONS,
+                                       "addon-alert");
+    }
+
+    let container = document.getElementById("PanelUI-footer-addons");
+
+    while (container.firstChild) {
+      container.firstChild.remove();
+    }
+
+    // Strings below to be properly localized in bug 1316996
+    const DEFAULT_EXTENSION_ICON =
+      "chrome://mozapps/skin/extensions/extensionGeneric.svg";
+    let items = 0;
+    for (let addon of sideloaded) {
+      if (++items > 4) {
+        break;
+      }
+      let button = document.createElement("toolbarbutton");
+      button.setAttribute("label", `"${addon.name}" added to Firefox`);
+
+      let icon = addon.iconURL || DEFAULT_EXTENSION_ICON;
+      button.setAttribute("image", icon);
+
+      button.addEventListener("click", evt => {
+        ExtensionsUI.showSideloaded(gBrowser, addon);
+      });
+
+      container.appendChild(button);
+    }
+  },
+};
+
 var LightWeightThemeWebInstaller = {
   init() {
     let mm = window.messageManager;
     mm.addMessageListener("LightWeightThemeWebInstaller:Install", this);
     mm.addMessageListener("LightWeightThemeWebInstaller:Preview", this);
     mm.addMessageListener("LightWeightThemeWebInstaller:ResetPreview", this);
   },
 
--- a/browser/base/content/browser.js
+++ b/browser/base/content/browser.js
@@ -42,16 +42,17 @@ Cu.import("resource://gre/modules/Notifi
   ["BrowserUsageTelemetry", "resource:///modules/BrowserUsageTelemetry.jsm"],
   ["BrowserUtils", "resource://gre/modules/BrowserUtils.jsm"],
   ["CastingApps", "resource:///modules/CastingApps.jsm"],
   ["CharsetMenu", "resource://gre/modules/CharsetMenu.jsm"],
   ["Color", "resource://gre/modules/Color.jsm"],
   ["ContentSearch", "resource:///modules/ContentSearch.jsm"],
   ["Deprecated", "resource://gre/modules/Deprecated.jsm"],
   ["E10SUtils", "resource:///modules/E10SUtils.jsm"],
+  ["ExtensionsUI", "resource:///modules/ExtensionsUI.jsm"],
   ["FormValidationHandler", "resource:///modules/FormValidationHandler.jsm"],
   ["GMPInstallManager", "resource://gre/modules/GMPInstallManager.jsm"],
   ["LightweightThemeManager", "resource://gre/modules/LightweightThemeManager.jsm"],
   ["Log", "resource://gre/modules/Log.jsm"],
   ["LoginManagerParent", "resource://gre/modules/LoginManagerParent.jsm"],
   ["NewTabUtils", "resource://gre/modules/NewTabUtils.jsm"],
   ["PageThumbs", "resource://gre/modules/PageThumbs.jsm"],
   ["PluralForm", "resource://gre/modules/PluralForm.jsm"],
@@ -1367,16 +1368,18 @@ var gBrowserInit = {
       gDataNotificationInfoBar.init();
 
     gBrowserThumbnails.init();
 
     gMenuButtonBadgeManager.init();
 
     gMenuButtonUpdateBadge.init();
 
+    gExtensionsNotifications.init();
+
     window.addEventListener("mousemove", MousePosTracker);
     window.addEventListener("dragover", MousePosTracker);
 
     gNavToolbox.addEventListener("customizationstarting", CustomizationHandler);
     gNavToolbox.addEventListener("customizationchange", CustomizationHandler);
     gNavToolbox.addEventListener("customizationending", CustomizationHandler);
 
     // End startup crash tracking after a delay to catch crashes while restoring
@@ -1496,16 +1499,18 @@ var gBrowserInit = {
     gGestureSupport.init(false);
 
     gHistorySwipeAnimation.uninit();
 
     FullScreen.uninit();
 
     gFxAccounts.uninit();
 
+    gExtensionsNotifications.uninit();
+
     Services.obs.removeObserver(gPluginHandler.NPAPIPluginCrashed, "plugin-crashed");
 
     try {
       gBrowser.removeProgressListener(window.XULBrowserWindow);
       gBrowser.removeTabsProgressListener(window.TabsProgressListener);
     } catch (ex) {
     }
 
@@ -2571,52 +2576,56 @@ function PageProxyClickHandler(aEvent) {
   if (aEvent.button == 1 && gPrefService.getBoolPref("middlemouse.paste"))
     middleMousePaste(aEvent);
 }
 
 var gMenuButtonBadgeManager = {
   BADGEID_APPUPDATE: "update",
   BADGEID_DOWNLOAD: "download",
   BADGEID_FXA: "fxa",
+  BADGEID_ADDONS: "addons",
 
   fxaBadge: null,
   downloadBadge: null,
   appUpdateBadge: null,
+  addonsBadge: null,
 
   init() {
     PanelUI.panel.addEventListener("popupshowing", this, true);
   },
 
   uninit() {
     PanelUI.panel.removeEventListener("popupshowing", this, true);
   },
 
   handleEvent(e) {
     if (e.type === "popupshowing") {
       this.clearBadges();
     }
   },
 
   _showBadge() {
-    let badgeToShow = this.downloadBadge || this.appUpdateBadge || this.fxaBadge;
+    let badgeToShow = this.downloadBadge || this.appUpdateBadge || this.fxaBadge || this.addonsBadge;
 
     if (badgeToShow) {
       PanelUI.menuButton.setAttribute("badge-status", badgeToShow);
     } else {
       PanelUI.menuButton.removeAttribute("badge-status");
     }
   },
 
   _changeBadge(badgeId, badgeStatus = null) {
     if (badgeId == this.BADGEID_APPUPDATE) {
       this.appUpdateBadge = badgeStatus;
     } else if (badgeId == this.BADGEID_DOWNLOAD) {
       this.downloadBadge = badgeStatus;
     } else if (badgeId == this.BADGEID_FXA) {
       this.fxaBadge = badgeStatus;
+    } else if (badgeId == this.BADGEID_ADDONS) {
+      this.addonsBadge = badgeStatus;
     } else {
       Cu.reportError("The badge ID '" + badgeId + "' is unknown!");
     }
     this._showBadge();
   },
 
   addBadge(badgeId, badgeStatus) {
     if (!badgeStatus) {
--- a/browser/base/content/popup-notifications.inc
+++ b/browser/base/content/popup-notifications.inc
@@ -70,12 +70,13 @@
 
     <popupnotification id="addon-install-confirmation-notification" hidden="true">
       <popupnotificationcontent id="addon-install-confirmation-content" orient="vertical"/>
     </popupnotification>
 
     <popupnotification id="addon-webext-permissions-notification" hidden="true">
       <popupnotificationcontent orient="vertical">
         <description id="addon-webext-perm-header" class="addon-webext-perm-header"/>
-        <label id="addon-webext-perm-text" class="addon-webext-perm-text"/>
+        <description id="addon-webext-perm-text" class="addon-webext-perm-text"/>
+        <label id="addon-webext-perm-intro" class="addon-webext-perm-text"/>
         <html:ul id="addon-webext-perm-list" class="addon-webext-perm-list"/>
       </popupnotificationcontent>
     </popupnotification>
--- a/browser/base/content/test/general/browser.ini
+++ b/browser/base/content/test/general/browser.ini
@@ -300,16 +300,17 @@ skip-if = !datareporting
 skip-if = os == "mac" # decoder doctor isn't implemented on osx
 [browser_discovery.js]
 [browser_double_close_tab.js]
 [browser_documentnavigation.js]
 [browser_duplicateIDs.js]
 [browser_drag.js]
 skip-if = true # browser_drag.js is disabled, as it needs to be updated for the new behavior from bug 320638.
 [browser_extension_permissions.js]
+[browser_extension_sideloading.js]
 [browser_favicon_change.js]
 [browser_favicon_change_not_in_document.js]
 [browser_findbarClose.js]
 [browser_focusonkeydown.js]
 [browser_fullscreen-window-open.js]
 tags = fullscreen
 skip-if = os == "linux" # Linux: Intermittent failures - bug 941575.
 [browser_fxaccounts.js]
--- a/browser/base/content/test/general/browser_extension_permissions.js
+++ b/browser/base/content/test/general/browser_extension_permissions.js
@@ -34,23 +34,18 @@ function promiseGetAddonByID(id) {
   return new Promise(resolve => {
     AddonManager.getAddonByID(id, resolve);
   });
 }
 
 function checkNotification(panel, url) {
   let icon = panel.getAttribute("icon");
 
-  let uls = panel.firstChild.getElementsByTagNameNS("http://www.w3.org/1999/xhtml", "ul");
-  is(uls.length, 1, "Found the permissions list");
-  let ul = uls[0];
-
-  let headers = panel.firstChild.getElementsByClassName("addon-webext-perm-text");
-  is(headers.length, 1, "Found the header");
-  let header = headers[0];
+  let ul = document.getElementById("addon-webext-perm-list");
+  let header = document.getElementById("addon-webext-perm-intro");
 
   if (url == PERMS_XPI) {
     // The icon should come from the extension, don't bother with the precise
     // path, just make sure we've got a jar url pointing to the right path
     // inside the jar.
     ok(icon.startsWith("jar:file://"), "Icon is a jar url");
     ok(icon.endsWith("/icon.png"), "Icon is icon.png inside a jar");
 
new file mode 100644
--- /dev/null
+++ b/browser/base/content/test/general/browser_extension_sideloading.js
@@ -0,0 +1,248 @@
+const {AddonManagerPrivate} = Cu.import("resource://gre/modules/AddonManager.jsm", {});
+
+// MockAddon mimics the AddonInternal interface and MockProvider implements
+// just enough of the AddonManager provider interface to make it look like
+// we have sideloaded webextensions so the sideloading flow can be tested.
+
+// MockAddon -> callback
+let setCallbacks = new Map();
+
+class MockAddon {
+  constructor(props) {
+    this._userDisabled = false;
+    this.pendingOperations = 0;
+    this.type = "extension";
+
+    for (let name in props) {
+      if (name == "userDisabled") {
+        this._userDisabled = props[name];
+      }
+      this[name] = props[name];
+    }
+  }
+
+  markAsSeen() {
+    this.seen = true;
+  }
+
+  get userDisabled() {
+    return this._userDisabled;
+  }
+
+  set userDisabled(val) {
+    this._userDisabled = val;
+    let fn = setCallbacks.get(this);
+    if (fn) {
+      setCallbacks.delete(this);
+      fn(val);
+    }
+    return val;
+  }
+
+  get permissions() {
+    return this._userDisabled ? AddonManager.PERM_CAN_ENABLE : AddonManager.PERM_CAN_DISABLE;
+  }
+}
+
+class MockProvider {
+  constructor(...addons) {
+    this.addons = new Set(addons);
+  }
+
+  startup() { }
+  shutdown() { }
+
+  getAddonByID(id, callback) {
+    for (let addon of this.addons) {
+      if (addon.id == id) {
+        callback(addon);
+        return;
+      }
+    }
+    callback(null);
+  }
+
+  getAddonsByTypes(types, callback) {
+    let addons = [];
+    if (!types || types.includes("extension")) {
+      addons = [...this.addons];
+    }
+    callback(addons);
+  }
+}
+
+function promiseViewLoaded(tab, viewid) {
+  let win = tab.linkedBrowser.contentWindow;
+  if (win.gViewController && !win.gViewController.isLoading &&
+      win.gViewController.currentViewId == viewid) {
+     return Promise.resolve();
+  }
+
+  return new Promise(resolve => {
+    function listener() {
+      if (win.gViewController.currentViewId != viewid) {
+        return;
+      }
+      win.document.removeEventListener("ViewChanged", listener);
+      resolve();
+    }
+    win.document.addEventListener("ViewChanged", listener);
+  });
+}
+
+function promisePopupNotificationShown(name) {
+  return new Promise(resolve => {
+    function popupshown() {
+      let notification = PopupNotifications.getNotification(name);
+      if (!notification) {
+        return;
+      }
+
+      ok(notification, `${name} notification shown`);
+      ok(PopupNotifications.isPanelOpen, "notification panel open");
+
+      PopupNotifications.panel.removeEventListener("popupshown", popupshown);
+      resolve(PopupNotifications.panel.firstChild);
+    }
+
+    PopupNotifications.panel.addEventListener("popupshown", popupshown);
+  });
+}
+
+function promiseSetDisabled(addon) {
+  return new Promise(resolve => {
+    setCallbacks.set(addon, resolve);
+  });
+}
+
+add_task(function* () {
+  // XXX remove this when prompts are enabled by default
+  yield SpecialPowers.pushPrefEnv({set: [
+    ["extensions.webextPermissionPrompts", true],
+  ]});
+
+  const ID1 = "addon1@tests.mozilla.org";
+  let mock1 = new MockAddon({
+    id: ID1,
+    name: "Test 1",
+    userDisabled: true,
+    seen: false,
+    userPermissions: {
+      permissions: ["history"],
+      hosts: ["https://*/*"],
+    },
+  });
+
+  const ID2 = "addon2@tests.mozilla.org";
+  let mock2 = new MockAddon({
+    id: ID2,
+    name: "Test 2",
+    userDisabled: true,
+    seen: false,
+    userPermissions: {
+      permissions: [],
+      hosts: [],
+    },
+  });
+
+  let provider = new MockProvider(mock1, mock2);
+  AddonManagerPrivate.registerProvider(provider, [{
+    id: "extension",
+    name: "Extensions",
+    uiPriority: 4000,
+    flags: AddonManager.TYPE_UI_VIEW_LIST |
+           AddonManager.TYPE_SUPPORTS_UNDO_RESTARTLESS_UNINSTALL,
+  }]);
+  registerCleanupFunction(function*() {
+    AddonManagerPrivate.unregisterProvider(provider);
+
+    // clear out ExtensionsUI state about sideloaded extensions so
+    // subsequent tests don't get confused.
+    ExtensionsUI.sideloaded.clear();
+    ExtensionsUI.emit("change");
+  });
+
+  let changePromise = new Promise(resolve => {
+    ExtensionsUI.on("change", function listener() {
+      ExtensionsUI.off("change", listener);
+      resolve();
+    });
+  });
+  ExtensionsUI._checkForSideloaded();
+  yield changePromise;
+
+  // Check for the addons badge on the hamburger menu
+  let menuButton = document.getElementById("PanelUI-menu-button");
+  is(menuButton.getAttribute("badge-status"), "addon-alert", "Should have addon alert badge");
+
+  // Find the menu entries for sideloaded extensions
+  yield PanelUI.show();
+
+  let addons = document.getElementById("PanelUI-footer-addons");
+  is(addons.children.length, 2, "Have 2 menu entries for sideloaded extensions");
+
+  // Click the first sideloaded extension
+  let tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, "about:addons");
+  let popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+  addons.children[0].click();
+
+  // about:addons should load and go to the list of extensions
+  let tab = yield tabPromise;
+  is(tab.linkedBrowser.currentURI.spec, "about:addons", "Newly opened tab is at about:addons");
+
+  const VIEW = "addons://list/extension";
+  yield promiseViewLoaded(tab, VIEW);
+  let win = tab.linkedBrowser.contentWindow;
+  ok(!win.gViewController.isLoading, "about:addons view is fully loaded");
+  is(win.gViewController.currentViewId, VIEW, "about:addons is at extensions list");
+
+  // Wait for the permission prompt and cancel it
+  let panel = yield popupPromise;
+  let disablePromise = promiseSetDisabled(mock1);
+  panel.secondaryButton.click();
+
+  let value = yield disablePromise;
+  is(value, true, "Addon should remain disabled");
+
+  let [addon1, addon2] = yield AddonManager.getAddonsByIDs([ID1, ID2]);
+  ok(addon1.seen, "Addon should be marked as seen");
+  is(addon1.userDisabled, true, "Addon 1 should still be disabled");
+  is(addon2.userDisabled, true, "Addon 2 should still be disabled");
+
+  yield BrowserTestUtils.removeTab(tab);
+
+  // Should still have 1 entry in the hamburger menu
+  yield PanelUI.show();
+
+  addons = document.getElementById("PanelUI-footer-addons");
+  is(addons.children.length, 1, "Have 1 menu entry for sideloaded extensions");
+
+  // Click the second sideloaded extension
+  tabPromise = BrowserTestUtils.waitForNewTab(gBrowser, "about:addons");
+  popupPromise = promisePopupNotificationShown("addon-webext-permissions");
+  addons.children[0].click();
+
+  tab = yield tabPromise;
+  is(tab.linkedBrowser.currentURI.spec, "about:addons", "Newly opened tab is at about:addons");
+
+  isnot(menuButton.getAttribute("badge-status"), "addon-alert", "Should no longer have addon alert badge");
+
+  yield promiseViewLoaded(tab, VIEW);
+  win = tab.linkedBrowser.contentWindow;
+  ok(!win.gViewController.isLoading, "about:addons view is fully loaded");
+  is(win.gViewController.currentViewId, VIEW, "about:addons is at extensions list");
+
+  // Wait for the permission prompt and accept it this time
+  panel = yield popupPromise;
+  disablePromise = promiseSetDisabled(mock2);
+  panel.button.click();
+
+  value = yield disablePromise;
+  is(value, false, "Addon should be set to enabled");
+
+  [addon1, addon2] = yield AddonManager.getAddonsByIDs([ID1, ID2]);
+  is(addon1.userDisabled, true, "Addon 1 should still be disabled");
+  is(addon2.userDisabled, false, "Addon 2 should now be enabled");
+
+  yield BrowserTestUtils.removeTab(tab);
+});
--- a/browser/components/customizableui/content/panelUI.inc.xul
+++ b/browser/components/customizableui/content/panelUI.inc.xul
@@ -11,16 +11,17 @@
        noautofocus="true">
   <panelmultiview id="PanelUI-multiView" mainViewId="PanelUI-mainView">
     <panelview id="PanelUI-mainView" context="customizationPanelContextMenu">
       <vbox id="PanelUI-contents-scroller">
         <vbox id="PanelUI-contents" class="panelUI-grid"/>
       </vbox>
 
       <footer id="PanelUI-footer">
+        <vbox id="PanelUI-footer-addons"></vbox>
         <toolbarbutton id="PanelUI-update-status"
                        oncommand="gMenuButtonUpdateBadge.onMenuPanelCommand(event);"
                        wrap="true"
                        hidden="true"/>
         <hbox id="PanelUI-footer-fxa">
           <hbox id="PanelUI-fxa-status"
                 defaultlabel="&fxaSignIn.label;"
                 signedinTooltiptext="&fxaSignedIn.tooltip;"
--- a/browser/components/nsBrowserGlue.js
+++ b/browser/components/nsBrowserGlue.js
@@ -1056,37 +1056,16 @@ BrowserGlue.prototype = {
       this._showUpdateNotification();
 
     // Load the "more info" page for a locked places.sqlite
     // This property is set earlier by places-database-locked topic.
     if (this._isPlacesDatabaseLocked) {
       this._showPlacesLockedNotificationBox();
     }
 
-    // For any add-ons that were installed disabled and can be enabled offer
-    // them to the user.
-    let win = RecentWindow.getMostRecentBrowserWindow();
-    AddonManager.getAllAddons(addons => {
-      for (let addon of addons) {
-        // If this add-on has already seen (or seen is undefined for non-XPI
-        // add-ons) then skip it.
-        if (addon.seen !== false) {
-          continue;
-        }
-
-        // If this add-on cannot be enabled (either already enabled or
-        // appDisabled) then skip it.
-        if (!(addon.permissions & AddonManager.PERM_CAN_ENABLE)) {
-          continue;
-        }
-
-        win.openUILinkIn("about:newaddon?id=" + addon.id, "tab");
-      }
-    });
-
     ExtensionsUI.init();
 
     let signingRequired;
     if (AppConstants.MOZ_REQUIRE_SIGNING) {
       signingRequired = true;
     } else {
       signingRequired = Services.prefs.getBoolPref("xpinstall.signatures.required");
     }
--- a/browser/modules/ExtensionsUI.jsm
+++ b/browser/modules/ExtensionsUI.jsm
@@ -2,25 +2,96 @@
  * 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";
 
 const {classes: Cc, interfaces: Ci, results: Cr, utils: Cu} = Components;
 
 this.EXPORTED_SYMBOLS = ["ExtensionsUI"];
 
-Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://devtools/shared/event-emitter.js");
+
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
+                                  "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow",
+                                  "resource:///modules/RecentWindow.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+                                  "resource://gre/modules/Services.jsm");
+
+XPCOMUtils.defineLazyPreferenceGetter(this, "WEBEXT_PERMISSION_PROMPTS",
+                                      "extensions.webextPermissionPrompts", false);
 
 const DEFAULT_EXENSION_ICON = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
 
 const HTML_NS = "http://www.w3.org/1999/xhtml";
 
 this.ExtensionsUI = {
+  sideloaded: new Set(),
+
   init() {
     Services.obs.addObserver(this, "webextension-permission-prompt", false);
+
+    this._checkForSideloaded();
+  },
+
+  _checkForSideloaded() {
+    AddonManager.getAllAddons(addons => {
+      // Check for any side-loaded addons that the user is allowed
+      // to enable.
+      let sideloaded = addons.filter(
+        addon => addon.seen === false && (addon.permissions & AddonManager.PERM_CAN_ENABLE));
+
+      if (!sideloaded.length) {
+        return;
+      }
+
+      if (WEBEXT_PERMISSION_PROMPTS) {
+        for (let addon of sideloaded) {
+          this.sideloaded.add(addon);
+        }
+        this.emit("change");
+      } else {
+        // This and all the accompanying about:newaddon code can eventually
+        // be removed.  See bug 1331521.
+        let win = RecentWindow.getMostRecentBrowserWindow();
+        for (let addon of sideloaded) {
+          win.openUILinkIn(`about:newaddon?id=${addon.id}`, "tab");
+        }
+      }
+    });
+  },
+
+  showSideloaded(browser, addon) {
+    addon.markAsSeen();
+    this.sideloaded.delete(addon);
+    this.emit("change");
+
+    let loadPromise = new Promise(resolve => {
+      let listener = (subject, topic) => {
+        if (subject.location.href == "about:addons") {
+          Services.obs.removeObserver(listener, topic);
+          resolve(subject);
+        }
+      };
+      Services.obs.addObserver(listener, "EM-loaded", false);
+    });
+    let tab = browser.addTab("about:addons");
+    browser.selectedTab = tab;
+    loadPromise.then(win => {
+      win.loadView("addons://list/extension");
+      let info = {
+        addon,
+        icon: addon.iconURL,
+        type: "sideload",
+      };
+      this.showPermissionsPrompt(browser.selectedBrowser, info).then(answer => {
+        addon.userDisabled = !answer;
+      });
+    });
   },
 
   observe(subject, topic, data) {
     if (topic == "webextension-permission-prompt") {
       let {target, info} = subject.wrappedJSObject;
 
       // Dismiss the progress notification.  Note that this is bad if
       // there are multiple simultaneous installs happening, see
@@ -52,18 +123,37 @@ this.ExtensionsUI = {
 
     // The strings below are placeholders, they will switch over to the
     // bundle.get*String() calls as part of bug 1316996.
 
     // let bundle = win.gNavigatorBundle;
     // let header = bundle.getFormattedString("webextPerms.header", [name])
     // let listHeader = bundle.getString("webextPerms.listHeader");
     let header = "Add ADDON?".replace("ADDON", name);
+    let text = "";
     let listHeader = "It can:";
 
+    // let acceptText = bundle.getString("webextPerms.accept.label");
+    // let acceptKey = bundle.getString("webextPerms.accept.accessKey");
+    // let cancelText = bundle.getString("webextPerms.cancel.label");
+    // let cancelKey = bundle.getString("webextPerms.cancel.accessKey");
+    let acceptText = "Add extension";
+    let acceptKey = "A";
+    let cancelText = "Cancel";
+    let cancelKey = "C";
+
+    if (info.type == "sideload") {
+      header = `${name} added`;
+      text = "Another program on your computer installed an add-on that may affect your browser.  Please review this add-on's permission requests and choose to Enable or Disable";
+      acceptText = "Enable";
+      acceptKey = "E";
+      cancelText = "Disable";
+      cancelKey = "D";
+    }
+
     let formatPermission = perm => {
       try {
         // return bundle.getString(`webextPerms.description.${perm}`);
         return `localized description of permission ${perm}`;
       } catch (err) {
         // return bundle.getFormattedString("webextPerms.description.unknown",
         //                                  [perm]);
         return `localized description of unknown permission ${perm}`;
@@ -89,25 +179,16 @@ this.ExtensionsUI = {
       return `localized description of single host permission for ${match[1]}`;
     };
 
     let msgs = [
       ...perms.permissions.map(formatPermission),
       ...perms.hosts.map(formatHostPermission),
     ];
 
-    // let acceptText = bundle.getString("webextPerms.accept.label");
-    // let acceptKey = bundle.getString("webextPerms.accept.accessKey");
-    // let cancelText = bundle.getString("webextPerms.cancel.label");
-    // let cancelKey = bundle.getString("webextPerms.cancel.accessKey");
-    let acceptText = "Add extension";
-    let acceptKey = "A";
-    let cancelText = "Cancel";
-    let cancelKey = "C";
-
     let rendered = false;
     let popupOptions = {
       hideClose: true,
       popupIconURL: info.icon,
       persistent: true,
 
       eventCallback(topic) {
         if (topic == "showing") {
@@ -119,17 +200,21 @@ this.ExtensionsUI = {
           let doc = this.browser.ownerDocument;
           doc.getElementById("addon-webext-perm-header").textContent = header;
 
           let list = doc.getElementById("addon-webext-perm-list");
           while (list.firstChild) {
             list.firstChild.remove();
           }
 
-          let listHeaderEl = doc.getElementById("addon-webext-perm-text");
+          if (text) {
+            doc.getElementById("addon-webext-perm-text").textContent = text;
+          }
+
+          let listHeaderEl = doc.getElementById("addon-webext-perm-intro");
           listHeaderEl.value = listHeader;
           listHeaderEl.hidden = (msgs.length == 0);
 
           for (let msg of msgs) {
             let item = doc.createElementNS(HTML_NS, "li");
             item.textContent = msg;
             list.appendChild(item);
           }
@@ -157,8 +242,10 @@ this.ExtensionsUI = {
                                       label: cancelText,
                                       accessKey: cancelKey,
                                       callback: () => resolve(false),
                                     },
                                   ], popupOptions);
     });
   },
 };
+
+EventEmitter.decorate(ExtensionsUI);
new file mode 100644
--- /dev/null
+++ b/browser/themes/shared/addons/addon-badge.svg
@@ -0,0 +1,19 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- 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/. -->
+<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10">
+<style type="text/css">
+path {
+  stroke: #bb3817;
+  fill: #FFFFFF;
+}
+</style>
+<path d="M6.6,9c0.3,0,0.5-0.3,0.6-0.6C7,7.7,7,6.9,7.1,6.2c0.1-0.3,0.3-0.4,0.6-0.4c0.3,0,0.3,0.4,1,0.4
+        c0.3,0,0.8-0.1,0.8-1.1S9,3.9,8.7,3.9c-0.6,0-0.7,0.5-1,0.5c-0.3,0-0.5-0.2-0.6-0.5c0-0.3,0-0.7,0-1c0-0.3-0.2-0.5-0.5-0.6
+        c0,0,0,0-0.1,0c-0.5,0-1,0.1-1.6,0C4.7,2.3,4.5,2.1,4.6,1.8c0-0.4,0.4-0.3,0.4-1C5,0.5,4.9,0,3.8,0S2.7,0.5,2.7,0.8
+        c0,0.6,0.5,0.7,0.5,1c0,0.3-0.2,0.5-0.5,0.5C2.1,2.4,1.6,2.4,1,2.4c-0.3,0-0.5,0.2-0.6,0.5c0,0,0,0,0,0.1v0.7c0,0-0.1,0.8,0.6,0.8
+        C1.5,4.5,1.6,4,2.2,4c0.3,0,0.7,0.7,0.7,1.3S2.4,6.6,2.2,6.6C1.6,6.6,1.5,6,1.1,6C0.4,5.9,0.5,6.7,0.5,6.7v1.7C0.4,8.7,0.7,9,1,9
+        c0,0,0,0,0,0h2.1C3.1,9,4,9,4,8.4c0-0.4-0.7-0.6-0.7-1.2C3.5,6.7,4,6.3,4.5,6.3c0.6,0,1.2,0.6,1.2,0.8c0,0.6-0.6,0.8-0.6,1.2
+        C5.1,9,5.9,9,5.9,9L6.6,9z"/>
+</svg>
--- a/browser/themes/shared/customizableui/panelUI.inc.css
+++ b/browser/themes/shared/customizableui/panelUI.inc.css
@@ -150,16 +150,21 @@
   background: transparent url(chrome://browser/skin/warning.svg) no-repeat center;
 }
 
 #PanelUI-menu-button[badge-status="download-warning"] > .toolbarbutton-badge-stack > .toolbarbutton-badge:-moz-window-inactive,
 #PanelUI-menu-button[badge-status="fxa-needs-authentication"] > .toolbarbutton-badge-stack > .toolbarbutton-badge:-moz-window-inactive {
   filter: none;
 }
 
+#PanelUI-menu-button[badge-status="addon-alert"] > .toolbarbutton-badge-stack > .toolbarbutton-badge {
+  height: 13px;
+  background: transparent url(chrome://browser/skin/addons/addon-badge.svg) no-repeat center;
+}
+
 .panel-subviews {
   padding: 4px;
   background-clip: padding-box;
   border-left: 1px solid var(--arrowpanel-border-color);
   box-shadow: 0 3px 5px hsla(210,4%,10%,.1),
               0 0 7px hsla(210,4%,10%,.1);
   margin-inline-start: var(--panel-ui-exit-subview-gutter-width);
 }
@@ -556,17 +561,18 @@ toolbarpaletteitem[place="palette"] > to
   width: 47px;
   padding-top: 1px;
   display: block;
   text-align: center;
   position: relative;
   top: 25%;
 }
 
-#PanelUI-update-status[update-status]::after {
+#PanelUI-update-status[update-status]::after,
+#PanelUI-footer-addons > toolbarbutton::after {
   content: "";
   width: 14px;
   height: 14px;
   margin-inline-end: 16.5px;
   box-shadow: 0px 1px 0px rgba(255,255,255,.2) inset, 0px -1px 0px rgba(0,0,0,.1) inset, 0px 1px 0px rgba(12,27,38,.2);
   border-radius: 2px;
   background-size: contain;
   display: -moz-box;
@@ -577,16 +583,34 @@ toolbarpaletteitem[place="palette"] > to
   background-color: #74BF43;
 }
 
 #PanelUI-update-status[update-status="failed"]::after {
   background-image: url(chrome://browser/skin/update-badge-failed.svg);
   background-color: #D90000;
 }
 
+#PanelUI-footer-addons > toolbarbutton {
+  background-color: #C7F5FF;
+  display: flex;
+  flex: 1 1 0%;
+  width: calc(@menuPanelWidth@ + 30px);
+  padding-inline-start: 15px;
+  border-inline-start-style: none;
+}
+
+#PanelUI-footer-addons > toolbarbutton > .toolbarbutton-icon {
+  width: 14px;
+  height: 14px;
+}
+
+#PanelUI-footer-addons > toolbarbutton::after {
+  background-image: url(chrome://browser/skin/addons/addon-badge.svg);
+}
+
 #PanelUI-fxa-status {
   display: flex;
   flex: 1 1 0%;
   width: 1px;
 }
 
 #PanelUI-footer-inner,
 #PanelUI-footer-fxa:not([hidden]) {
@@ -611,16 +635,17 @@ toolbarpaletteitem[place="palette"] > to
 #PanelUI-footer-fxa:hover > toolbarseparator {
   margin: 0;
 }
 
 #PanelUI-update-status,
 #PanelUI-help,
 #PanelUI-fxa-label,
 #PanelUI-fxa-icon,
+#PanelUI-footer-addons > toolbarbutton,
 #PanelUI-customize,
 #PanelUI-quit {
   margin: 0;
   padding: 11px 0;
   box-sizing: border-box;
   min-height: 40px;
   -moz-appearance: none;
   box-shadow: none;
@@ -672,16 +697,17 @@ toolbarpaletteitem[place="palette"] > to
 }
 
 #PanelUI-fxa-icon {
   padding-inline-start: 15px;
   padding-inline-end: 15px;
 }
 
 #PanelUI-fxa-label,
+#PanelUI-footer-addons > toolbarbutton,
 #PanelUI-customize {
   flex: 1;
   padding-inline-start: 15px;
   border-inline-start-style: none;
 }
 
 #PanelUI-footer-fxa[fxaprofileimage="set"] > #PanelUI-fxa-status > #PanelUI-fxa-label,
 #PanelUI-footer-fxa[fxaprofileimage="enabled"]:not([fxastatus="error"]) > #PanelUI-fxa-status > #PanelUI-fxa-label {
@@ -836,16 +862,17 @@ toolbarpaletteitem[place="palette"] > to
 
 #PanelUI-quit {
   border-inline-end-style: none;
   list-style-image: url(chrome://browser/skin/menuPanel-exit.png);
 }
 
 #PanelUI-fxa-label,
 #PanelUI-fxa-icon,
+#PanelUI-footer-addons > toolbarbutton,
 #PanelUI-customize,
 #PanelUI-help,
 #PanelUI-quit {
   -moz-image-region: rect(0, 16px, 16px, 0);
 }
 
 #PanelUI-footer-fxa[fxastatus="signedin"] > #PanelUI-fxa-status > #PanelUI-fxa-label > .toolbarbutton-icon,
 #PanelUI-footer-fxa[fxastatus="error"][fxaprofileimage="set"] > #PanelUI-fxa-status > #PanelUI-fxa-label > .toolbarbutton-icon {
--- a/browser/themes/shared/jar.inc.mn
+++ b/browser/themes/shared/jar.inc.mn
@@ -11,16 +11,17 @@
   skin/classic/browser/blockedSite.css                         (../shared/blockedSite.css)
   skin/classic/browser/error-pages.css                         (../shared/error-pages.css)
 * skin/classic/browser/aboutProviderDirectory.css              (../shared/aboutProviderDirectory.css)
 * skin/classic/browser/aboutSessionRestore.css                 (../shared/aboutSessionRestore.css)
   skin/classic/browser/aboutSocialError.css                    (../shared/aboutSocialError.css)
   skin/classic/browser/aboutTabCrashed.css                     (../shared/aboutTabCrashed.css)
   skin/classic/browser/aboutWelcomeBack.css                    (../shared/aboutWelcomeBack.css)
   skin/classic/browser/content-contextmenu.svg                 (../shared/content-contextmenu.svg)
+  skin/classic/browser/addons/addon-badge.svg                  (../shared/addons/addon-badge.svg)
   skin/classic/browser/addons/addon-install-blocked.svg        (../shared/addons/addon-install-blocked.svg)
   skin/classic/browser/addons/addon-install-confirm.svg        (../shared/addons/addon-install-confirm.svg)
   skin/classic/browser/addons/addon-install-downloading.svg    (../shared/addons/addon-install-downloading.svg)
   skin/classic/browser/addons/addon-install-error.svg          (../shared/addons/addon-install-error.svg)
   skin/classic/browser/addons/addon-install-installed.svg      (../shared/addons/addon-install-installed.svg)
   skin/classic/browser/addons/addon-install-restart.svg        (../shared/addons/addon-install-restart.svg)
   skin/classic/browser/addons/addon-install-warning.svg        (../shared/addons/addon-install-warning.svg)
 * skin/classic/browser/addons/addon-install-anchor.svg         (../shared/addons/addon-install-anchor.svg)
--- a/toolkit/mozapps/extensions/internal/XPIProviderUtils.js
+++ b/toolkit/mozapps/extensions/internal/XPIProviderUtils.js
@@ -84,17 +84,18 @@ const PROP_JSON_FIELDS = ["id", "syncGUI
                           "optionsType", "aboutURL", "icons", "iconURL", "icon64URL",
                           "defaultLocale", "visible", "active", "userDisabled",
                           "appDisabled", "pendingUninstall", "descriptor", "installDate",
                           "updateDate", "applyBackgroundUpdates", "bootstrap",
                           "skinnable", "size", "sourceURI", "releaseNotesURI",
                           "softDisabled", "foreignInstall", "hasBinaryComponents",
                           "strictCompatibility", "locales", "targetApplications",
                           "targetPlatforms", "multiprocessCompatible", "signedState",
-                          "seen", "dependencies", "hasEmbeddedWebExtension", "mpcOptedOut"];
+                          "seen", "dependencies", "hasEmbeddedWebExtension", "mpcOptedOut",
+                          "userPermissions"];
 
 // Properties that should be migrated where possible from an old database. These
 // shouldn't include properties that can be read directly from install.rdf files
 // or calculated
 const DB_MIGRATE_METADATA = ["installDate", "userDisabled", "softDisabled",
                             "sourceURI", "applyBackgroundUpdates",
                             "releaseNotesURI", "foreignInstall", "syncGUID"];