Bug 1356826: Part 7 - Scan for extension sideloads after final UI startup. r=aswan,rhelmer
authorKris Maglione <maglione.k@gmail.com>
Wed, 10 May 2017 12:34:17 -0700
changeset 406389 d771366888273fc96056c05a7294755f69235a01
parent 406388 79fbcdee024b43ba86b6d24dd7b8b2a74018fa78
child 406390 6bc3416bc56efdb78550dd308d2f392dd8aa4343
push id7391
push usermtabara@mozilla.com
push dateMon, 12 Jun 2017 13:08:53 +0000
treeherdermozilla-beta@2191d7f87e2e [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan, rhelmer
bugs1356826
milestone55.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 1356826: Part 7 - Scan for extension sideloads after final UI startup. r=aswan,rhelmer MozReview-Commit-ID: 1syn9GD2DEb
browser/base/content/test/webextensions/browser_extension_sideloading.js
browser/base/content/test/webextensions/head.js
browser/modules/ExtensionsUI.jsm
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/internal/XPIProviderUtils.js
toolkit/mozapps/extensions/test/xpcshell/head_addons.js
toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
toolkit/mozapps/extensions/test/xpcshell/test_sideloads.js
toolkit/mozapps/extensions/test/xpcshell/xpcshell-shared.ini
--- a/browser/base/content/test/webextensions/browser_extension_sideloading.js
+++ b/browser/base/content/test/webextensions/browser_extension_sideloading.js
@@ -1,160 +1,94 @@
 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.
+const {AddonTestUtils} = Cu.import("resource://testing-common/AddonTestUtils.jsm", {});
+
+AddonTestUtils.initMochitest(this);
 
-// MockAddon -> callback
-let setCallbacks = new Map();
-
-class MockAddon {
-  constructor(props) {
-    this._userDisabled = false;
-    this.pendingOperations = 0;
-    this.type = "extension";
+async function createWebExtension(details) {
+  let options = {
+    manifest: {
+      applications: {gecko: {id: details.id}},
 
-    for (let name in props) {
-      if (name == "userDisabled") {
-        this._userDisabled = props[name];
-      }
-      this[name] = props[name];
-    }
-  }
+      name: details.name,
 
-  markAsSeen() {
-    this.seen = true;
-  }
+      permissions: details.permissions,
+    },
+  };
 
-  get userDisabled() {
-    return this._userDisabled;
+  if (details.iconURL) {
+    options.manifest.icons = {"64": details.iconURL};
   }
 
-  set userDisabled(val) {
-    this._userDisabled = val;
-    AddonManagerPrivate.callAddonListeners(val ? "onDisabled" : "onEnabled", this);
-    let fn = setCallbacks.get(this);
-    if (fn) {
-      setCallbacks.delete(this);
-      fn(val);
-    }
-    return val;
-  }
+  let xpi = AddonTestUtils.createTempWebExtensionFile(options);
 
-  get permissions() {
-    return this._userDisabled ? AddonManager.PERM_CAN_ENABLE : AddonManager.PERM_CAN_DISABLE;
-  }
+  await AddonTestUtils.manuallyInstall(xpi);
 }
 
-class MockProvider {
-  constructor(...addons) {
-    this.addons = new Set(addons);
-  }
-
-  startup() { }
-  shutdown() { }
+async function createXULExtension(details) {
+  let xpi = AddonTestUtils.createTempXPIFile({
+    "install.rdf": {
+      id: details.id,
+      name: details.name,
+      version: "0.1",
+      targetApplications: [{
+        id: "toolkit@mozilla.org",
+        minVersion: "0",
+        maxVersion: "*",
+      }],
+    },
+  });
 
-  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 promiseSetDisabled(addon) {
-  return new Promise(resolve => {
-    setCallbacks.set(addon, resolve);
-  });
+  await AddonTestUtils.manuallyInstall(xpi);
 }
 
 let cleanup;
 
 add_task(async function() {
-  // ICON_URL wouldn't ever appear as an actual webextension icon, but
-  // we're just mocking out the addon here, so all we care about is that
-  // that it propagates correctly to the popup.
-  const ICON_URL = "chrome://mozapps/skin/extensions/category-extensions.svg";
   const DEFAULT_ICON_URL = "chrome://mozapps/skin/extensions/extensionGeneric.svg";
 
+  await SpecialPowers.pushPrefEnv({
+    set: [
+      ["xpinstall.signatures.required", false],
+      ["extensions.autoDisableScopes", 15],
+      ["extensions.ui.ignoreUnsigned", true],
+    ],
+  });
+
   const ID1 = "addon1@tests.mozilla.org";
-  let mock1 = new MockAddon({
+  await createWebExtension({
     id: ID1,
     name: "Test 1",
     userDisabled: true,
-    seen: false,
-    userPermissions: {
-      permissions: ["history"],
-      origins: ["https://*/*"],
-    },
-    iconURL: ICON_URL,
+    permissions: ["history", "https://*/*"],
+    iconURL: "foo-icon.png",
   });
 
   const ID2 = "addon2@tests.mozilla.org";
-  let mock2 = new MockAddon({
+  await createXULExtension({
     id: ID2,
     name: "Test 2",
-    userDisabled: true,
-    seen: false,
-    userPermissions: {
-      permissions: [],
-      origins: [],
-    },
   });
 
   const ID3 = "addon3@tests.mozilla.org";
-  let mock3 = new MockAddon({
+  await createWebExtension({
     id: ID3,
     name: "Test 3",
-    isWebExtension: true,
-    userDisabled: true,
-    seen: false,
-    userPermissions: {
-      permissions: [],
-      origins: ["<all_urls>"],
-    }
+    permissions: ["<all_urls>"],
   });
 
   const ID4 = "addon4@tests.mozilla.org";
-  let mock4 = new MockAddon({
+  await createWebExtension({
     id: ID4,
     name: "Test 4",
-    isWebExtension: true,
-    userDisabled: true,
-    seen: false,
-    userPermissions: {
-      permissions: [],
-      origins: ["<all_urls>"],
-    }
+    permissions: ["<all_urls>"],
   });
 
-  let provider = new MockProvider(mock1, mock2, mock3, mock4);
-  AddonManagerPrivate.registerProvider(provider, [{
-    id: "extension",
-    name: "Extensions",
-    uiPriority: 4000,
-    flags: AddonManager.TYPE_UI_VIEW_LIST |
-           AddonManager.TYPE_SUPPORTS_UNDO_RESTARTLESS_UNINSTALL,
-  }]);
-
   testCleanup = async function() {
-    AddonManagerPrivate.unregisterProvider(provider);
-
     // clear out ExtensionsUI state about sideloaded extensions so
     // subsequent tests don't get confused.
     ExtensionsUI.sideloaded.clear();
     ExtensionsUI.emit("change");
   };
 
   // Navigate away from the starting page to force about:addons to load
   // in a new tab during the tests below.
@@ -198,27 +132,23 @@ add_task(async function() {
   is(gBrowser.currentURI.spec, "about:addons", "Foreground tab is at about:addons");
 
   const VIEW = "addons://list/extension";
   let win = gBrowser.selectedBrowser.contentWindow;
   ok(!win.gViewController.isLoading, "about:addons view is fully loaded");
   is(win.gViewController.currentViewId, VIEW, "about:addons is at extensions list");
 
   // Check the contents of the notification, then choose "Cancel"
-  checkNotification(panel, ICON_URL, [
+  checkNotification(panel, /\/foo-icon\.png$/, [
     ["webextPerms.hostDescription.allUrls"],
     ["webextPerms.description.history"],
   ]);
 
-  let disablePromise = promiseSetDisabled(mock1);
   panel.secondaryButton.click();
 
-  let value = await disablePromise;
-  is(value, true, "Addon should remain disabled");
-
   let [addon1, addon2, addon3, addon4] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3, ID4]);
   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");
   is(addon3.userDisabled, true, "Addon 3 should still be disabled");
   is(addon4.userDisabled, true, "Addon 4 should still be disabled");
 
   await BrowserTestUtils.removeTab(gBrowser.selectedTab);
@@ -240,22 +170,18 @@ add_task(async function() {
   win = gBrowser.selectedBrowser.contentWindow;
   ok(!win.gViewController.isLoading, "about:addons view is fully loaded");
   is(win.gViewController.currentViewId, VIEW, "about:addons is at extensions list");
 
   // Check the notification contents.
   checkNotification(panel, DEFAULT_ICON_URL, []);
 
   // This time accept the install.
-  disablePromise = promiseSetDisabled(mock2);
   panel.button.click();
 
-  value = await disablePromise;
-  is(value, false, "Addon should be set to enabled");
-
   [addon1, addon2, addon3, addon4] = await AddonManager.getAddonsByIDs([ID1, ID2, ID3, ID4]);
   is(addon1.userDisabled, true, "Addon 1 should still be disabled");
   is(addon2.userDisabled, false, "Addon 2 should now be enabled");
   is(addon3.userDisabled, true, "Addon 3 should still be disabled");
   is(addon4.userDisabled, true, "Addon 4 should still be disabled");
 
   // Should still have 2 entries in the hamburger menu
   await PanelUI.show();
@@ -283,20 +209,17 @@ add_task(async function() {
   // When clicking enable we should see the permissions notification
   popupPromise = promisePopupNotificationShown("addon-webext-permissions");
   BrowserTestUtils.synthesizeMouseAtCenter(item._enableBtn, {},
                                            gBrowser.selectedBrowser);
   panel = await popupPromise;
   checkNotification(panel, DEFAULT_ICON_URL, [["webextPerms.hostDescription.allUrls"]]);
 
   // Accept the permissions
-  disablePromise = promiseSetDisabled(mock3);
   panel.button.click();
-  value = await disablePromise;
-  is(value, false, "userDisabled should be set on addon 3");
 
   addon3 = await AddonManager.getAddonByID(ID3);
   is(addon3.userDisabled, false, "Addon 3 should be enabled");
 
   // Should still have 1 entry in the hamburger menu
   await PanelUI.show();
 
   addons = PanelUI.addonNotificationContainer;
@@ -311,23 +234,26 @@ add_task(async function() {
   // When clicking enable we should see the permissions notification
   popupPromise = promisePopupNotificationShown("addon-webext-permissions");
   BrowserTestUtils.synthesizeMouseAtCenter(button, {},
                                            gBrowser.selectedBrowser);
   panel = await popupPromise;
   checkNotification(panel, DEFAULT_ICON_URL, [["webextPerms.hostDescription.allUrls"]]);
 
   // Accept the permissions
-  disablePromise = promiseSetDisabled(mock4);
   panel.button.click();
-  value = await disablePromise;
-  is(value, false, "userDisabled should be set on addon 4");
 
   addon4 = await AddonManager.getAddonByID(ID4);
   is(addon4.userDisabled, false, "Addon 4 should be enabled");
 
   // We should have recorded 1 cancelled followed by 3 accepted sideloads.
   expectTelemetry(["sideloadRejected", "sideloadAccepted", "sideloadAccepted", "sideloadAccepted"]);
 
   isnot(menuButton.getAttribute("badge-status"), "addon-alert", "Should no longer have addon alert badge");
 
+  await new Promise(resolve => setTimeout(resolve, 100));
+
+  for (let addon of [addon1, addon2, addon3, addon4]) {
+    addon.uninstall();
+  }
+
   await BrowserTestUtils.removeTab(gBrowser.selectedTab);
 });
--- a/browser/base/content/test/webextensions/head.js
+++ b/browser/base/content/test/webextensions/head.js
@@ -213,17 +213,17 @@ function checkPermissionString(string, k
  *        optional formatting parameter.
  */
 function checkNotification(panel, checkIcon, permissions) {
   let icon = panel.getAttribute("icon");
   let ul = document.getElementById("addon-webext-perm-list");
   let header = document.getElementById("addon-webext-perm-intro");
 
   if (checkIcon instanceof RegExp) {
-    ok(checkIcon.test(icon), "Notification icon is correct");
+    ok(checkIcon.test(icon), `Notification icon is correct ${JSON.stringify(icon)} ~= ${checkIcon}`);
   } else if (typeof checkIcon == "function") {
     ok(checkIcon(icon), "Notification icon is correct");
   } else {
     is(icon, checkIcon, "Notification icon is correct");
   }
 
   is(ul.childElementCount, permissions.length, `Permissions list has ${permissions.length} entries`);
   if (permissions.length == 0) {
--- a/browser/modules/ExtensionsUI.jsm
+++ b/browser/modules/ExtensionsUI.jsm
@@ -7,16 +7,18 @@ const {classes: Cc, interfaces: Ci, resu
 
 this.EXPORTED_SYMBOLS = ["ExtensionsUI"];
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/EventEmitter.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
                                   "resource://gre/modules/AddonManager.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "AddonManagerPrivate",
+                                  "resource://gre/modules/AddonManager.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "PluralForm",
                                   "resource://gre/modules/PluralForm.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "RecentWindow",
                                   "resource:///modules/RecentWindow.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Services",
                                   "resource://gre/modules/Services.jsm");
 
 XPCOMUtils.defineLazyPreferenceGetter(this, "WEBEXT_PERMISSION_PROMPTS",
@@ -43,60 +45,60 @@ this.ExtensionsUI = {
     Services.obs.addObserver(this, "webextension-install-notify");
     Services.obs.addObserver(this, "webextension-optional-permission-prompt");
 
     await RecentWindow.getMostRecentBrowserWindow().delayedStartupPromise;
 
     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));
+  async _checkForSideloaded() {
+    let sideloaded = await AddonManagerPrivate.getNewSideloads();
+
+    if (!sideloaded.length) {
+      // No new side-loads. We're done.
+      return;
+    }
+
+    // The ordering shouldn't matter, but tests depend on notifications
+    // happening in a specific order.
+    sideloaded.sort((a, b) => a.id.localeCompare(b.id));
 
-      if (!sideloaded.length) {
-        return;
+    if (WEBEXT_PERMISSION_PROMPTS) {
+      if (!this.sideloadListener) {
+        this.sideloadListener = {
+          onEnabled: addon => {
+            if (!this.sideloaded.has(addon)) {
+              return;
+            }
+
+            this.sideloaded.delete(addon);
+            this.emit("change");
+
+            if (this.sideloaded.size == 0) {
+              AddonManager.removeAddonListener(this.sideloadListener);
+              this.sideloadListener = null;
+            }
+          },
+        };
+        AddonManager.addAddonListener(this.sideloadListener);
       }
 
-      if (WEBEXT_PERMISSION_PROMPTS) {
-        if (!this.sideloadListener) {
-          this.sideloadListener = {
-            onEnabled: addon => {
-              if (!this.sideloaded.has(addon)) {
-                return;
-              }
-
-              this.sideloaded.delete(addon);
-              this.emit("change");
-
-              if (this.sideloaded.size == 0) {
-                AddonManager.removeAddonListener(this.sideloadListener);
-                this.sideloadListener = null;
-              }
-            },
-          };
-          AddonManager.addAddonListener(this.sideloadListener);
-        }
-
-        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");
-        }
+      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");
+      }
+    }
   },
 
   showAddonsManager(browser, strings, icon, histkey) {
     let global = browser.selectedBrowser.ownerGlobal;
     return global.BrowserOpenAddonsMgr("addons://list/extension").then(aomWin => {
       let aomBrowser = aomWin.QueryInterface(Ci.nsIInterfaceRequestor)
                              .getInterface(Ci.nsIDocShell)
                              .chromeEventHandler;
@@ -143,16 +145,21 @@ this.ExtensionsUI = {
       // there are multiple simultaneous installs happening, see
       // bug 1329884 for a longer explanation.
       let progressNotification = target.ownerGlobal.PopupNotifications.getNotification("addon-progress", target);
       if (progressNotification) {
         progressNotification.remove();
       }
 
       info.unsigned = info.addon.signedState <= AddonManager.SIGNEDSTATE_MISSING;
+      if (info.unsigned && Cu.isInAutomation &&
+          Services.prefs.getBoolPref("extensions.ui.ignoreUnsigned", false)) {
+        info.unsigned = false;
+      }
+
       let strings = this._buildStrings(info);
 
       // If this is an update with no promptable permissions, just apply it
       if (info.type == "update" && strings.msgs.length == 0) {
         info.resolve();
         return;
       }
 
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -3079,16 +3079,27 @@ this.AddonManagerPrivate = {
     AddonManagerInternal.startup();
   },
 
   addonIsActive(addonId) {
     return AddonManagerInternal._getProviderByName("XPIProvider")
                                .addonIsActive(addonId);
   },
 
+  /**
+   * Gets an array of add-ons which were side-loaded prior to the last
+   * startup, and are currently disabled.
+   *
+   * @returns {Promise<Array<Addon>>}
+   */
+  getNewSideloads() {
+    return AddonManagerInternal._getProviderByName("XPIProvider")
+                               .getNewSideloads();
+  },
+
   get browserUpdated() {
     return gBrowserUpdated;
   },
 
   registerProvider(aProvider, aTypes) {
     AddonManagerInternal.registerProvider(aProvider, aTypes);
   },
 
--- a/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
+++ b/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
@@ -212,16 +212,19 @@ var AddonTestUtils = {
   useRealCertChecks: false,
 
   init(testScope) {
     this.testScope = testScope;
 
     // Get the profile directory for tests to use.
     this.profileDir = testScope.do_get_profile();
 
+    this.profileExtensions = this.profileDir.clone();
+    this.profileExtensions.append("extensions");
+
     this.extensionsINI = this.profileDir.clone();
     this.extensionsINI.append("extensions.ini");
 
     // Register a temporary directory for the tests.
     this.tempDir = this.profileDir.clone();
     this.tempDir.append("temp");
     this.tempDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
     this.registerDirectory("TmpD", this.tempDir);
@@ -281,20 +284,17 @@ var AddonTestUtils = {
     // Make sure that a given path does not exist
     function pathShouldntExist(file) {
       if (file.exists()) {
         throw new Error(`Test cleanup: path ${file.path} exists when it should not`);
       }
     }
 
     testScope.do_register_cleanup(() => {
-      for (let file of this.tempXPIs) {
-        if (file.exists())
-          file.remove(false);
-      }
+      this.cleanupTempXPIs();
 
       // Check that the temporary directory is empty
       var dirEntries = this.tempDir.directoryEntries
                            .QueryInterface(Ci.nsIDirectoryEnumerator);
       var entries = [];
       while (dirEntries.hasMoreElements())
         entries.push(dirEntries.nextFile.leafName);
       if (entries.length)
@@ -336,16 +336,47 @@ var AddonTestUtils = {
 
       testDir.leafName = "staged";
       pathShouldntExist(testDir);
 
       return this.promiseShutdownManager();
     });
   },
 
+  initMochitest(testScope) {
+    this.profileDir = FileUtils.getDir("ProfD", []);
+
+    this.profileExtensions = FileUtils.getDir("ProfD", ["extensions"]);
+
+    this.tempDir = FileUtils.getDir("TmpD", []);
+    this.tempDir.append("addons-mochitest");
+    this.tempDir.createUnique(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+    testScope.registerCleanupFunction(() => {
+      this.cleanupTempXPIs();
+      try {
+        this.tempDir.remove(true);
+      } catch (e) {
+        Cu.reportError(e);
+      }
+    });
+  },
+
+  cleanupTempXPIs() {
+    for (let file of this.tempXPIs.splice(0)) {
+      if (file.exists()) {
+        try {
+          file.remove(false);
+        } catch (e) {
+          Cu.reportError(e);
+        }
+      }
+    }
+  },
+
   /**
    * Helper to spin the event loop until a promise resolves or rejects
    *
    * @param {Promise} promise
    *        The promise to wait on.
    * @returns {*} The promise's resolution value.
    * @throws The promise's rejection value, if it rejects.
    */
@@ -406,16 +437,20 @@ var AddonTestUtils = {
       }
 
       throw new Error("No manifest file present");
     } finally {
       zip.close();
     }
   },
 
+  getIDFromExtension(file) {
+    return this.getIDFromManifest(this.getManifestURI(file));
+  },
+
   async getIDFromManifest(manifestURI) {
     let body = await fetch(manifestURI.spec);
 
     if (manifestURI.spec.endsWith(".rdf")) {
       let data = await body.text();
 
       let ds = new RDFDataSource();
       new RDFXMLParser(ds, manifestURI, data);
@@ -846,28 +881,32 @@ var AddonTestUtils = {
 
   /**
    * Manually installs an XPI file into an install location by either copying the
    * XPI there or extracting it depending on whether unpacking is being tested
    * or not.
    *
    * @param {nsIFile} xpiFile
    *        The XPI file to install.
-   * @param {nsIFile} installLocation
+   * @param {nsIFile} [installLocation = this.profileExtensions]
    *        The install location (an nsIFile) to install into.
-   * @param {string} id
+   * @param {string} [id]
    *        The ID to install as.
    * @param {boolean} [unpacked = this.testUnpacked]
    *        If true, install as an unpacked directory, rather than a
    *        packed XPI.
    * @returns {nsIFile}
    *        A file pointing to the installed location of the XPI file or
    *        unpacked directory.
    */
-  manuallyInstall(xpiFile, installLocation, id, unpacked = this.testUnpacked) {
+  async manuallyInstall(xpiFile, installLocation = this.profileExtensions, id = null, unpacked = this.testUnpacked) {
+    if (id == null) {
+      id = await this.getIDFromExtension(xpiFile);
+    }
+
     if (unpacked) {
       let dir = installLocation.clone();
       dir.append(id);
       dir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
 
       let zip = ZipReader(xpiFile);
       let entries = zip.findEntries(null);
       while (entries.hasMore()) {
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -2234,16 +2234,24 @@ function SerializableMap(arg) {
 
 /**
  * Keeps track of the state of XPI add-ons on the file system.
  */
 this.XPIStates = {
   // Map(location name -> Map(add-on ID -> XPIState))
   db: null,
 
+  /**
+   * @property {Map<string, XPIState>} sideLoadedAddons
+   *        A map of new add-ons detected during install location
+   *        directory scans. Keys are add-on IDs, values are XPIState
+   *        objects corresponding to those add-ons.
+   */
+  sideLoadedAddons: new Map(),
+
   get size() {
     if (!this.db) {
       return 0;
     }
     let count = 0;
     for (let location of this.db.values()) {
       count += location.size;
     }
@@ -2314,16 +2322,17 @@ this.XPIStates = {
       }
 
       for (let [id, file] of addons) {
         if (!(id in locState)) {
           logger.debug("New add-on ${id} in ${location}", {id, location: location.name});
           let xpiState = new XPIState({d: file.persistentDescriptor});
           changed = xpiState.getModTime(file, id) || changed;
           foundAddons.set(id, xpiState);
+          this.sideLoadedAddons.set(id, xpiState);
         } else {
           let xpiState = new XPIState(locState[id]);
           // We found this add-on in the file system
           delete locState[id];
 
           changed = xpiState.getModTime(file, id) || changed;
 
           if (file.persistentDescriptor != xpiState.descriptor) {
@@ -3860,22 +3869,17 @@ this.XPIProvider = {
         } catch (e) {
           logger.warn("Unable to remove old extension cache " + oldCache.path, e);
         }
       }
 
       // If the application crashed before completing any pending operations then
       // we should perform them now.
       if (extensionListChanged || hasPendingChanges) {
-        logger.debug("Updating database with changes to installed add-ons");
-        XPIDatabase.updateActiveAddons();
-        Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS,
-                                   !XPIDatabase.writeAddonsList());
-        Services.prefs.setCharPref(PREF_BOOTSTRAP_ADDONS,
-                                   JSON.stringify(this.bootstrappedAddons));
+        this._updateActiveAddons();
         return true;
       }
 
       logger.debug("No changes found");
     } catch (e) {
       logger.error("Error during startup file checks", e);
     }
 
@@ -3886,16 +3890,48 @@ this.XPIProvider = {
     if (addonsList.exists() != haveAnyAddons) {
       logger.debug("Add-ons list is invalid, rebuilding");
       XPIDatabase.writeAddonsList();
     }
 
     return false;
   },
 
+  _updateActiveAddons() {
+    logger.debug("Updating database with changes to installed add-ons");
+    XPIDatabase.updateActiveAddons();
+    Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS,
+                               !XPIDatabase.writeAddonsList());
+    Services.prefs.setCharPref(PREF_BOOTSTRAP_ADDONS,
+                               JSON.stringify(this.bootstrappedAddons));
+  },
+
+  /**
+   * Gets an array of add-ons which were placed in a known install location
+   * prior to startup of the current session, were detected by a directory scan
+   * of those locations, and are currently disabled.
+   *
+   * @returns {Promise<Array<Addon>>}
+   */
+  async getNewSideloads() {
+    if (XPIStates.getInstallState(false)) {
+      // We detected changes. Update the database to account for them.
+      await XPIDatabase.asyncLoadDB(false);
+      XPIDatabaseReconcile.processFileChanges({}, false);
+      this._updateActiveAddons();
+    }
+
+    let addons = await Promise.all(
+      Array.from(XPIStates.sideLoadedAddons.keys(),
+                 id => AddonManager.getAddonByID(id)));
+
+    return addons.filter(addon => (addon.seen === false &&
+                                   addon.permissions & AddonManager.PERM_CAN_ENABLE));
+  },
+
   /**
    * Called to test whether this provider supports installing a particular
    * mimetype.
    *
    * @param  aMimetype
    *         The mimetype to check for
    * @return true if the mimetype is application/x-xpinstall
    */
--- a/toolkit/mozapps/extensions/internal/XPIProviderUtils.js
+++ b/toolkit/mozapps/extensions/internal/XPIProviderUtils.js
@@ -1703,17 +1703,18 @@ this.XPIDatabaseReconcile = {
       let disablingScopes = Preferences.get(PREF_EM_AUTO_DISABLED_SCOPES, 0);
       if (aInstallLocation.scope & disablingScopes) {
         logger.warn("Disabling foreign installed add-on " + aNewAddon.id + " in "
             + aInstallLocation.name);
         aNewAddon.userDisabled = true;
 
         // If we don't have an old app version then this is a new profile in
         // which case just mark any sideloaded add-ons as already seen.
-        aNewAddon.seen = !aOldAppVersion;
+        aNewAddon.seen = (aInstallLocation.name != KEY_APP_PROFILE &&
+                          !aOldAppVersion);
       }
     }
 
     return XPIDatabase.addAddonMetadata(aNewAddon, aAddonState.descriptor);
   },
 
   /**
    * Called when an add-on has been removed.
--- a/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/head_addons.js
@@ -65,17 +65,16 @@ XPCOMUtils.defineLazyModuleGetter(this, 
 
 const {
   awaitPromise,
   createAppInfo,
   createInstallRDF,
   createTempWebExtensionFile,
   createUpdateRDF,
   getFileForAddon,
-  manuallyInstall,
   manuallyUninstall,
   promiseAddonEvent,
   promiseCompleteAllInstalls,
   promiseCompleteInstall,
   promiseConsoleOutput,
   promiseFindAddonUpdates,
   promiseInstallAllFiles,
   promiseInstallFile,
@@ -84,16 +83,21 @@ const {
   promiseShutdownManager,
   promiseStartupManager,
   promiseWriteProxyFileToDir,
   registerDirectory,
   setExtensionModifiedTime,
   writeFilesToZip
 } = AddonTestUtils;
 
+function manuallyInstall(...args) {
+  return AddonTestUtils.awaitPromise(
+    AddonTestUtils.manuallyInstall(...args));
+}
+
 // WebExtension wrapper for ease of testing
 ExtensionTestUtils.init(this);
 
 AddonTestUtils.init(this);
 AddonTestUtils.overrideCertDB();
 
 Object.defineProperty(this, "gAppInfo", {
   get() {
--- a/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
@@ -13,16 +13,17 @@ const IGNORE = ["getPreferredIconURL", "
                 "mapURIToAddonID", "shutdown", "init",
                 "stateToString", "errorToString", "getUpgradeListener",
                 "addUpgradeListener", "removeUpgradeListener"];
 
 const IGNORE_PRIVATE = ["AddonAuthor", "AddonCompatibilityOverride",
                         "AddonScreenshot", "AddonType", "startup", "shutdown",
                         "addonIsActive", "registerProvider", "unregisterProvider",
                         "addStartupChange", "removeStartupChange",
+                        "getNewSideloads",
                         "recordTimestamp", "recordSimpleMeasure",
                         "recordException", "getSimpleMeasures", "simpleTimer",
                         "setTelemetryDetails", "getTelemetryDetails",
                         "callNoUpdateListeners", "backgroundUpdateTimerHandler",
                         "hasUpgradeListener", "getUpgradeListener"];
 
 function test_functions() {
   for (let prop in AddonManager) {
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_sideloads.js
@@ -0,0 +1,90 @@
+/* Any copyright is dedicated to the Public Domain.
+ * http://creativecommons.org/publicdomain/zero/1.0/
+ */
+
+createAppInfo("xpcshell@tests.mozilla.org", "XPCShell", "1", "49");
+
+async function createWebExtension(details) {
+  let options = {
+    manifest: {
+      applications: {gecko: {id: details.id}},
+
+      name: details.name,
+
+      permissions: details.permissions,
+    },
+  };
+
+  if (details.iconURL) {
+    options.manifest.icons = {"64": details.iconURL};
+  }
+
+  let xpi = AddonTestUtils.createTempWebExtensionFile(options);
+
+  await AddonTestUtils.manuallyInstall(xpi);
+}
+
+async function createXULExtension(details) {
+  let xpi = AddonTestUtils.createTempXPIFile({
+    "install.rdf": {
+      id: details.id,
+      name: details.name,
+      version: "0.1",
+      targetApplications: [{
+        id: "toolkit@mozilla.org",
+        minVersion: "0",
+        maxVersion: "*",
+      }],
+    },
+  });
+
+  await AddonTestUtils.manuallyInstall(xpi);
+}
+
+add_task(async function test_sideloading() {
+  Services.prefs.setIntPref("extensions.autoDisableScopes", 15);
+  Services.prefs.setIntPref("extensions.startupScanScopes", 0);
+
+  const ID1 = "addon1@tests.mozilla.org";
+  await createWebExtension({
+    id: ID1,
+    name: "Test 1",
+    userDisabled: true,
+    permissions: ["history", "https://*/*"],
+    iconURL: "foo-icon.png",
+  });
+
+  const ID2 = "addon2@tests.mozilla.org";
+  await createXULExtension({
+    id: ID2,
+    name: "Test 2",
+  });
+
+  const ID3 = "addon3@tests.mozilla.org";
+  await createWebExtension({
+    id: ID3,
+    name: "Test 3",
+    permissions: ["<all_urls>"],
+  });
+
+  const ID4 = "addon4@tests.mozilla.org";
+  await createWebExtension({
+    id: ID4,
+    name: "Test 4",
+    permissions: ["<all_urls>"],
+  });
+
+  await promiseStartupManager();
+
+  let sideloaded = await AddonManagerPrivate.getNewSideloads();
+
+  sideloaded.sort((a, b) => a.id.localeCompare(b.id));
+
+  deepEqual(sideloaded.map(a => a.id),
+            [ID1, ID2, ID3, ID4],
+            "Got the correct sideload add-ons");
+
+  deepEqual(sideloaded.map(a => a.userDisabled),
+            [true, true, true, true],
+            "All sideloaded add-ons are disabled");
+});
--- a/toolkit/mozapps/extensions/test/xpcshell/xpcshell-shared.ini
+++ b/toolkit/mozapps/extensions/test/xpcshell/xpcshell-shared.ini
@@ -329,16 +329,17 @@ skip-if = os == "android"
 run-sequentially = Uses global XCurProcD dir.
 [test_upgrade_strictcompat.js]
 # Bug 676992: test consistently hangs on Android
 skip-if = os == "android"
 run-sequentially = Uses global XCurProcD dir.
 [test_overrideblocklist.js]
 run-sequentially = Uses global XCurProcD dir.
 tags = blocklist
+[test_sideloads.js]
 [test_sourceURI.js]
 [test_webextension_icons.js]
 skip-if = appname == "thunderbird"
 tags = webextensions
 [test_webextension.js]
 skip-if = appname == "thunderbird"
 tags = webextensions
 [test_webextension_install.js]