Bug 1317363 Implement the new sideloading flow draft
authorAndrew Swan <aswan@mozilla.com>
Wed, 18 Jan 2017 18:16:19 -0800
changeset 463451 58acaa18a06516047eea50d5893046a266590219
parent 463342 96cb95af530477edb66ae48d98c18533476e57bb
child 463457 06a753e6d22146e0e7b9207ea01aa6883f34afec
push id42071
push useraswan@mozilla.com
push dateThu, 19 Jan 2017 05:28:17 +0000
bugs1317363
milestone53.0a1
Bug 1317363 Implement the new sideloading flow 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"];