Bug 1461217: Fold InstallLocation classes into XPIStateLocation sub-classes. r?aswan draft
authorKris Maglione <maglione.k@gmail.com>
Sun, 13 May 2018 16:05:03 -0700
changeset 796724 8383030c2ffb904abb6802323446b6fdd6ab0225
parent 796691 00ef440c200079598ec238673ed78528273e4e7b
child 797399 81dc779eb6a2bd970613019ff74c02850916b7bf
push id110347
push usermaglione.k@gmail.com
push dateFri, 18 May 2018 07:01:17 +0000
reviewersaswan
bugs1461217
milestone62.0a1
Bug 1461217: Fold InstallLocation classes into XPIStateLocation sub-classes. r?aswan MozReview-Commit-ID: KaAQhXv5B7u
toolkit/mozapps/extensions/internal/XPIDatabase.jsm
toolkit/mozapps/extensions/internal/XPIInstall.jsm
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/test/xpcshell/test_XPIStates.js
toolkit/mozapps/extensions/test/xpcshell/test_delay_update_webextension.js
--- a/toolkit/mozapps/extensions/internal/XPIDatabase.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIDatabase.jsm
@@ -116,17 +116,17 @@ const COMPATIBLE_BY_DEFAULT_TYPES = {
   "webextension-dictionary": true,
 };
 
 // Properties that exist in the extension manifest
 const PROP_LOCALE_SINGLE = ["name", "description", "creator", "homepageURL"];
 const PROP_LOCALE_MULTI  = ["developers", "translators", "contributors"];
 
 // Properties to save in JSON file
-const PROP_JSON_FIELDS = ["id", "syncGUID", "location", "version", "type",
+const PROP_JSON_FIELDS = ["id", "syncGUID", "version", "type",
                           "updateURL", "optionsURL",
                           "optionsType", "optionsBrowserStyle", "aboutURL",
                           "defaultLocale", "visible", "active", "userDisabled",
                           "appDisabled", "pendingUninstall", "installDate",
                           "updateDate", "applyBackgroundUpdates", "path",
                           "skinnable", "size", "sourceURI", "releaseNotesURI",
                           "softDisabled", "foreignInstall",
                           "strictCompatibility", "locales", "targetApplications",
@@ -264,22 +264,25 @@ class AddonInternal {
     this.hasEmbeddedWebExtension = false;
 
     if (addonData) {
       if (addonData.descriptor && !addonData.path) {
         addonData.path = descriptorToPath(addonData.descriptor);
       }
 
       copyProperties(addonData, PROP_JSON_FIELDS, this);
+      this.location = addonData.location;
 
       if (!this.dependencies)
         this.dependencies = [];
       Object.freeze(this.dependencies);
 
-      this.addedToDatabase();
+      if (this.location) {
+        this.addedToDatabase();
+      }
 
       if (!addonData._sourceBundle) {
         throw new Error("Expected passed argument to contain a path");
       }
 
       this._sourceBundle = addonData._sourceBundle;
     }
   }
@@ -287,24 +290,17 @@ class AddonInternal {
   get wrapper() {
     if (!this._wrapper) {
       this._wrapper = new AddonWrapper(this);
     }
     return this._wrapper;
   }
 
   addedToDatabase() {
-    if (this._installLocation) {
-      this.location = this._installLocation.name;
-    } else if (this.location) {
-      this._installLocation = XPIProvider.installLocationsByName[this.location];
-    }
-
-    this._key = `${this.location}:${this.id}`;
-
+    this._key = `${this.location.name}:${this.id}`;
     this.inDatabase = true;
   }
 
   get selectedLocale() {
     if (this._selectedLocale)
       return this._selectedLocale;
 
     /**
@@ -352,17 +348,17 @@ class AddonInternal {
     return this._selectedLocale;
   }
 
   get providesUpdatesSecurely() {
     return !this.updateURL || this.updateURL.startsWith("https:");
   }
 
   get isCorrectlySigned() {
-    switch (this._installLocation.name) {
+    switch (this.location.name) {
       case KEY_APP_SYSTEM_ADDONS:
         // System add-ons must be signed by the system key.
         return this.signedState == AddonManager.SIGNEDSTATE_SYSTEM;
 
       case KEY_APP_SYSTEM_DEFAULTS:
       case KEY_APP_TEMPORARY:
         // Temporary and built-in system add-ons do not require signing.
         return true;
@@ -382,17 +378,17 @@ class AddonInternal {
     return this.signedState > AddonManager.SIGNEDSTATE_MISSING;
   }
 
   get isCompatible() {
     return this.isCompatibleWith();
   }
 
   get hidden() {
-    return this._installLocation.isSystem;
+    return this.location.isSystem;
   }
 
   get disabled() {
     return (this.userDisabled || this.appDisabled || this.softDisabled);
   }
 
   get isPlatformCompatible() {
     if (this.targetPlatforms.length == 0)
@@ -579,17 +575,19 @@ class AddonInternal {
       if (this.inDatabase)
         XPIDatabase.updateAddonDisabledState(this);
       else
         this.appDisabled = !XPIDatabase.isUsableAddon(this);
     }
   }
 
   toJSON() {
-    return copyProperties(this, PROP_JSON_FIELDS);
+    let obj = copyProperties(this, PROP_JSON_FIELDS);
+    obj.location = this.location.name;
+    return obj;
   }
 
   /**
    * When an add-on install is pending its metadata will be cached in a file.
    * This method reads particular properties of that metadata that may be newer
    * than that in the extension manifest, like compatibility information.
    *
    * @param {Object} aObj
@@ -619,21 +617,21 @@ class AddonInternal {
         permissions |= AddonManager.PERM_CAN_ENABLE;
       } else if (this.type != "theme") {
         permissions |= AddonManager.PERM_CAN_DISABLE;
       }
     }
 
     // Add-ons that are in locked install locations, or are pending uninstall
     // cannot be upgraded or uninstalled
-    if (!this._installLocation.locked && !this.pendingUninstall) {
+    if (!this.location.locked && !this.pendingUninstall) {
       // System add-on upgrades are triggered through a different mechanism (see updateSystemAddons())
-      let isSystem = this._installLocation.isSystem;
+      let isSystem = this.location.isSystem;
       // Add-ons that are installed by a file link cannot be upgraded.
-      if (!this._installLocation.isLinkedAddon(this.id) && !isSystem) {
+      if (!this.location.isLinkedAddon(this.id) && !isSystem) {
         permissions |= AddonManager.PERM_CAN_UPGRADE;
       }
 
       permissions |= AddonManager.PERM_CAN_UNINSTALL;
     }
 
     if (Services.policies &&
         !Services.policies.isAllowed(`modify-extension:${this.id}`)) {
@@ -678,17 +676,17 @@ AddonWrapper = class {
     return XPIInternal.getExternalType(addonFor(this).type);
   }
 
   get isWebExtension() {
     return isWebExtension(addonFor(this).type);
   }
 
   get temporarilyInstalled() {
-    return addonFor(this)._installLocation == XPIInternal.TemporaryInstallLocation;
+    return addonFor(this).location.isTemporary;
   }
 
   get aboutURL() {
     return this.isActive ? addonFor(this).aboutURL : null;
   }
 
   get optionsURL() {
     if (!this.isActive) {
@@ -851,18 +849,18 @@ AddonWrapper = class {
 
   get pendingUpgrade() {
     let addon = addonFor(this);
     return addon.pendingUpgrade ? addon.pendingUpgrade.wrapper : null;
   }
 
   get scope() {
     let addon = addonFor(this);
-    if (addon._installLocation)
-      return addon._installLocation.scope;
+    if (addon.location)
+      return addon.location.scope;
 
     return AddonManager.SCOPE_PROFILE;
   }
 
   get pendingOperations() {
     let addon = addonFor(this);
     let pending = 0;
     if (!(addon.inDatabase)) {
@@ -961,24 +959,24 @@ AddonWrapper = class {
   }
 
   get hidden() {
     return addonFor(this).hidden;
   }
 
   get isSystem() {
     let addon = addonFor(this);
-    return addon._installLocation.isSystem;
+    return addon.location.isSystem;
   }
 
   // Returns true if Firefox Sync should sync this addon. Only addons
   // in the profile install location are considered syncable.
   get isSyncable() {
     let addon = addonFor(this);
-    return (addon._installLocation.name == KEY_APP_PROFILE);
+    return (addon.location.name == KEY_APP_PROFILE);
   }
 
   get userPermissions() {
     return addonFor(this).userPermissions;
   }
 
   isCompatibleWith(aAppVersion, aPlatformVersion) {
     return addonFor(this).isCompatibleWith(aAppVersion, aPlatformVersion);
@@ -986,17 +984,17 @@ AddonWrapper = class {
 
   async uninstall(alwaysAllowUndo) {
     let addon = addonFor(this);
     return XPIInstall.uninstallAddon(addon, alwaysAllowUndo);
   }
 
   cancelUninstall() {
     let addon = addonFor(this);
-    XPIProvider.cancelUninstallAddon(addon);
+    XPIInstall.cancelUninstallAddon(addon);
   }
 
   findUpdates(aListener, aReason, aAppVersion, aPlatformVersion) {
     new UpdateChecker(addonFor(this), aListener, aReason, aAppVersion, aPlatformVersion);
   }
 
   // Returns true if there was an update in progress, false if there was no update to cancel
   cancelUpdate() {
@@ -1270,16 +1268,19 @@ function _filterDB(addonDB, aFilter) {
 
 this.XPIDatabase = {
   // true if the database connection has been opened
   initialized: false,
   // The database file
   jsonFile: FileUtils.getFile(KEY_PROFILEDIR, [FILE_JSON_DB], true),
   rebuildingDatabase: false,
   syncLoadingDB: false,
+  // Add-ons from the database in locations which are no longer
+  // supported.
+  orphanedAddons: [],
 
   _saveTask: null,
 
   // Saved error object if we fail to read an existing database
   _loadError: null,
 
   // Saved error object if we fail to save the database
   _saveError: null,
@@ -1358,17 +1359,17 @@ this.XPIDatabase = {
     if (!this.addonDB) {
       // We never loaded the database?
       throw new Error("Attempt to save database without loading it first");
     }
 
     let toSave = {
       schemaVersion: DB_SCHEMA,
       addons: Array.from(this.addonDB.values())
-                   .filter(addon => addon.location != KEY_APP_TEMPORARY),
+                   .filter(addon => !addon.location.isTemporary),
     };
     return toSave;
   },
 
   /**
    * Synchronously loads the database, by running the normal async load
    * operation with idle dispatch disabled, and spinning the event loop
    * until it finishes.
@@ -1432,19 +1433,24 @@ this.XPIDatabase = {
             loadedAddon.path = descriptorToPath(loadedAddon.descriptor);
           }
           loadedAddon._sourceBundle = new nsIFile(loadedAddon.path);
         } catch (e) {
           // We can fail here when the path is invalid, usually from the
           // wrong OS
           logger.warn("Could not find source bundle for add-on " + loadedAddon.id, e);
         }
+        loadedAddon.location = XPIStates.getLocation(loadedAddon.location);
 
         let newAddon = new AddonInternal(loadedAddon);
-        addonDB.set(newAddon._key, newAddon);
+        if (loadedAddon.location) {
+          addonDB.set(newAddon._key, newAddon);
+        } else {
+          this.orphanedAddons.push(newAddon);
+        }
       });
 
       parseTimer.done();
       this.addonDB = addonDB;
       logger.debug("Successfully read XPI database");
       this.initialized = true;
     } catch (e) {
       // If we catch and log a SyntaxError from the JSON
@@ -1772,17 +1778,17 @@ this.XPIDatabase = {
   /**
    * Asynchronously get all the add-ons in a particular install location.
    *
    * @param {string} aLocation
    *        The name of the install location
    * @returns {Promise<Array<AddonInternal>>}
    */
   getAddonsInLocation(aLocation) {
-    return this.getAddonList(aAddon => aAddon._installLocation.name == aLocation);
+    return this.getAddonList(aAddon => aAddon.location.name == aLocation);
   },
 
   /**
    * Asynchronously gets the add-on with the specified ID that is visible.
    *
    * @param {string} aId
    *        The ID of the add-on to retrieve
    * @returns {Promise<AddonInternal?>}
@@ -1896,22 +1902,22 @@ this.XPIDatabase = {
    *
    * @returns {boolean} Whether the addon should be disabled for being legacy
    */
   isDisabledLegacy(addon) {
     return (!AddonSettings.ALLOW_LEGACY_EXTENSIONS &&
             LEGACY_TYPES.has(addon.type) &&
 
             // Legacy add-ons are allowed in the system location.
-            !addon._installLocation.isSystem &&
+            !addon.location.isSystem &&
 
             // Legacy extensions may be installed temporarily in
             // non-release builds.
             !(AppConstants.MOZ_ALLOW_LEGACY_EXTENSIONS &&
-              addon._installLocation.name == KEY_APP_TEMPORARY) &&
+              addon.location.isTemporary) &&
 
             // Properly signed legacy extensions are allowed.
             addon.signedState !== AddonManager.SIGNEDSTATE_PRIVILEGED);
   },
 
   /**
    * Calculates whether an add-on should be appDisabled or not.
    *
@@ -2041,19 +2047,19 @@ this.XPIDatabase = {
    *        The AddonInternal being removed
    */
   removeAddonMetadata(aAddon) {
     this.addonDB.delete(aAddon._key);
     this.saveChanges();
   },
 
   updateXPIStates(addon) {
-    let xpiState = XPIStates.getAddon(addon.location, addon.id);
-    if (xpiState) {
-      xpiState.syncWithDB(addon);
+    let state = addon.location && addon.location.get(addon.id);
+    if (state) {
+      state.syncWithDB(addon);
       XPIStates.save();
     }
   },
 
   /**
    * Synchronously marks a AddonInternal as visible marking all other
    * instances with the same ID as not visible.
    *
@@ -2077,17 +2083,17 @@ this.XPIDatabase = {
   },
 
   /**
    * Synchronously marks a given add-on ID visible in a given location,
    * instances with the same ID as not visible.
    *
    * @param {string} aId
    *        The ID of the add-on to make visible
-   * @param {InstallLocation} aLocation
+   * @param {XPIStateLocation} aLocation
    *        The location in which to make the add-on visible.
    * @returns {AddonInternal?}
    *        The add-on instance which was marked visible, if any.
    */
   makeAddonLocationVisible(aId, aLocation) {
     logger.debug(`Make addon ${aId} visible in location ${aLocation}`);
     let result;
     for (let [, addon] of this.addonDB) {
@@ -2261,25 +2267,17 @@ this.XPIDatabase = {
     // If the add-on is not visible or the add-on is not changing state then
     // there is no need to do anything else
     if (!aAddon.visible || (wasDisabled == isDisabled))
       return undefined;
 
     // Flag that active states in the database need to be updated on shutdown
     Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
 
-    // Sync with XPIStates.
-    let xpiState = XPIStates.getAddon(aAddon.location, aAddon.id);
-    if (xpiState) {
-      xpiState.syncWithDB(aAddon);
-      XPIStates.save();
-    } else {
-      // There should always be an xpiState
-      logger.warn("No XPIState for ${id} in ${location}", aAddon);
-    }
+    this.updateXPIStates(aAddon);
 
     // Have we just gone back to the current state?
     if (isDisabled != aAddon.active) {
       AddonManagerPrivate.callAddonListeners("onOperationCancelled", wrapper);
     } else {
       if (isDisabled) {
         AddonManagerPrivate.callAddonListeners("onDisabling", wrapper, false);
       } else {
@@ -2297,21 +2295,17 @@ this.XPIDatabase = {
         AddonManagerPrivate.callAddonListeners("onEnabled", wrapper);
       }
     }
 
     // Notify any other providers that a new theme has been enabled
     if (isTheme(aAddon.type)) {
       if (!isDisabled) {
         AddonManagerPrivate.notifyAddonChanged(aAddon.id, aAddon.type);
-
-        if (xpiState) {
-          xpiState.syncWithDB(aAddon);
-          XPIStates.save();
-        }
+        this.updateXPIStates(aAddon);
       } else if (isDisabled && !aBecauseSelecting) {
         AddonManagerPrivate.notifyAddonChanged(null, "theme");
       }
     }
 
     return isDisabled;
   },
 
@@ -2344,21 +2338,21 @@ this.XPIDatabaseReconcile = {
    *        The add-on map to flatten.
    * @param {string?} [hideLocation]
    *        An optional location from which to hide any add-ons.
    * @returns {Map<string, AddonInternal>}
    */
   flattenByID(addonMap, hideLocation) {
     let map = new Map();
 
-    for (let installLocation of XPIProvider.installLocations) {
-      if (installLocation.name == hideLocation)
+    for (let loc of XPIStates.locations()) {
+      if (loc.name == hideLocation)
         continue;
 
-      let locationMap = addonMap.get(installLocation.name);
+      let locationMap = addonMap.get(loc.name);
       if (!locationMap)
         continue;
 
       for (let [id, addon] of locationMap) {
         if (!map.has(id))
           map.set(id, addon);
       }
     }
@@ -2396,17 +2390,17 @@ this.XPIDatabaseReconcile = {
   /**
    * Called to add the metadata for an add-on in one of the install locations
    * to the database. This can be called in three different cases. Either an
    * add-on has been dropped into the location from outside of Firefox, or
    * an add-on has been installed through the application, or the database
    * has been upgraded or become corrupt and add-on data has to be reloaded
    * into it.
    *
-   * @param {InstallLocation} aInstallLocation
+   * @param {XPIStateLocation} aLocation
    *        The install location containing the add-on
    * @param {string} aId
    *        The ID of the add-on
    * @param {XPIState} aAddonState
    *        The new state of the add-on
    * @param {AddonInternal?} [aNewAddon]
    *        The manifest for the new add-on if it has already been loaded
    * @param {string?} [aOldAppVersion]
@@ -2414,19 +2408,19 @@ this.XPIDatabaseReconcile = {
    *        if it is a new profile or the version is unknown
    * @param {string?} [aOldPlatformVersion]
    *        The version of the platform last run with this profile or null
    *        if it is a new profile or the version is unknown
    * @returns {boolean}
    *        A boolean indicating if flushing caches is required to complete
    *        changing this add-on
    */
-  addMetadata(aInstallLocation, aId, aAddonState, aNewAddon, aOldAppVersion,
+  addMetadata(aLocation, aId, aAddonState, aNewAddon, aOldAppVersion,
               aOldPlatformVersion) {
-    logger.debug("New add-on " + aId + " installed in " + aInstallLocation.name);
+    logger.debug(`New add-on ${aId} installed in ${aLocation.name}`);
 
     // We treat this is a new install if,
     //
     // a) It was explicitly registered as a staged install in the last
     //    session, or,
     // b) We're not currently migrating or rebuilding a corrupt database. In
     //    that case, we can assume this add-on was found during a routine
     //    directory scan.
@@ -2437,64 +2431,60 @@ this.XPIDatabaseReconcile = {
     let isDetectedInstall = isNewInstall && !aNewAddon;
 
     // Load the manifest if necessary and sanity check the add-on ID
     let unsigned;
     try {
       if (!aNewAddon) {
         // Load the manifest from the add-on.
         let file = new nsIFile(aAddonState.path);
-        aNewAddon = XPIInstall.syncLoadManifestFromFile(file, aInstallLocation);
+        aNewAddon = XPIInstall.syncLoadManifestFromFile(file, aLocation);
       }
       // The add-on in the manifest should match the add-on ID.
       if (aNewAddon.id != aId) {
-        throw new Error("Invalid addon ID: expected addon ID " + aId +
-                        ", found " + aNewAddon.id + " in manifest");
+        throw new Error(`Invalid addon ID: expected addon ID ${aId}, found ${aNewAddon.id} in manifest`);
       }
 
       unsigned = XPIDatabase.mustSign(aNewAddon.type) && !aNewAddon.isCorrectlySigned;
       if (unsigned) {
           throw Error(`Extension ${aNewAddon.id} is not correctly signed`);
       }
     } catch (e) {
-      logger.warn("addMetadata: Add-on " + aId + " is invalid", e);
+      logger.warn(`addMetadata: Add-on ${aId} is invalid`, e);
 
       // Remove the invalid add-on from the install location if the install
       // location isn't locked
-      if (aInstallLocation.isLinkedAddon(aId))
+      if (aLocation.isLinkedAddon(aId))
         logger.warn("Not uninstalling invalid item because it is a proxy file");
-      else if (aInstallLocation.locked)
+      else if (aLocation.locked)
         logger.warn("Could not uninstall invalid item from locked install location");
       else if (unsigned && !isNewInstall)
         logger.warn("Not uninstalling existing unsigned add-on");
       else
-        aInstallLocation.uninstallAddon(aId);
+        aLocation.installer.uninstallAddon(aId);
       return null;
     }
 
     // Update the AddonInternal properties.
     aNewAddon.installDate = aAddonState.mtime;
     aNewAddon.updateDate = aAddonState.mtime;
 
     // Assume that add-ons in the system add-ons install location aren't
     // foreign and should default to enabled.
-    aNewAddon.foreignInstall = isDetectedInstall &&
-                               aInstallLocation.name != KEY_APP_SYSTEM_ADDONS &&
-                               aInstallLocation.name != KEY_APP_SYSTEM_DEFAULTS;
+    aNewAddon.foreignInstall = isDetectedInstall && !aLocation.isSystem;
 
     // appDisabled depends on whether the add-on is a foreignInstall so update
     aNewAddon.appDisabled = !XPIDatabase.isUsableAddon(aNewAddon);
 
     if (isDetectedInstall && aNewAddon.foreignInstall) {
       // If the add-on is a foreign install and is in a scope where add-ons
       // that were dropped in should default to disabled then disable it
       let disablingScopes = Services.prefs.getIntPref(PREF_EM_AUTO_DISABLED_SCOPES, 0);
-      if (aInstallLocation.scope & disablingScopes) {
-        logger.warn("Disabling foreign installed add-on " + aNewAddon.id + " in "
-            + aInstallLocation.name);
+      if (aLocation.scope & disablingScopes) {
+        logger.warn(`Disabling foreign installed add-on ${aNewAddon.id} in ${aLocation.name}`);
         aNewAddon.userDisabled = true;
         aNewAddon.seen = false;
       }
     }
 
     return XPIDatabase.addToDatabase(aNewAddon, aAddonState.path);
   },
 
@@ -2502,57 +2492,59 @@ this.XPIDatabaseReconcile = {
    * Called when an add-on has been removed.
    *
    * @param {AddonInternal} aOldAddon
    *        The AddonInternal as it appeared the last time the application
    *        ran
    */
   removeMetadata(aOldAddon) {
     // This add-on has disappeared
-    logger.debug("Add-on " + aOldAddon.id + " removed from " + aOldAddon.location);
+    logger.debug("Add-on " + aOldAddon.id + " removed from " + aOldAddon.location.name);
     XPIDatabase.removeAddonMetadata(aOldAddon);
   },
 
   /**
    * Updates an add-on's metadata and determines. This is called when either the
    * add-on's install directory path or last modified time has changed.
    *
-   * @param {InstallLocation} aInstallLocation
+   * @param {XPIStateLocation} aLocation
    *        The install location containing the add-on
    * @param {AddonInternal} aOldAddon
    *        The AddonInternal as it appeared the last time the application
    *        ran
    * @param {XPIState} aAddonState
    *        The new state of the add-on
    * @param {AddonInternal?} [aNewAddon]
    *        The manifest for the new add-on if it has already been loaded
    * @returns {boolean?}
    *        A boolean indicating if flushing caches is required to complete
    *        changing this add-on
    */
-  updateMetadata(aInstallLocation, aOldAddon, aAddonState, aNewAddon) {
-    logger.debug("Add-on " + aOldAddon.id + " modified in " + aInstallLocation.name);
+  updateMetadata(aLocation, aOldAddon, aAddonState, aNewAddon) {
+    logger.debug(`Add-on ${aOldAddon.id} modified in ${aLocation.name}`);
 
     try {
       // If there isn't an updated install manifest for this add-on then load it.
       if (!aNewAddon) {
         let file = new nsIFile(aAddonState.path);
-        aNewAddon = XPIInstall.syncLoadManifestFromFile(file, aInstallLocation, aOldAddon);
+        aNewAddon = XPIInstall.syncLoadManifestFromFile(file, aLocation, aOldAddon);
       }
 
       // The ID in the manifest that was loaded must match the ID of the old
       // add-on.
       if (aNewAddon.id != aOldAddon.id)
-        throw new Error("Incorrect id in install manifest for existing add-on " + aOldAddon.id);
+        throw new Error(`Incorrect id in install manifest for existing add-on ${aOldAddon.id}`);
     } catch (e) {
-      logger.warn("updateMetadata: Add-on " + aOldAddon.id + " is invalid", e);
+      logger.warn(`updateMetadata: Add-on ${aOldAddon.id} is invalid`, e);
+
       XPIDatabase.removeAddonMetadata(aOldAddon);
-      XPIStates.removeAddon(aOldAddon.location, aOldAddon.id);
-      if (!aInstallLocation.locked)
-        aInstallLocation.uninstallAddon(aOldAddon.id);
+      aOldAddon.location.removeAddon(aOldAddon.id);
+
+      if (!aLocation.locked)
+        aLocation.installer.uninstallAddon(aOldAddon.id);
       else
         logger.warn("Could not uninstall invalid item from locked install location");
 
       return null;
     }
 
     // Set the additional properties on the new AddonInternal
     aNewAddon.updateDate = aAddonState.mtime;
@@ -2560,62 +2552,62 @@ this.XPIDatabaseReconcile = {
     // Update the database
     return XPIDatabase.updateAddonMetadata(aOldAddon, aNewAddon, aAddonState.path);
   },
 
   /**
    * Updates an add-on's path for when the add-on has moved in the
    * filesystem but hasn't changed in any other way.
    *
-   * @param {InstallLocation} aInstallLocation
+   * @param {XPIStateLocation} aLocation
    *        The install location containing the add-on
    * @param {AddonInternal} aOldAddon
    *        The AddonInternal as it appeared the last time the application
    *        ran
    * @param {XPIState} aAddonState
    *        The new state of the add-on
    * @returns {AddonInternal}
    */
-  updatePath(aInstallLocation, aOldAddon, aAddonState) {
-    logger.debug("Add-on " + aOldAddon.id + " moved to " + aAddonState.path);
+  updatePath(aLocation, aOldAddon, aAddonState) {
+    logger.debug(`Add-on ${aOldAddon.id} moved to ${aAddonState.path}`);
     aOldAddon.path = aAddonState.path;
     aOldAddon._sourceBundle = new nsIFile(aAddonState.path);
 
     return aOldAddon;
   },
 
   /**
    * Called when no change has been detected for an add-on's metadata but the
    * application has changed so compatibility may have changed.
    *
-   * @param {InstallLocation} aInstallLocation
+   * @param {XPIStateLocation} aLocation
    *        The install location containing the add-on
    * @param {AddonInternal} aOldAddon
    *        The AddonInternal as it appeared the last time the application
    *        ran
    * @param {XPIState} aAddonState
    *        The new state of the add-on
    * @param {boolean} [aReloadMetadata = false]
    *        A boolean which indicates whether metadata should be reloaded from
    *        the addon manifests. Default to false.
    * @returns {AddonInternal}
    *        The new addon.
    */
-  updateCompatibility(aInstallLocation, aOldAddon, aAddonState, aReloadMetadata) {
-    logger.debug("Updating compatibility for add-on " + aOldAddon.id + " in " + aInstallLocation.name);
+  updateCompatibility(aLocation, aOldAddon, aAddonState, aReloadMetadata) {
+    logger.debug(`Updating compatibility for add-on ${aOldAddon.id} in ${aLocation.name}`);
 
     let checkSigning = (aOldAddon.signedState === undefined &&
                         AddonSettings.ADDON_SIGNING &&
                         SIGNED_TYPES.has(aOldAddon.type));
 
     let manifest = null;
     if (checkSigning || aReloadMetadata) {
       try {
         let file = new nsIFile(aAddonState.path);
-        manifest = XPIInstall.syncLoadManifestFromFile(file, aInstallLocation);
+        manifest = XPIInstall.syncLoadManifestFromFile(file, aLocation);
       } catch (err) {
         // If we can no longer read the manifest, it is no longer compatible.
         aOldAddon.brokenManifest = true;
         aOldAddon.appDisabled = true;
         return aOldAddon;
       }
     }
 
@@ -2644,17 +2636,17 @@ this.XPIDatabaseReconcile = {
     return aOldAddon;
   },
 
   /**
    * Returns true if this install location is part of the application
    * bundle. Add-ons in these locations are expected to change whenever
    * the application updates.
    *
-   * @param {InstallLocation} location
+   * @param {XPIStateLocation} location
    *        The install location to check.
    * @returns {boolean}
    *        True if this location is part of the application bundle.
    */
   isAppBundledLocation(location) {
     return (location.name == KEY_APP_GLOBAL ||
             location.name == KEY_APP_SYSTEM_DEFAULTS);
   },
@@ -2677,17 +2669,17 @@ this.XPIDatabaseReconcile = {
    *        The schema has changed and all add-on manifests should be re-read.
    * @returns {AddonInternal?}
    *        The updated AddonInternal object for the add-on, if one
    *        could be created.
    */
   updateExistingAddon(oldAddon, xpiState, newAddon, aUpdateCompatibility, aSchemaChange) {
     XPIDatabase.recordAddonTelemetry(oldAddon);
 
-    let installLocation = oldAddon._installLocation;
+    let installLocation = oldAddon.location;
 
     if (xpiState.mtime < oldAddon.updateDate) {
       XPIProvider.setTelemetry(oldAddon.id, "olderFile", {
         mtime: xpiState.mtime,
         oldtime: oldAddon.updateDate
       });
     }
 
@@ -2735,130 +2727,128 @@ this.XPIDatabaseReconcile = {
    * @param {boolean} aSchemaChange
    *        The schema has changed and all add-on manifests should be re-read.
    * @returns {boolean}
    *        A boolean indicating if a change requiring flushing the caches was
    *        detected
    */
   processFileChanges(aManifests, aUpdateCompatibility, aOldAppVersion, aOldPlatformVersion,
                      aSchemaChange) {
-    let findManifest = (aInstallLocation, aId) => {
-      return (aManifests[aInstallLocation.name] &&
-              aManifests[aInstallLocation.name][aId]) || null;
+    let findManifest = (loc, id) => {
+      return (aManifests[loc.name] &&
+              aManifests[loc.name][id]) || null;
     };
 
     let addonExists = addon => addon._sourceBundle.exists();
 
     let previousAddons = new ExtensionUtils.DefaultMap(() => new Map());
     let currentAddons = new ExtensionUtils.DefaultMap(() => new Map());
 
     // Get the previous add-ons from the database and put them into maps by location
     for (let addon of XPIDatabase.getAddons()) {
-      previousAddons.get(addon.location).set(addon.id, addon);
+      previousAddons.get(addon.location.name).set(addon.id, addon);
     }
 
     // Keep track of add-ons whose blocklist status may have changed. We'll check this
     // after everything else.
     let addonsToCheckAgainstBlocklist = [];
 
     // Build the list of current add-ons into similar maps. When add-ons are still
     // present we re-use the add-on objects from the database and update their
     // details directly
     let addonStates = new Map();
-    for (let installLocation of XPIProvider.installLocations) {
-      let locationAddons = currentAddons.get(installLocation.name);
+    for (let location of XPIStates.locations()) {
+      let locationAddons = currentAddons.get(location.name);
 
       // Get all the on-disk XPI states for this location, and keep track of which
       // ones we see in the database.
-      let states = XPIStates.getLocation(installLocation.name) || new Map();
-      let dbAddons = previousAddons.get(installLocation.name) || new Map();
+      let dbAddons = previousAddons.get(location.name) || new Map();
       for (let [id, oldAddon] of dbAddons) {
         // Check if the add-on is still installed
-        let xpiState = states.get(id);
+        let xpiState = location.get(id);
         if (xpiState) {
           let newAddon = this.updateExistingAddon(oldAddon, xpiState,
-                                                  findManifest(installLocation, id),
+                                                  findManifest(location, id),
                                                   aUpdateCompatibility, aSchemaChange);
           if (newAddon) {
             locationAddons.set(newAddon.id, newAddon);
 
             // We need to do a blocklist check later, but the add-on may have changed by then.
             // Avoid storing the current copy and just get one when we need one instead.
             addonsToCheckAgainstBlocklist.push(newAddon.id);
           }
         } else {
           // The add-on is in the DB, but not in xpiState (and thus not on disk).
           this.removeMetadata(oldAddon);
         }
       }
 
-      for (let [id, xpiState] of states) {
+      for (let [id, xpiState] of location) {
         if (locationAddons.has(id))
           continue;
-        let newAddon = findManifest(installLocation, id);
-        let addon = this.addMetadata(installLocation, id, xpiState, newAddon,
+        let newAddon = findManifest(location, id);
+        let addon = this.addMetadata(location, id, xpiState, newAddon,
                                      aOldAppVersion, aOldPlatformVersion);
         if (addon) {
           locationAddons.set(addon.id, addon);
           addonStates.set(addon, xpiState);
         }
       }
     }
 
-    // Remove metadata for any add-ons in install locations that are no
-    // longer supported.
-    for (let [locationName, addons] of previousAddons) {
-      if (!currentAddons.has(locationName)) {
-        for (let oldAddon of addons.values())
-          this.removeMetadata(oldAddon);
-      }
-    }
-
     // Validate the updated system add-ons
     let hideLocation;
     {
-      let systemAddonLocation = XPIProvider.installLocationsByName[KEY_APP_SYSTEM_ADDONS];
+      let systemAddonLocation = XPIStates.getLocation(KEY_APP_SYSTEM_ADDONS);
       let addons = currentAddons.get(systemAddonLocation.name);
 
-      if (!systemAddonLocation.isValid(addons)) {
+      if (!systemAddonLocation.installer.isValid(addons)) {
         // Hide the system add-on updates if any are invalid.
         logger.info("One or more updated system add-ons invalid, falling back to defaults.");
         hideLocation = systemAddonLocation.name;
       }
     }
 
     // Apply startup changes to any currently-visible add-ons, and
     // uninstall any which were previously visible, but aren't anymore.
     let previousVisible = this.getVisibleAddons(previousAddons);
     let currentVisible = this.flattenByID(currentAddons, hideLocation);
 
+    for (let addon of XPIDatabase.orphanedAddons.splice(0)) {
+      if (addon.visible) {
+        previousVisible.set(addon.id, addon);
+      }
+    }
+
     let promises = [];
     for (let [id, addon] of currentVisible) {
       // If we have a stored manifest for the add-on, it came from the
       // startup data cache, and supersedes any previous XPIStates entry.
-      let xpiState = (!findManifest(addon._installLocation, id) &&
+      let xpiState = (!findManifest(addon.location, id) &&
                       addonStates.get(addon));
 
       promises.push(this.applyStartupChange(addon, previousVisible.get(id), xpiState));
       previousVisible.delete(id);
     }
 
     if (promises.some(p => p)) {
       XPIInternal.awaitPromise(Promise.all(promises));
     }
 
     for (let [id, addon] of previousVisible) {
-      if (addonExists(addon)) {
-        XPIInternal.BootstrapScope.get(addon).uninstall();
+      if (addon.location) {
+        if (addonExists(addon)) {
+          XPIInternal.BootstrapScope.get(addon).uninstall();
+        }
+        addon.location.removeAddon(id);
+        addon.visible = false;
+        addon.active = false;
       }
+
       AddonManagerPrivate.addStartupChange(AddonManager.STARTUP_CHANGE_UNINSTALLED, id);
-      XPIStates.removeAddon(addon.location, id);
-
-      addon.visible = false;
-      addon.active = false;
     }
     if (previousVisible.size) {
       XPIInstall.flushChromeCaches();
     }
 
     // Finally update XPIStates to match everything
     for (let [locationName, locationAddons] of currentAddons) {
       for (let [id, addon] of locationAddons) {
@@ -2912,17 +2902,17 @@ this.XPIDatabaseReconcile = {
 
     let isActive = !currentAddon.disabled;
     let wasActive = previousAddon ? previousAddon.active : currentAddon.active;
 
     if (previousAddon) {
       if (previousAddon !== currentAddon) {
         AddonManagerPrivate.addStartupChange(AddonManager.STARTUP_CHANGE_CHANGED, id);
 
-        if (previousAddon._installLocation &&
+        if (previousAddon.location &&
             previousAddon._sourceBundle.exists() &&
             !previousAddon._sourceBundle.equals(currentAddon._sourceBundle)) {
           promise = XPIInternal.BootstrapScope.get(previousAddon).update(
             currentAddon);
         } else {
           let reason = XPIInstall.newVersionReason(previousAddon.version, currentAddon.version);
           XPIInternal.BootstrapScope.get(currentAddon).install(
             reason, false, {oldVersion: previousAddon.version});
--- a/toolkit/mozapps/extensions/internal/XPIInstall.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm
@@ -83,22 +83,21 @@ const PREF_DISTRO_ADDONS_PERMS        = 
 const PREF_INSTALL_REQUIRESECUREORIGIN = "extensions.install.requireSecureOrigin";
 const PREF_PENDING_OPERATIONS         = "extensions.pendingOperations";
 const PREF_SYSTEM_ADDON_UPDATE_URL    = "extensions.systemAddon.update.url";
 const PREF_XPI_ENABLED                = "xpinstall.enabled";
 const PREF_XPI_DIRECT_WHITELISTED     = "xpinstall.whitelist.directRequest";
 const PREF_XPI_FILE_WHITELISTED       = "xpinstall.whitelist.fileRequest";
 const PREF_XPI_WHITELIST_REQUIRED     = "xpinstall.whitelist.required";
 
-/* globals BOOTSTRAP_REASONS, KEY_APP_SYSTEM_ADDONS, KEY_APP_SYSTEM_DEFAULTS, KEY_APP_TEMPORARY, PREF_BRANCH_INSTALLED_ADDON, PREF_SYSTEM_ADDON_SET, TEMPORARY_ADDON_SUFFIX, SIGNED_TYPES, TOOLKIT_ID, XPI_PERMISSION, XPIStates, getExternalType, isTheme, isWebExtension */
+/* globals BOOTSTRAP_REASONS, KEY_APP_SYSTEM_ADDONS, KEY_APP_SYSTEM_DEFAULTS, PREF_BRANCH_INSTALLED_ADDON, PREF_SYSTEM_ADDON_SET, TEMPORARY_ADDON_SUFFIX, SIGNED_TYPES, TOOLKIT_ID, XPI_PERMISSION, XPIStates, getExternalType, isTheme, isWebExtension */
 const XPI_INTERNAL_SYMBOLS = [
   "BOOTSTRAP_REASONS",
   "KEY_APP_SYSTEM_ADDONS",
   "KEY_APP_SYSTEM_DEFAULTS",
-  "KEY_APP_TEMPORARY",
   "PREF_BRANCH_INSTALLED_ADDON",
   "PREF_SYSTEM_ADDON_SET",
   "SIGNED_TYPES",
   "TEMPORARY_ADDON_SUFFIX",
   "TOOLKIT_ID",
   "XPI_PERMISSION",
   "XPIStates",
   "getExternalType",
@@ -778,17 +777,17 @@ function generateTemporaryInstallID(aFil
   const sess = TEMP_INSTALL_ID_GEN_SESSION;
   hasher.update(sess, sess.length);
   hasher.update(data, data.length);
   let id = `${getHashStringForCrypto(hasher)}${TEMPORARY_ADDON_SUFFIX}`;
   logger.info(`Generated temp id ${id} (${sess.join("")}) for ${aFile.path}`);
   return id;
 }
 
-var loadManifest = async function(aPackage, aInstallLocation, aOldAddon) {
+var loadManifest = async function(aPackage, aLocation, aOldAddon) {
   async function loadFromRDF(aUri) {
     let manifest = await aPackage.readString("install.rdf");
     let addon = await loadManifestFromRDF(aUri, manifest, aPackage);
 
     if (await aPackage.hasResource("icon.png")) {
       addon.icons[32] = "icon.png";
       addon.icons[48] = "icon.png";
     }
@@ -807,17 +806,17 @@ var loadManifest = async function(aPacka
   }
 
   let isWebExtension = entry == FILE_WEB_MANIFEST;
   let addon = isWebExtension ?
               await loadManifestFromWebManifest(aPackage.rootURI) :
               await loadFromRDF(aPackage.getURI("install.rdf"));
 
   addon._sourceBundle = aPackage.file;
-  addon._installLocation = aInstallLocation;
+  addon.location = aLocation;
 
   addon.size = 0;
   await aPackage.iterFiles(entry => {
     if (!entry.isDir) {
       addon.size += entry.size;
     }
   });
 
@@ -826,17 +825,17 @@ var loadManifest = async function(aPacka
 
   if (isWebExtension && !addon.id) {
     if (cert) {
       addon.id = cert.commonName;
       if (!gIDTest.test(addon.id)) {
         throw new Error(`Webextension is signed with an invalid id (${addon.id})`);
       }
     }
-    if (!addon.id && aInstallLocation.name == KEY_APP_TEMPORARY) {
+    if (!addon.id && aLocation.isTemporary) {
       addon.id = generateTemporaryInstallID(aPackage.file);
     }
   }
 
   await addon.updateBlocklistState({oldAddon: aOldAddon});
   addon.appDisabled = !XPIDatabase.isUsableAddon(addon);
 
   defineSyncGUID(addon);
@@ -844,42 +843,42 @@ var loadManifest = async function(aPacka
   return addon;
 };
 
 /**
  * Loads an add-on's manifest from the given file or directory.
  *
  * @param {nsIFile} aFile
  *        The file to load the manifest from.
- * @param {InstallLocation} aInstallLocation
+ * @param {XPIStateLocation} aLocation
  *        The install location the add-on is installed in, or will be
  *        installed to.
  * @param {AddonInternal?} aOldAddon
  *        The currently-installed add-on with the same ID, if one exist.
  *        This is used to migrate user settings like the add-on's
  *        disabled state.
  * @returns {AddonInternal}
  *        The parsed Addon object for the file's manifest.
  */
-var loadManifestFromFile = async function(aFile, aInstallLocation, aOldAddon) {
+var loadManifestFromFile = async function(aFile, aLocation, aOldAddon) {
   let pkg = Package.get(aFile);
   try {
-    let addon = await loadManifest(pkg, aInstallLocation, aOldAddon);
+    let addon = await loadManifest(pkg, aLocation, aOldAddon);
     return addon;
   } finally {
     pkg.close();
   }
 };
 
 /*
  * A synchronous method for loading an add-on's manifest. Do not use
  * this.
  */
-function syncLoadManifestFromFile(aFile, aInstallLocation, aOldAddon) {
-  return XPIInternal.awaitPromise(loadManifestFromFile(aFile, aInstallLocation, aOldAddon));
+function syncLoadManifestFromFile(aFile, aLocation, aOldAddon) {
+  return XPIInternal.awaitPromise(loadManifestFromFile(aFile, aLocation, aOldAddon));
 }
 
 function flushChromeCaches() {
   // Init this, so it will get the notification.
   Services.obs.notifyObservers(null, "startupcache-invalidate");
   // Flush message manager cached scripts
   Services.obs.notifyObservers(null, "message-manager-flush-caches");
   // Also dispatch this event to child processes
@@ -958,21 +957,21 @@ function getSignedStatus(aRv, aCert, aAd
       // Any other error indicates that either the add-on isn't signed or it
       // is signed by a signature that doesn't chain to the trusted root.
       return AddonManager.SIGNEDSTATE_UNKNOWN;
   }
 }
 
 function shouldVerifySignedState(aAddon) {
   // Updated system add-ons should always have their signature checked
-  if (aAddon._installLocation.name == KEY_APP_SYSTEM_ADDONS)
+  if (aAddon.location.name == KEY_APP_SYSTEM_ADDONS)
     return true;
 
   // We don't care about signatures for default system add-ons
-  if (aAddon._installLocation.name == KEY_APP_SYSTEM_DEFAULTS)
+  if (aAddon.location.name == KEY_APP_SYSTEM_DEFAULTS)
     return false;
 
   // Otherwise only check signatures if signing is enabled and the add-on is one
   // of the signed types.
   return AddonSettings.ADDON_SIGNING && SIGNED_TYPES.has(aAddon.type);
 }
 
 /**
@@ -1333,17 +1332,17 @@ function getHashStringForCrypto(aCrypto)
 /**
  * Base class for objects that manage the installation of an addon.
  * This class isn't instantiated directly, see the derived classes below.
  */
 class AddonInstall {
   /**
    * Instantiates an AddonInstall.
    *
-   * @param {InstallLocation} installLocation
+   * @param {XPIStateLocation} installLocation
    *        The install location the add-on will be installed into
    * @param {nsIURL} url
    *        The nsIURL to get the add-on from. If this is an nsIFileURL then
    *        the add-on will not need to be downloaded
    * @param {Object} [options = {}]
    *        Additional options for the install
    * @param {string} [options.hash]
    *        An optional hash for the add-on
@@ -1357,17 +1356,17 @@ class AddonInstall {
    *        Optional icons for the add-on
    * @param {string} [options.version]
    *        An optional version for the add-on
    * @param {function(string) : Promise<void>} [options.promptHandler]
    *        A callback to prompt the user before installing.
    */
   constructor(installLocation, url, options = {}) {
     this.wrapper = new AddonInstallWrapper(this);
-    this.installLocation = installLocation;
+    this.location = installLocation;
     this.sourceURI = url;
 
     if (options.hash) {
       let hashSplit = options.hash.toLowerCase().split(":");
       this.originalHash = {
         algorithm: hashSplit[0],
         data: hashSplit[1]
       };
@@ -1469,19 +1468,19 @@ class AddonInstall {
       logger.debug("Cancelling download of " + this.sourceURI.spec);
       this.state = AddonManager.STATE_CANCELLED;
       XPIInstall.installs.delete(this);
       this._callInstallListeners("onDownloadCancelled");
       this.removeTemporaryFile();
       break;
     case AddonManager.STATE_INSTALLED:
       logger.debug("Cancelling install of " + this.addon.id);
-      let xpi = getFile(`${this.addon.id}.xpi`, this.installLocation.getStagingDir());
+      let xpi = getFile(`${this.addon.id}.xpi`, this.location.installer.getStagingDir());
       flushJarCache(xpi);
-      this.installLocation.cleanStagingDir([this.addon.id, this.addon.id + ".xpi"]);
+      this.location.installer.cleanStagingDir([this.addon.id, this.addon.id + ".xpi"]);
       this.state = AddonManager.STATE_CANCELLED;
       XPIInstall.installs.delete(this);
 
       if (this.existingAddon) {
         delete this.existingAddon.pendingUpgrade;
         this.existingAddon.pendingUpgrade = null;
       }
 
@@ -1491,17 +1490,17 @@ class AddonInstall {
       break;
     case AddonManager.STATE_POSTPONED:
       logger.debug(`Cancelling postponed install of ${this.addon.id}`);
       this.state = AddonManager.STATE_CANCELLED;
       XPIInstall.installs.delete(this);
       this._callInstallListeners("onInstallCancelled");
       this.removeTemporaryFile();
 
-      let stagingDir = this.installLocation.getStagingDir();
+      let stagingDir = this.location.installer.getStagingDir();
       let stagedAddon = stagingDir.clone();
 
       this.unstageInstall(stagedAddon);
     default:
       throw new Error("Cannot cancel install of " + this.sourceURI.spec +
                       " from this state (" + this.state + ")");
     }
   }
@@ -1531,29 +1530,29 @@ class AddonInstall {
   }
 
   /**
    * Removes the temporary file owned by this AddonInstall if there is one.
    */
   removeTemporaryFile() {
     // Only proceed if this AddonInstall owns its XPI file
     if (!this.ownsTempFile) {
-      this.logger.debug("removeTemporaryFile: " + this.sourceURI.spec + " does not own temp file");
+      this.logger.debug(`removeTemporaryFile: ${this.sourceURI.spec} does not own temp file`);
       return;
     }
 
     try {
-      this.logger.debug("removeTemporaryFile: " + this.sourceURI.spec + " removing temp file " +
-          this.file.path);
+      this.logger.debug(`removeTemporaryFile: ${this.sourceURI.spec} removing temp file ` +
+                        this.file.path);
       this.file.remove(true);
       this.ownsTempFile = false;
     } catch (e) {
-      this.logger.warn("Failed to remove temporary file " + this.file.path + " for addon " +
-          this.sourceURI.spec,
-          e);
+      this.logger.warn(`Failed to remove temporary file ${this.file.path} for addon ` +
+                       this.sourceURI.spec,
+                       e);
     }
   }
 
   /**
    * Updates the sourceURI and releaseNotesURI values on the Addon being
    * installed by this AddonInstall instance.
    */
   updateAddonURIs() {
@@ -1575,17 +1574,17 @@ class AddonInstall {
     try {
       pkg = Package.get(file);
     } catch (e) {
       return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]);
     }
 
     try {
       try {
-        this.addon = await loadManifest(pkg, this.installLocation, this.existingAddon);
+        this.addon = await loadManifest(pkg, this.location, this.existingAddon);
       } catch (e) {
         return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, e]);
       }
 
       if (!this.addon.id) {
         let err = new Error(`Cannot find id for addon ${file.path}`);
         return Promise.reject([AddonManager.ERROR_CORRUPT_FILE, err]);
       }
@@ -1732,35 +1731,35 @@ class AddonInstall {
       this._callInstallListeners("onInstallCancelled");
       return;
     }
 
     // Find and cancel any pending installs for the same add-on in the same
     // install location
     for (let install of XPIInstall.installs) {
       if (install.state == AddonManager.STATE_INSTALLED &&
-          install.installLocation == this.installLocation &&
+          install.location == this.location &&
           install.addon.id == this.addon.id) {
         logger.debug(`Cancelling previous pending install of ${install.addon.id}`);
         install.cancel();
       }
     }
 
     let isUpgrade = this.existingAddon &&
-                    this.existingAddon._installLocation == this.installLocation;
+                    this.existingAddon.location == this.location;
 
     logger.debug("Starting install of " + this.addon.id + " from " + this.sourceURI.spec);
     AddonManagerPrivate.callAddonListeners("onInstalling",
                                            this.addon.wrapper,
                                            false);
 
-    let stagedAddon = this.installLocation.getStagingDir();
+    let stagedAddon = this.location.installer.getStagingDir();
 
     (async () => {
-      await this.installLocation.requestStagingDir();
+      await this.location.installer.requestStagingDir();
 
       // remove any previously staged files
       await this.unstageInstall(stagedAddon);
 
       stagedAddon.append(`${this.addon.id}.xpi`);
 
       await this.stageInstall(false, stagedAddon, isUpgrade);
 
@@ -1769,30 +1768,30 @@ class AddonInstall {
 
       let install = () => {
         if (this.existingAddon && this.existingAddon.active && !isUpgrade) {
           XPIDatabase.updateAddonActive(this.existingAddon, false);
         }
 
         // Install the new add-on into its final location
         let existingAddonID = this.existingAddon ? this.existingAddon.id : null;
-        let file = this.installLocation.installAddon({
+        let file = this.location.installer.installAddon({
           id: this.addon.id,
           source: stagedAddon,
           existingAddonID
         });
 
         // Update the metadata in the database
         this.addon._sourceBundle = file;
         this.addon.visible = true;
 
         if (isUpgrade) {
           this.addon =  XPIDatabase.updateAddonMetadata(this.existingAddon, this.addon,
                                                         file.path);
-          let state = XPIStates.getAddon(this.installLocation.name, this.addon.id);
+          let state = this.location.get(this.addon.id);
           if (state) {
             state.syncWithDB(this.addon, true);
           } else {
             logger.warn("Unexpected missing XPI state for add-on ${id}", this.addon);
           }
         } else {
           this.addon.active = (this.addon.visible && !this.addon.disabled);
           this.addon = XPIDatabase.addToDatabase(this.addon, file.path);
@@ -1831,17 +1830,17 @@ class AddonInstall {
       this.state = AddonManager.STATE_INSTALL_FAILED;
       this.error = AddonManager.ERROR_FILE_ACCESS;
       XPIInstall.installs.delete(this);
       AddonManagerPrivate.callAddonListeners("onOperationCancelled",
                                              this.addon.wrapper);
       this._callInstallListeners("onInstallFailed");
     }).then(() => {
       this.removeTemporaryFile();
-      return this.installLocation.releaseStagingDir();
+      return this.location.installer.releaseStagingDir();
     });
   }
 
   /**
    * Stages an add-on for install.
    *
    * @param {boolean} restartRequired
    *        If true, the final installation will be deferred until the
@@ -1858,53 +1857,52 @@ class AddonInstall {
 
     await OS.File.copy(this.file.path, stagedAddon.path);
 
     if (restartRequired) {
       // Point the add-on to its extracted files as the xpi may get deleted
       this.addon._sourceBundle = stagedAddon;
 
       // Cache the AddonInternal as it may have updated compatibility info
-      XPIStates.getLocation(this.installLocation.name).stageAddon(this.addon.id,
-                                                                  this.addon.toJSON());
+      this.location.stageAddon(this.addon.id, this.addon.toJSON());
 
       logger.debug(`Staged install of ${this.addon.id} from ${this.sourceURI.spec} ready; waiting for restart.`);
       if (isUpgrade) {
         delete this.existingAddon.pendingUpgrade;
         this.existingAddon.pendingUpgrade = this.addon;
       }
     }
   }
 
   /**
    * Removes any previously staged upgrade.
    *
    * @param {nsIFile} stagingDir
    *        The staging directory from which to unstage the install.
    */
   async unstageInstall(stagingDir) {
-    XPIStates.getLocation(this.installLocation.name).unstageAddon(this.addon.id);
+    this.location.unstageAddon(this.addon.id);
 
     await removeAsync(getFile(this.addon.id, stagingDir));
 
     await removeAsync(getFile(`${this.addon.id}.xpi`, stagingDir));
   }
 
   /**
     * Postone a pending update, until restart or until the add-on resumes.
     *
     * @param {function} resumeFn
     *        A function for the add-on to run when resuming.
     */
   async postpone(resumeFn) {
     this.state = AddonManager.STATE_POSTPONED;
 
-    let stagingDir = this.installLocation.getStagingDir();
-
-    await this.installLocation.requestStagingDir();
+    let stagingDir = this.location.installer.getStagingDir();
+
+    await this.location.installer.requestStagingDir();
     await this.unstageInstall(stagingDir);
 
     let stagedAddon = getFile(`${this.addon.id}.xpi`, stagingDir);
 
     await this.stageInstall(true, stagedAddon, true);
 
     this._callInstallListeners("onInstallPostponed");
 
@@ -1926,17 +1924,17 @@ class AddonInstall {
             break;
           }
         },
       });
     }
     // Release the staging directory lock, but since the staging dir is populated
     // it will not be removed until resumed or installed by restart.
     // See also cleanStagingDir()
-    this.installLocation.releaseStagingDir();
+    this.location.installer.releaseStagingDir();
   }
 
   _callInstallListeners(event, ...args) {
     switch (event) {
       case "onDownloadCancelled":
       case "onDownloadFailed":
       case "onInstallCancelled":
       case "onInstallFailed":
@@ -2047,18 +2045,18 @@ var LocalAddonInstall = class extends Ad
     return super.install();
   }
 };
 
 var DownloadAddonInstall = class extends AddonInstall {
   /**
    * Instantiates a DownloadAddonInstall
    *
-   * @param {InstallLocation} installLocation
-   *        The InstallLocation the add-on will be installed into
+   * @param {XPIStateLocation} installLocation
+   *        The XPIStateLocation the add-on will be installed into
    * @param {nsIURL} url
    *        The nsIURL to get the add-on from
    * @param {Object} [options = {}]
    *        Additional options for the install
    * @param {string} [options.hash]
    *        An optional hash for the add-on
    * @param {AddonInternal} [options.existingAddon]
    *        The add-on this install will update if known
@@ -2457,20 +2455,20 @@ function createUpdate(aCallback, aAddon,
       existingAddon: aAddon,
       name: aAddon.selectedLocale.name,
       type: aAddon.type,
       icons: aAddon.icons,
       version: aUpdate.version,
     };
     let install;
     if (url instanceof Ci.nsIFileURL) {
-      install = new LocalAddonInstall(aAddon._installLocation, url, opts);
+      install = new LocalAddonInstall(aAddon.location, url, opts);
       await install.init();
     } else {
-      install = new DownloadAddonInstall(aAddon._installLocation, url, opts);
+      install = new DownloadAddonInstall(aAddon.location, url, opts);
     }
     try {
       if (aUpdate.updateInfoURL)
         install.releaseNotesURI = Services.io.newURI(escapeAddonURI(aAddon, aUpdate.updateInfoURL));
     } catch (e) {
       // If the releaseNotesURI cannot be parsed then just ignore it.
     }
 
@@ -2693,17 +2691,17 @@ UpdateChecker.prototype = {
                           null :
                           await AddonRepository.getCompatibilityOverrides(this.addon.id);
 
     let update = await AUC.getNewestCompatibleUpdate(
       aUpdates, this.appVersion, this.platformVersion,
       ignoreMaxVersion, ignoreStrictCompat, compatOverrides);
 
     if (update && Services.vc.compare(this.addon.version, update.version) < 0
-        && !this.addon._installLocation.locked) {
+        && !this.addon.location.locked) {
       for (let currentInstall of XPIInstall.installs) {
         // Skip installs that don't match the available update
         if (currentInstall.existingAddon != this.addon ||
             currentInstall.version != update.version)
           continue;
 
         // If the existing install has not yet started downloading then send an
         // available update notification. If it is already downloading then
@@ -2751,62 +2749,75 @@ UpdateChecker.prototype = {
   }
 };
 
 /**
  * Creates a new AddonInstall to install an add-on from a local file.
  *
  * @param {nsIFile} file
  *        The file to install
- * @param {InstallLocation} location
+ * @param {XPIStateLocation} location
  *        The location to install to
  * @returns {Promise<AddonInstall>}
  *        A Promise that resolves with the new install object.
  */
 function createLocalInstall(file, location) {
   if (!location) {
-    location = XPIProvider.installLocationsByName[KEY_APP_PROFILE];
+    location = XPIStates.getLocation(KEY_APP_PROFILE);
   }
   let url = Services.io.newFileURI(file);
 
   try {
     let install = new LocalAddonInstall(location, url);
     return install.init().then(() => install);
   } catch (e) {
     logger.error("Error creating install", e);
     XPIInstall.installs.delete(this);
     return Promise.resolve(null);
   }
 }
 
-// These are partial classes which contain the install logic for the
-// homonymous classes in XPIProvider.jsm. Those classes forward calls to
-// their install methods to these classes, with the `this` value set to
-// an instance the class as defined in XPIProvider.
-class DirectoryInstallLocation {}
-
-class MutableDirectoryInstallLocation extends DirectoryInstallLocation {
+class DirectoryInstaller {
+  constructor(location) {
+    this.location = location;
+
+    this._stagingDirLock = 0;
+    this._stagingDirPromise = null;
+  }
+
+  get name() {
+    return this.location.name;
+  }
+
+  get dir() {
+    return this.location.dir;
+  }
+  set dir(val) {
+    this.location.dir = val;
+    this.location.path = val.path;
+  }
+
   /**
    * Gets the staging directory to put add-ons that are pending install and
    * uninstall into.
    *
    * @returns {nsIFile}
    */
   getStagingDir() {
-    return getFile(DIR_STAGE, this._directory);
+    return getFile(DIR_STAGE, this.dir);
   }
 
   requestStagingDir() {
     this._stagingDirLock++;
 
     if (this._stagingDirPromise)
       return this._stagingDirPromise;
 
-    OS.File.makeDir(this._directory.path);
-    let stagepath = OS.Path.join(this._directory.path, DIR_STAGE);
+    OS.File.makeDir(this.dir.path);
+    let stagepath = OS.Path.join(this.dir.path, DIR_STAGE);
     return this._stagingDirPromise = OS.File.makeDir(stagepath).catch((e) => {
       if (e instanceof OS.File.Error && e.becauseExists)
         return;
       logger.error("Failed to create staging directory", e);
       throw e;
     });
   }
 
@@ -2861,17 +2872,17 @@ class MutableDirectoryInstallLocation ex
    * Returns a directory that is normally on the same filesystem as the rest of
    * the install location and can be used for temporarily storing files during
    * safe move operations. Calling this method will delete the existing trash
    * directory and its contents.
    *
    * @returns {nsIFile}
    */
   getTrashDir() {
-    let trashDir = getFile(DIR_TRASH, this._directory);
+    let trashDir = getFile(DIR_TRASH, this.dir);
     let trashDirExists = trashDir.exists();
     try {
       if (trashDirExists)
         recursiveRemove(trashDir);
       trashDirExists = false;
     } catch (e) {
       logger.warn("Failed to remove trash directory", e);
     }
@@ -2905,21 +2916,21 @@ class MutableDirectoryInstallLocation ex
    *        An nsIFile indicating where the add-on was installed to
    */
   installAddon({ id, source, existingAddonID, action = "move" }) {
     let trashDir = this.getTrashDir();
 
     let transaction = new SafeInstallOperation();
 
     let moveOldAddon = aId => {
-      let file = getFile(aId, this._directory);
+      let file = getFile(aId, this.dir);
       if (file.exists())
         transaction.moveUnder(file, trashDir);
 
-      file = getFile(`${aId}.xpi`, this._directory);
+      file = getFile(`${aId}.xpi`, this.dir);
       if (file.exists()) {
         flushJarCache(file);
         transaction.moveUnder(file, trashDir);
       }
     };
 
     // If any of these operations fails the finally block will clean up the
     // temporary directory
@@ -2948,152 +2959,149 @@ class MutableDirectoryInstallLocation ex
             }
 
             transaction.moveTo(oldDataDir, newDataDir);
           }
         }
       }
 
       if (action == "copy") {
-        transaction.copy(source, this._directory);
+        transaction.copy(source, this.dir);
       } else if (action == "move") {
         flushJarCache(source);
-        transaction.moveUnder(source, this._directory);
+        transaction.moveUnder(source, this.dir);
       }
       // Do nothing for the proxy file as we sideload an addon permanently
     } finally {
       // It isn't ideal if this cleanup fails but it isn't worth rolling back
       // the install because of it.
       try {
         recursiveRemove(trashDir);
       } catch (e) {
-        logger.warn("Failed to remove trash directory when installing " + id, e);
+        logger.warn(`Failed to remove trash directory when installing ${id}`, e);
       }
     }
 
-    let newFile = this._directory.clone();
+    let newFile = this.dir.clone();
 
     if (action == "proxy") {
       // When permanently installing sideloaded addon, we just put a proxy file
       // referring to the addon sources
       newFile.append(id);
 
       writeStringToFile(newFile, source.path);
     } else {
       newFile.append(source.leafName);
     }
 
     try {
       newFile.lastModifiedTime = Date.now();
     } catch (e) {
-      logger.warn("failed to set lastModifiedTime on " + newFile.path, e);
-    }
-    this._IDToFileMap[id] = newFile;
-
-    if (existingAddonID && existingAddonID != id &&
-        existingAddonID in this._IDToFileMap) {
-      delete this._IDToFileMap[existingAddonID];
+      logger.warn(`failed to set lastModifiedTime on ${newFile.path}`, e);
     }
 
     return newFile;
   }
 
   /**
    * Uninstalls an add-on from this location.
    *
    * @param {string} aId
    *        The ID of the add-on to uninstall
    * @throws if the ID does not match any of the add-ons installed
    */
   uninstallAddon(aId) {
-    let file = this._IDToFileMap[aId];
-    if (!file) {
-      logger.warn("Attempted to remove " + aId + " from " +
-           this._name + " but it was already gone");
-      return;
-    }
-
-    file = getFile(aId, this._directory);
+    let file = getFile(aId, this.dir);
     if (!file.exists())
       file.leafName += ".xpi";
 
     if (!file.exists()) {
-      logger.warn("Attempted to remove " + aId + " from " +
-           this._name + " but it was already gone");
-
-      delete this._IDToFileMap[aId];
+      logger.warn(`Attempted to remove ${aId} from ${this.name} but it was already gone`);
+      this.location.delete(aId);
       return;
     }
 
     let trashDir = this.getTrashDir();
 
     if (file.leafName != aId) {
-      logger.debug("uninstallAddon: flushing jar cache " + file.path + " for addon " + aId);
+      logger.debug(`uninstallAddon: flushing jar cache ${file.path} for addon ${aId}`);
       flushJarCache(file);
     }
 
     let transaction = new SafeInstallOperation();
 
     try {
       transaction.moveUnder(file, trashDir);
     } finally {
       // It isn't ideal if this cleanup fails, but it is probably better than
       // rolling back the uninstall at this point
       try {
         recursiveRemove(trashDir);
       } catch (e) {
-        logger.warn("Failed to remove trash directory when uninstalling " + aId, e);
+        logger.warn(`Failed to remove trash directory when uninstalling ${aId}`, e);
       }
     }
 
-    XPIStates.removeAddon(this.name, aId);
-
-    delete this._IDToFileMap[aId];
+    this.location.removeAddon(aId);
   }
 }
 
-class SystemAddonInstallLocation extends MutableDirectoryInstallLocation {
+class SystemAddonInstaller extends DirectoryInstaller {
+  constructor(location) {
+    super(location);
+
+    this._baseDir = location._baseDir;
+    this._nextDir = null;
+  }
+
+  get _addonSet() {
+    return this.location._addonSet;
+  }
+  set _addonSet(val) {
+    this.location._addonSet = val;
+  }
+
   /**
    * Saves the current set of system add-ons
    *
    * @param {Object} aAddonSet - object containing schema, directory and set
    *                 of system add-on IDs and versions.
    */
   static _saveAddonSet(aAddonSet) {
     Services.prefs.setStringPref(PREF_SYSTEM_ADDON_SET, JSON.stringify(aAddonSet));
   }
 
   static _loadAddonSet() {
-    return XPIInternal.SystemAddonInstallLocation._loadAddonSet();
+    return XPIInternal.SystemAddonLocation._loadAddonSet();
   }
 
   /**
    * Gets the staging directory to put add-ons that are pending install and
    * uninstall into.
    *
    * @returns {nsIFile}
    *        Staging directory for system add-on upgrades.
    */
   getStagingDir() {
-    this._addonSet = SystemAddonInstallLocation._loadAddonSet();
+    this._addonSet = SystemAddonInstaller._loadAddonSet();
     let dir = null;
     if (this._addonSet.directory) {
-      this._directory = getFile(this._addonSet.directory, this._baseDir);
-      dir = getFile(DIR_STAGE, this._directory);
+      this.dir = getFile(this._addonSet.directory, this._baseDir);
+      dir = getFile(DIR_STAGE, this.dir);
     } else {
-      logger.info("SystemAddonInstallLocation directory is missing");
+      logger.info("SystemAddonInstaller directory is missing");
     }
 
     return dir;
   }
 
   requestStagingDir() {
-    this._addonSet = SystemAddonInstallLocation._loadAddonSet();
+    this._addonSet = SystemAddonInstaller._loadAddonSet();
     if (this._addonSet.directory) {
-      this._directory = getFile(this._addonSet.directory, this._baseDir);
+      this.dir = getFile(this._addonSet.directory, this._baseDir);
     }
     return super.requestStagingDir();
   }
 
   isValidAddon(aAddon) {
     if (aAddon.appDisabled) {
       logger.warn(`System add-on ${aAddon.id} isn't compatible with the application.`);
       return false;
@@ -3135,17 +3143,17 @@ class SystemAddonInstallLocation extends
    */
   async resetAddonSet() {
     logger.info("Removing all system add-on upgrades.");
 
     // remove everything from the pref first, if uninstall
     // fails then at least they will not be re-activated on
     // next restart.
     this._addonSet = { schema: 1, addons: {} };
-    SystemAddonInstallLocation._saveAddonSet(this._addonSet);
+    SystemAddonInstaller._saveAddonSet(this._addonSet);
 
     // If this is running at app startup, the pref being cleared
     // will cause later stages of startup to notice that the
     // old updates are now gone.
     //
     // Updates will only be explicitly uninstalled if they are
     // removed restartlessly, for instance if they are no longer
     // part of the latest update set.
@@ -3181,17 +3189,17 @@ class SystemAddonInstallLocation extends
     try {
       for (;;) {
         let {value: entry, done} = await iterator.next();
         if (done) {
           break;
         }
 
         // Skip the directory currently in use
-        if (this._directory && this._directory.path == entry.path) {
+        if (this.dir && this.dir.path == entry.path) {
           continue;
         }
 
         // Skip the next directory
         if (this._nextDir && this._nextDir.path == entry.path) {
           continue;
         }
 
@@ -3219,17 +3227,17 @@ class SystemAddonInstallLocation extends
    * add-on set in prefs.
    *
    * @param {Array} aAddons - An array of addons to install.
    */
   async installAddonSet(aAddons) {
     // Make sure the base dir exists
     await OS.File.makeDir(this._baseDir.path, { ignoreExisting: true });
 
-    let addonSet = SystemAddonInstallLocation._loadAddonSet();
+    let addonSet = SystemAddonInstaller._loadAddonSet();
 
     // Remove any add-ons that are no longer part of the set.
     for (let addonID of Object.keys(addonSet.addons)) {
       if (!aAddons.includes(addonID)) {
         AddonManager.getAddonByID(addonID).then(a => a.uninstall());
       }
     }
 
@@ -3243,72 +3251,71 @@ class SystemAddonInstallLocation extends
         break;
       } catch (e) {
         logger.debug("Could not create new system add-on updates dir, retrying", e);
       }
     }
 
     // Record the new upgrade directory.
     let state = { schema: 1, directory: newDir.leafName, addons: {} };
-    SystemAddonInstallLocation._saveAddonSet(state);
+    SystemAddonInstaller._saveAddonSet(state);
 
     this._nextDir = newDir;
-    let location = this;
 
     let installs = [];
     for (let addon of aAddons) {
-      let install = await createLocalInstall(addon._sourceBundle, location);
+      let install = await createLocalInstall(addon._sourceBundle, this.location);
       installs.push(install);
     }
 
     async function installAddon(install) {
       // Make the new install own its temporary file.
       install.ownsTempFile = true;
       install.install();
     }
 
     async function postponeAddon(install) {
       let resumeFn;
       if (AddonManagerPrivate.hasUpgradeListener(install.addon.id)) {
         logger.info(`system add-on ${install.addon.id} has an upgrade listener, postponing upgrade set until restart`);
         resumeFn = () => {
           logger.info(`${install.addon.id} has resumed a previously postponed addon set`);
-          install.installLocation.resumeAddonSet(installs);
+          install.location.installer.resumeAddonSet(installs);
         };
       }
       await install.postpone(resumeFn);
     }
 
     let previousState;
 
     try {
       // All add-ons in position, create the new state and store it in prefs
       state = { schema: 1, directory: newDir.leafName, addons: {} };
       for (let addon of aAddons) {
         state.addons[addon.id] = {
           version: addon.version
         };
       }
 
-      previousState = SystemAddonInstallLocation._loadAddonSet();
-      SystemAddonInstallLocation._saveAddonSet(state);
+      previousState = SystemAddonInstaller._loadAddonSet();
+      SystemAddonInstaller._saveAddonSet(state);
 
       let blockers = aAddons.filter(
         addon => AddonManagerPrivate.hasUpgradeListener(addon.id)
       );
 
       if (blockers.length > 0) {
         await waitForAllPromises(installs.map(postponeAddon));
       } else {
         await waitForAllPromises(installs.map(installAddon));
       }
     } catch (e) {
       // Roll back to previous upgrade set (if present) on restart.
       if (previousState) {
-        SystemAddonInstallLocation._saveAddonSet(previousState);
+        SystemAddonInstaller._saveAddonSet(previousState);
       }
       // Otherwise, roll back to built-in set on restart.
       // TODO try to do these restartlessly
       this.resetAddonSet();
 
       try {
         await OS.File.removeDir(newDir.path, { ignorePermissions: true });
       } catch (e) {
@@ -3322,17 +3329,17 @@ class SystemAddonInstallLocation extends
   * Resumes upgrade of a previously-delayed add-on set.
   *
   * @param {AddonInstall[]} installs
   *        The set of installs to resume.
   */
   async resumeAddonSet(installs) {
     async function resumeAddon(install) {
       install.state = AddonManager.STATE_DOWNLOADED;
-      install.installLocation.releaseStagingDir();
+      install.location.installer.releaseStagingDir();
       install.install();
     }
 
     let blockers = installs.filter(
       install => AddonManagerPrivate.hasUpgradeListener(install.addon.id)
     );
 
     if (blockers.length > 1) {
@@ -3346,17 +3353,17 @@ class SystemAddonInstallLocation extends
    * Returns a directory that is normally on the same filesystem as the rest of
    * the install location and can be used for temporarily storing files during
    * safe move operations. Calling this method will delete the existing trash
    * directory and its contents.
    *
    * @returns {nsIFile}
    */
   getTrashDir() {
-    let trashDir = getFile(DIR_TRASH, this._directory);
+    let trashDir = getFile(DIR_TRASH, this.dir);
     let trashDirExists = trashDir.exists();
     try {
       if (trashDirExists)
         recursiveRemove(trashDir);
       trashDirExists = false;
     } catch (e) {
       logger.warn("Failed to remove trash directory", e);
     }
@@ -3380,35 +3387,34 @@ class SystemAddonInstallLocation extends
     let trashDir = this.getTrashDir();
     let transaction = new SafeInstallOperation();
 
     // If any of these operations fails the finally block will clean up the
     // temporary directory
     try {
       flushJarCache(source);
 
-      transaction.moveUnder(source, this._directory);
+      transaction.moveUnder(source, this.dir);
     } finally {
       // It isn't ideal if this cleanup fails but it isn't worth rolling back
       // the install because of it.
       try {
         recursiveRemove(trashDir);
       } catch (e) {
-        logger.warn("Failed to remove trash directory when installing " + id, e);
+        logger.warn(`Failed to remove trash directory when installing ${id}`, e);
       }
     }
 
-    let newFile = getFile(source.leafName, this._directory);
+    let newFile = getFile(source.leafName, this.dir);
 
     try {
       newFile.lastModifiedTime = Date.now();
     } catch (e) {
       logger.warn("failed to set lastModifiedTime on " + newFile.path, e);
     }
-    this._IDToFileMap[id] = newFile;
 
     return newFile;
   }
 
   // old system add-on upgrade dirs get automatically removed
   uninstallAddon(aAddon) {}
 }
 
@@ -3423,82 +3429,78 @@ var XPIInstall = {
   recursiveRemove,
   syncLoadManifestFromFile,
 
   /**
    * @param {string} id
    *        The expected ID of the add-on.
    * @param {nsIFile} file
    *        The XPI file to install the add-on from.
-   * @param {InstallLocation} location
+   * @param {XPIStateLocation} location
    *        The install location to install the add-on to.
    * @returns {AddonInternal}
    *        The installed Addon object, upon success.
    */
   async installDistributionAddon(id, file, location) {
     let addon = await loadManifestFromFile(file, location);
 
     if (addon.id != id) {
       throw new Error(`File file ${file.path} contains an add-on with an incorrect ID`);
     }
 
-    let existingEntry = null;
-    try {
-      existingEntry = location.getLocationForID(id);
-    } catch (e) {
-    }
-
-    if (existingEntry) {
+    let state = location.get(id);
+
+    if (state) {
       try {
-        let existingAddon = await loadManifestFromFile(existingEntry, location);
+        let existingAddon = await loadManifestFromFile(state.file, location);
 
         if (Services.vc.compare(addon.version, existingAddon.version) <= 0)
           return null;
       } catch (e) {
         // Bad add-on in the profile so just proceed and install over the top
         logger.warn("Profile contains an add-on with a bad or missing install " +
-                    `manifest at ${existingEntry.path}, overwriting`, e);
+                    `manifest at ${state.path}, overwriting`, e);
       }
     } else if (Services.prefs.getBoolPref(PREF_BRANCH_INSTALLED_ADDON + id, false)) {
       return null;
     }
 
     // Install the add-on
-    addon._sourceBundle = location.installAddon({ id, source: file, action: "copy" });
+    addon._sourceBundle = location.installer.installAddon({ id, source: file, action: "copy" });
     if (Services.prefs.getBoolPref(PREF_DISTRO_ADDONS_PERMS, false)) {
       addon.userDisabled = true;
       if (!XPIProvider.newDistroAddons) {
         XPIProvider.newDistroAddons = new Set();
       }
       XPIProvider.newDistroAddons.add(id);
     }
 
     XPIStates.addAddon(addon);
-    logger.debug("Installed distribution add-on " + id);
+    logger.debug(`Installed distribution add-on ${id}`);
 
     Services.prefs.setBoolPref(PREF_BRANCH_INSTALLED_ADDON + id, true);
 
     return addon;
   },
 
   /**
    * Completes the install of an add-on which was staged during the last
    * session.
    *
    * @param {string} id
    *        The expected ID of the add-on.
    * @param {object} metadata
    *        The parsed metadata for the staged install.
-   * @param {InstallLocation} location
+   * @param {XPIStateLocation} location
    *        The install location to install the add-on to.
    * @returns {AddonInternal}
    *        The installed Addon object, upon success.
    */
   async installStagedAddon(id, metadata, location) {
-    let source = getFile(`${id}.xpi`, location.getStagingDir());
+    let source = getFile(`${id}.xpi`, location.installer.getStagingDir());
 
     // Check that the directory's name is a valid ID.
     if (!gIDTest.test(id) || !source.exists() || !source.isFile()) {
       throw new Error(`Ignoring invalid staging directory entry: ${id}`);
     }
 
     let addon = await loadManifestFromFile(source, location);
 
@@ -3521,56 +3523,58 @@ var XPIInstall = {
           XPIInternal.get(existingAddon).uninstall(reason, {newVersion});
         }
       } catch (e) {
         Cu.reportError(e);
       }
     }
 
     try {
-      addon._sourceBundle = location.installAddon({
+      addon._sourceBundle = location.installer.installAddon({
         id, source, existingAddonID: id,
       });
       XPIStates.addAddon(addon);
     } catch (e) {
       if (existingAddon) {
         // Re-install the old add-on
         XPIInternal.get(existingAddon).install();
       }
       throw e;
     }
 
     return addon;
   },
 
   async updateSystemAddons() {
-    let systemAddonLocation = XPIProvider.installLocationsByName[KEY_APP_SYSTEM_ADDONS];
+    let systemAddonLocation = XPIStates.getLocation(KEY_APP_SYSTEM_ADDONS);
     if (!systemAddonLocation)
       return;
 
+    let installer = systemAddonLocation.installer;
+
     // Don't do anything in safe mode
     if (Services.appinfo.inSafeMode)
       return;
 
     // Download the list of system add-ons
     let url = Services.prefs.getStringPref(PREF_SYSTEM_ADDON_UPDATE_URL, null);
     if (!url) {
-      await systemAddonLocation.cleanDirectories();
+      await installer.cleanDirectories();
       return;
     }
 
     url = await UpdateUtils.formatUpdateURL(url);
 
     logger.info(`Starting system add-on update check from ${url}.`);
     let res = await ProductAddonChecker.getProductAddonList(url);
 
     // If there was no list then do nothing.
     if (!res || !res.gmpAddons) {
       logger.info("No system add-ons list was returned.");
-      await systemAddonLocation.cleanDirectories();
+      await installer.cleanDirectories();
       return;
     }
 
     let addonList = new Map(
       res.gmpAddons.map(spec => [spec.id, { spec, path: null, addon: null }]));
 
     let setMatches = (wanted, existing) => {
       if (wanted.size != existing.size)
@@ -3587,27 +3591,27 @@ var XPIInstall = {
 
       return true;
     };
 
     // If this matches the current set in the profile location then do nothing.
     let updatedAddons = addonMap(await XPIDatabase.getAddonsInLocation(KEY_APP_SYSTEM_ADDONS));
     if (setMatches(addonList, updatedAddons)) {
       logger.info("Retaining existing updated system add-ons.");
-      await systemAddonLocation.cleanDirectories();
+      await installer.cleanDirectories();
       return;
     }
 
     // If this matches the current set in the default location then reset the
     // updated set.
     let defaultAddons = addonMap(await XPIDatabase.getAddonsInLocation(KEY_APP_SYSTEM_DEFAULTS));
     if (setMatches(addonList, defaultAddons)) {
       logger.info("Resetting system add-ons.");
-      systemAddonLocation.resetAddonSet();
-      await systemAddonLocation.cleanDirectories();
+      installer.resetAddonSet();
+      await installer.cleanDirectories();
       return;
     }
 
     // Download all the add-ons
     async function downloadAddon(item) {
       try {
         let sourceAddon = updatedAddons.get(item.spec.id);
         if (sourceAddon && sourceAddon.version == item.spec.version) {
@@ -3646,30 +3650,30 @@ var XPIInstall = {
         return false;
       }
 
       if (item.spec.version != item.addon.version) {
         logger.warn(`Expected system add-on ${item.spec.id} to be version ${item.spec.version} but was ${item.addon.version}.`);
         return false;
       }
 
-      if (!systemAddonLocation.isValidAddon(item.addon))
+      if (!installer.isValidAddon(item.addon))
         return false;
 
       return true;
     };
 
     if (!Array.from(addonList.values()).every(item => item.path && item.addon && validateAddon(item))) {
       throw new Error("Rejecting updated system add-on set that either could not " +
                       "be downloaded or contained unusable add-ons.");
     }
 
     // Install into the install location
     logger.info("Installing new system add-on set");
-    await systemAddonLocation.installAddonSet(Array.from(addonList.values())
+    await installer.installAddonSet(Array.from(addonList.values())
       .map(a => a.addon));
   },
 
   /**
    * Called to test whether installing XPI add-ons is enabled.
    *
    * @returns {boolean}
    *        True if installing is enabled.
@@ -3757,17 +3761,17 @@ var XPIInstall = {
    *        Icon URLs for the install
    * @param {string} [aVersion]
    *        A version for the install
    * @param {XULElement?} [aBrowser]
    *        The browser performing the install
    * @returns {AddonInstall}
    */
   async getInstallForURL(aUrl, aHash, aName, aIcons, aVersion, aBrowser) {
-    let location = XPIProvider.installLocationsByName[KEY_APP_PROFILE];
+    let location = XPIStates.getLocation(KEY_APP_PROFILE);
     let url = Services.io.newURI(aUrl);
 
     let options = {
       hash: aHash,
       browser: aBrowser,
       name: aName,
       icons: aIcons,
       version: aVersion,
@@ -3830,17 +3834,17 @@ var XPIInstall = {
   async installTemporaryAddon(aFile) {
     let installLocation = XPIInternal.TemporaryInstallLocation;
 
     if (aFile.exists() && aFile.isFile()) {
       flushJarCache(aFile);
     }
     let addon = await loadManifestFromFile(aFile, installLocation);
 
-    installLocation.installAddon({ id: addon.id, source: aFile });
+    installLocation.installer.installAddon({ id: addon.id, source: aFile });
 
     if (addon.appDisabled) {
       let message = `Add-on ${addon.id} is not compatible with application version.`;
 
       let app = addon.matchingTargetApplication;
       if (app) {
         if (app.minVersion) {
           message += ` add-on minVersion: ${app.minVersion}.`;
@@ -3918,50 +3922,50 @@ var XPIInstall = {
    *        Force this addon into the pending uninstall state (used
    *        e.g. while the add-on manager is open and offering an
    *        "undo" button)
    * @throws if the addon cannot be uninstalled because it is in an install
    *         location that does not allow it
    */
   async uninstallAddon(aAddon, aForcePending) {
     if (!(aAddon.inDatabase))
-      throw new Error("Cannot uninstall addon " + aAddon.id + " because it is not installed");
-
-    if (aAddon._installLocation.locked)
-      throw new Error("Cannot uninstall addon " + aAddon.id
-          + " from locked install location " + aAddon._installLocation.name);
+      throw new Error(`Cannot uninstall addon ${aAddon.id} because it is not installed`);
+
+    if (aAddon.location.locked)
+      throw new Error(`Cannot uninstall addon ${aAddon.id} ` +
+                      `from locked install location ${aAddon.location.name}`);
 
     if (aForcePending && aAddon.pendingUninstall)
       throw new Error("Add-on is already marked to be uninstalled");
 
     aAddon._hasResourceCache.clear();
 
     if (aAddon._updateCheck) {
-      logger.debug("Cancel in-progress update check for " + aAddon.id);
+      logger.debug(`Cancel in-progress update check for ${aAddon.id}`);
       aAddon._updateCheck.cancel();
     }
 
     let wasPending = aAddon.pendingUninstall;
 
     if (aForcePending) {
       // We create an empty directory in the staging directory to indicate
       // that an uninstall is necessary on next startup. Temporary add-ons are
       // automatically uninstalled on shutdown anyway so there is no need to
       // do this for them.
-      if (aAddon._installLocation.name != KEY_APP_TEMPORARY) {
-        let stage = getFile(aAddon.id, aAddon._installLocation.getStagingDir());
+      if (!aAddon.location.isTemporary) {
+        let stage = getFile(aAddon.id, aAddon.location.installer.getStagingDir());
         if (!stage.exists())
           stage.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
       }
 
       XPIDatabase.setAddonProperties(aAddon, {
         pendingUninstall: true
       });
       Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
-      let xpiState = XPIStates.getAddon(aAddon.location, aAddon.id);
+      let xpiState = aAddon.location.get(aAddon.id);
       if (xpiState) {
         xpiState.enabled = false;
         XPIStates.save();
       } else {
         logger.warn("Can't find XPI state while uninstalling ${id} from ${location}", aAddon);
       }
     }
 
@@ -3972,32 +3976,32 @@ var XPIInstall = {
     let wrapper = aAddon.wrapper;
 
     // If the add-on wasn't already pending uninstall then notify listeners.
     if (!wasPending) {
       AddonManagerPrivate.callAddonListeners("onUninstalling", wrapper,
                                              !!aForcePending);
     }
 
-    let existingAddon = XPIStates.findAddon(aAddon.id, loc =>
-      loc.name != aAddon._installLocation.name);
+    let existingAddon = XPIStates.findAddon(aAddon.id,
+                                            loc => loc != aAddon.location);
 
     let bootstrap = XPIInternal.BootstrapScope.get(aAddon);
     if (!aForcePending) {
       let existing;
       if (existingAddon) {
         existing = await XPIDatabase.getAddonInLocation(aAddon.id, existingAddon.location.name);
       }
 
       let uninstall = () => {
         XPIStates.disableAddon(aAddon.id);
 
-        aAddon._installLocation.uninstallAddon(aAddon.id);
+        aAddon.location.installer.uninstallAddon(aAddon.id);
         XPIDatabase.removeAddonMetadata(aAddon);
-        XPIStates.removeAddon(aAddon.location, aAddon.id);
+        aAddon.location.removeAddon(aAddon.id);
         AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper);
 
         if (existing) {
           XPIDatabase.makeAddonVisible(existing);
           AddonManagerPrivate.callAddonListeners("onInstalling", existing.wrapper, false);
 
           if (!existing.disabled) {
             XPIDatabase.updateAddonActive(existing, true);
@@ -4005,17 +4009,17 @@ var XPIInstall = {
         }
       };
 
       if (existing) {
         await bootstrap.update(existing, !existing.disabled, uninstall);
 
         AddonManagerPrivate.callAddonListeners("onInstalled", existing.wrapper);
       } else {
-        XPIStates.removeAddon(aAddon.location, aAddon.id);
+        aAddon.location.removeAddon(aAddon.id);
         await bootstrap.uninstall();
         uninstall();
       }
     } else if (aAddon.active) {
       XPIStates.disableAddon(aAddon.id);
       bootstrap.shutdown(BOOTSTRAP_REASONS.ADDON_UNINSTALL);
       XPIDatabase.updateAddonActive(aAddon, false);
     }
@@ -4032,27 +4036,27 @@ var XPIInstall = {
    *        The DBAddonInternal to cancel uninstall for
    */
   cancelUninstallAddon(aAddon) {
     if (!(aAddon.inDatabase))
       throw new Error("Can only cancel uninstall for installed addons.");
     if (!aAddon.pendingUninstall)
       throw new Error("Add-on is not marked to be uninstalled");
 
-    if (aAddon._installLocation.name != KEY_APP_TEMPORARY)
-      aAddon._installLocation.cleanStagingDir([aAddon.id]);
+    if (!aAddon.location.isTemporary)
+      aAddon.location.installer.cleanStagingDir([aAddon.id]);
 
     XPIDatabase.setAddonProperties(aAddon, {
       pendingUninstall: false
     });
 
     if (!aAddon.visible)
       return;
 
-    XPIStates.getAddon(aAddon.location, aAddon.id).syncWithDB(aAddon);
+    aAddon.location.get(aAddon.id).syncWithDB(aAddon);
     XPIStates.save();
 
     Services.prefs.setBoolPref(PREF_PENDING_OPERATIONS, true);
 
     // TODO hide hidden add-ons (bug 557710)
     let wrapper = aAddon.wrapper;
     AddonManagerPrivate.callAddonListeners("onOperationCancelled", wrapper);
 
@@ -4061,11 +4065,11 @@ var XPIInstall = {
       XPIDatabase.updateAddonActive(aAddon, true);
     }
 
     // Notify any other providers that this theme is now enabled again.
     if (isTheme(aAddon.type) && aAddon.active)
       AddonManagerPrivate.notifyAddonChanged(aAddon.id, aAddon.type, false);
   },
 
-  MutableDirectoryInstallLocation,
-  SystemAddonInstallLocation,
+  DirectoryInstaller,
+  SystemAddonInstaller,
 };
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -360,25 +360,22 @@ function isTheme(type) {
  */
 function canRunInSafeMode(aAddon) {
   // Even though the updated system add-ons aren't generally run in safe mode we
   // include them here so their uninstall functions get called when switching
   // back to the default set.
 
   // TODO product should make the call about temporary add-ons running
   // in safe mode. assuming for now that they are.
-  let location = aAddon._installLocation || null;
+  let location = aAddon.location || null;
   if (!location) {
     return false;
   }
 
-  if (location.name == KEY_APP_TEMPORARY)
-    return true;
-
-  return location.isSystem;
+  return location.isTemporary || location.isSystem;
 }
 
 /**
  * Converts an internal add-on type to the type presented through the API.
  *
  * @param {string} aType
  *        The internal add-on type
  * @returns {string}
@@ -739,39 +736,73 @@ class XPIState {
   }
 }
 
 /**
  * Manages the state data for add-ons in a given install location.
  *
  * @param {string} name
  *        The name of the install location (e.g., "app-profile").
- * @param {string?} path
+ * @param {string | nsIFile | null} path
  *        The on-disk path of the install location. May be null for some
  *        locations which do not map to a specific on-disk path.
- * @param {object} [saved = {}]
+ * @param {integer} scope
+ *        The scope of add-ons installed in this location.
+ * @param {object} [saved]
  *        The persisted JSON state data to restore.
  */
 class XPIStateLocation extends Map {
-  constructor(name, path, saved = {}) {
+  constructor(name, path, scope, saved) {
     super();
 
     this.name = name;
-    this.path = path || saved.path || null;
+    this.scope = scope;
+    if (path instanceof Ci.nsIFile) {
+      this.dir = path;
+      this.path = path.path;
+    } else {
+      this.path = path;
+      this.dir = this.path && new nsIFile(this.path);
+    }
+    this.staged = {};
+    this.changed = false;
+
+    if (saved) {
+      this.restore(saved);
+    }
+
+    this._installler = undefined;
+  }
+
+  get installer() {
+    if (this._installer === undefined) {
+      this._installer = this.makeInstaller();
+    }
+    return this._installer;
+  }
+
+  makeInstaller() {
+    return null;
+  }
+
+  restore(saved) {
+    if (!this.path && saved.path) {
+      this.path = saved.path;
+      this.dir = new nsIFile(this.path);
+    }
     this.staged = saved.staged || {};
     this.changed = saved.changed || false;
-    this.dir = this.path && new nsIFile(this.path);
 
     for (let [id, data] of Object.entries(saved.addons || {})) {
       let xpiState = this._addState(id, data);
 
       // Make a note that this state was restored from saved data. But
       // only if this location hasn't moved since the last startup,
       // since that causes problems for new system add-on bundles.
-      if (!path || path == saved.path) {
+      if (!this.path || this.path == saved.path) {
         xpiState.wasRestored = true;
       }
     }
   }
 
   /**
    * Returns a JSON-compatible representation of this location's state
    * data, to be saved to addonStartup.json.
@@ -822,16 +853,29 @@ class XPIStateLocation extends Map {
 
     let xpiState = this._addState(addon.id, {file: addon._sourceBundle});
     xpiState.syncWithDB(addon, true);
 
     XPIProvider.setTelemetry(addon.id, "location", this.name);
   }
 
   /**
+   * Remove the XPIState for an add-on and save the new state.
+   *
+   * @param {string} aId
+   *        The ID of the add-on.
+   */
+  removeAddon(aId) {
+    if (this.has(aId)) {
+      this.delete(aId);
+      XPIStates.save();
+    }
+  }
+
+  /**
    * Adds stub state data for the local file to the DB.
    *
    * @param {string} addonId
    *        The ID of the add-on represented by the given file.
    * @param {nsIFile} file
    *        The local file or directory containing the add-on.
    * @returns {XPIState}
    */
@@ -888,41 +932,476 @@ class XPIStateLocation extends Map {
    *        The add-on's data from the xpiState preference.
    * @param {object} [bootstrapped]
    *        The add-on's data from the bootstrappedAddons preference, if
    *        applicable.
    */
   migrateAddon(id, state, bootstrapped) {
     this.set(id, XPIState.migrate(this, id, state, bootstrapped));
   }
+
+  /**
+   * Returns true if the given addon was installed in this location by a text
+   * file pointing to its real path.
+   *
+   * @param {string} aId
+   *        The ID of the addon
+   * @returns {boolean}
+   */
+  isLinkedAddon(aId) {
+    if (!this.dir) {
+      return true;
+    }
+    return this.has(aId) && !this.dir.contains(this.get(aId).file);
+  }
+
+  get isTemporary() {
+    return false;
+  }
+
+  get isSystem() {
+    return false;
+  }
+}
+
+class TemporaryLocation extends XPIStateLocation {
+  /**
+   * @param {string} name
+   *        The string identifier for the install location.
+   */
+  constructor(name) {
+    super(name, null, null);
+    this.locked = false;
+  }
+
+  makeInstaller() {
+    // Installs are a no-op. We only register that add-ons exist, and
+    // run them from their current location.
+    return {
+      installAddon() {},
+      uninstallAddon() {},
+    };
+  }
+
+  toJSON() {
+    return {};
+  }
+
+  readAddons() {
+    return new Map();
+  }
+
+  get isTemporary() {
+    return true;
+  }
+}
+
+var TemporaryInstallLocation = new TemporaryLocation(KEY_APP_TEMPORARY);
+
+/**
+ * An object which identifies a directory install location for add-ons. The
+ * location consists of a directory which contains the add-ons installed in the
+ * location.
+ *
+ */
+class DirectoryLocation extends XPIStateLocation {
+  /**
+   * Each add-on installed in the location is either a directory containing the
+   * add-on's files or a text file containing an absolute path to the directory
+   * containing the add-ons files. The directory or text file must have the same
+   * name as the add-on's ID.
+   *
+   * @param {string} name
+   *        The string identifier for the install location.
+   * @param {nsIFile} dir
+   *        The directory for the install location.
+   * @param {integer} scope
+   *        The scope of add-ons installed in this location.
+   * @param {boolean} [locked = true]
+   *        If false, the location accepts new add-on installs.
+   */
+  constructor(name, dir, scope, locked = true) {
+    super(name, dir, scope);
+    this.locked = locked;
+    this.initialized = false;
+  }
+
+  makeInstaller() {
+    if (this.locked) {
+      return null;
+    }
+    return new XPIInstall.DirectoryInstaller(this);
+  }
+
+  /**
+   * Reads a single-line file containing the path to a directory, and
+   * returns an nsIFile pointing to that directory, if successful.
+   *
+   * @param {nsIFile} aFile
+   *        The file containing the directory path
+   * @returns {nsIFile?}
+   *        An nsIFile object representing the linked directory, or null
+   *        on error.
+   */
+  _readLinkFile(aFile) {
+    let linkedDirectory;
+    if (aFile.isSymlink()) {
+      linkedDirectory = aFile.clone();
+      try {
+        linkedDirectory.normalize();
+      } catch (e) {
+        logger.warn(`Symbolic link ${aFile.path} points to a path ` +
+                    `which does not exist`);
+        return null;
+      }
+    } else {
+      let fis = new FileInputStream(aFile, -1, -1, false);
+      let line = {};
+      fis.QueryInterface(Ci.nsILineInputStream).readLine(line);
+      fis.close();
+
+      if (line.value) {
+        linkedDirectory = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
+        try {
+          linkedDirectory.initWithPath(line.value);
+        } catch (e) {
+          linkedDirectory.setRelativeDescriptor(aFile.parent, line.value);
+        }
+      }
+    }
+
+    if (linkedDirectory) {
+      if (!linkedDirectory.exists()) {
+        logger.warn(`File pointer ${aFile.path} points to ${linkedDirectory.path} ` +
+                    "which does not exist");
+        return null;
+      }
+
+      if (!linkedDirectory.isDirectory()) {
+        logger.warn(`File pointer ${aFile.path} points to ${linkedDirectory.path} ` +
+                    "which is not a directory");
+        return null;
+      }
+
+      return linkedDirectory;
+    }
+
+    logger.warn(`File pointer ${aFile.path} does not contain a path`);
+    return null;
+  }
+
+  /**
+   * Finds all the add-ons installed in this location.
+   *
+   * @returns {Map<AddonID, nsIFile>}
+   *        A map of add-ons present in this location.
+   */
+  readAddons() {
+    let addons = new Map();
+
+    if (!this.dir) {
+      return addons;
+    }
+    this.initialized = true;
+
+    // Use a snapshot of the directory contents to avoid possible issues with
+    // iterating over a directory while removing files from it (the YAFFS2
+    // embedded filesystem has this issue, see bug 772238).
+    let entries = getDirectoryEntries(this.dir);
+    for (let entry of entries) {
+      let id = entry.leafName;
+      if (id == DIR_STAGE || id == DIR_TRASH)
+        continue;
+
+      let isFile = id.toLowerCase().endsWith(".xpi");
+      if (isFile) {
+        id = id.substring(0, id.length - 4);
+      }
+
+      if (!gIDTest.test(id)) {
+        logger.debug("Ignoring file entry whose name is not a valid add-on ID: " +
+                     entry.path);
+        continue;
+      }
+
+      if (!isFile && (entry.isFile() || entry.isSymlink())) {
+        let newEntry = this._readLinkFile(entry);
+        if (!newEntry) {
+          logger.debug(`Deleting stale pointer file ${entry.path}`);
+          try {
+            entry.remove(true);
+          } catch (e) {
+            logger.warn(`Failed to remove stale pointer file ${entry.path}`, e);
+            // Failing to remove the stale pointer file is ignorable
+          }
+          continue;
+        }
+
+        entry = newEntry;
+      }
+
+
+      addons.set(id, entry);
+    }
+    return addons;
+  }
+}
+
+/**
+ * An object which identifies a built-in install location for add-ons, such
+ * as default system add-ons.
+ *
+ * This location should point either to a XPI, or a directory in a local build.
+ */
+class BuiltInLocation extends DirectoryLocation {
+  /**
+   * Read the manifest of allowed add-ons and build a mapping between ID and URI
+   * for each.
+   *
+   * @returns {Map<AddonID, nsIFile>}
+   *        A map of add-ons present in this location.
+   */
+  readAddons() {
+    let addons = new Map();
+
+    let manifest;
+    try {
+      let url = Services.io.newURI(BUILT_IN_ADDONS_URI);
+      let data = Cu.readUTF8URI(url);
+      manifest = JSON.parse(data);
+    } catch (e) {
+      logger.warn("List of valid built-in add-ons could not be parsed.", e);
+      return addons;
+    }
+
+    if (!("system" in manifest)) {
+      logger.warn("No list of valid system add-ons found.");
+      return addons;
+    }
+
+    for (let id of manifest.system) {
+      let file = this.dir.clone();
+      file.append(`${id}.xpi`);
+
+      // Only attempt to load unpacked directory if unofficial build.
+      if (!AppConstants.MOZILLA_OFFICIAL && !file.exists()) {
+        file = this.dir.clone();
+        file.append(`${id}`);
+      }
+
+      addons.set(id, file);
+    }
+
+    return addons;
+  }
+
+  get isSystem() {
+    return true;
+  }
+}
+
+/**
+ * An object which identifies a directory install location for system add-ons
+ * updates.
+ */
+class SystemAddonLocation extends DirectoryLocation {
+  /**
+   * The location consists of a directory which contains the add-ons installed.
+   *
+   * @param {string} name
+   *        The string identifier for the install location.
+   * @param {nsIFile} dir
+   *        The directory for the install location.
+   * @param {integer} scope
+   *        The scope of add-ons installed in this location.
+   * @param {boolean} resetSet
+   *        True to throw away the current add-on set
+   */
+  constructor(name, dir, scope, resetSet) {
+    let addonSet = SystemAddonLocation._loadAddonSet();
+    let directory = null;
+
+    // The system add-on update directory is stored in a pref.
+    // Therefore, this is looked up before calling the
+    // constructor on the superclass.
+    if (addonSet.directory) {
+      directory = getFile(addonSet.directory, dir);
+      logger.info(`SystemAddonLocation scanning directory ${directory.path}`);
+    } else {
+      logger.info("SystemAddonLocation directory is missing");
+    }
+
+    super(name, directory, scope, false);
+
+    this._addonSet = addonSet;
+    this._baseDir = dir;
+
+    if (resetSet) {
+      this.installer.resetAddonSet();
+    }
+  }
+
+  makeInstaller() {
+    if (this.locked) {
+      return null;
+    }
+    return new XPIInstall.SystemAddonInstaller(this);
+  }
+
+  /**
+   * Reads the current set of system add-ons
+   *
+   * @returns {Object}
+   */
+  static _loadAddonSet() {
+    try {
+      let setStr = Services.prefs.getStringPref(PREF_SYSTEM_ADDON_SET, null);
+      if (setStr) {
+        let addonSet = JSON.parse(setStr);
+        if ((typeof addonSet == "object") && addonSet.schema == 1) {
+          return addonSet;
+        }
+      }
+    } catch (e) {
+      logger.error("Malformed system add-on set, resetting.");
+    }
+
+    return { schema: 1, addons: {} };
+  }
+
+  readAddons() {
+    // Updated system add-ons are ignored in safe mode
+    if (Services.appinfo.inSafeMode) {
+      return new Map();
+    }
+
+    let addons = super.readAddons();
+
+    // Strip out any unexpected add-ons from the list
+    for (let id of addons.keys()) {
+      if (!(id in this._addonSet.addons)) {
+        addons.delete(id);
+      }
+    }
+
+    return addons;
+  }
+
+  /**
+   * Tests whether updated system add-ons are expected.
+   *
+   * @returns {boolean}
+   */
+  isActive() {
+    return this.dir != null;
+  }
+
+  get isSystem() {
+    return true;
+  }
+}
+
+/**
+ * An object that identifies a registry install location for add-ons. The location
+ * consists of a registry key which contains string values mapping ID to the
+ * path where an add-on is installed
+ *
+ */
+class WinRegLocation extends XPIStateLocation {
+  /**
+   * @param {string} name
+   *        The string identifier for the install location.
+   * @param {integer} rootKey
+   *        The root key (one of the ROOT_KEY_ values from nsIWindowsRegKey).
+   * @param {integer} scope
+   *        The scope of add-ons installed in this location.
+   */
+  constructor(name, rootKey, scope) {
+    super(name, undefined, scope);
+
+    this.locked = true;
+    this._rootKey = rootKey;
+  }
+
+  /**
+   * Retrieves the path of this Application's data key in the registry.
+   */
+  get _appKeyPath() {
+    let appVendor = Services.appinfo.vendor;
+    let appName = Services.appinfo.name;
+
+    // XXX Thunderbird doesn't specify a vendor string
+    if (appVendor == "" && AppConstants.MOZ_APP_NAME == "thunderbird")
+      appVendor = "Mozilla";
+
+    return `SOFTWARE\\${appVendor}\\${appName}`;
+  }
+
+  /**
+   * Read the registry and build a mapping between ID and path for each
+   * installed add-on.
+   *
+   * @returns {Map<AddonID, nsIFile>}
+   *        A map of add-ons in this location.
+   */
+  readAddons() {
+    let addons = new Map();
+
+    let path = `${this._appKeyPath}\\Extensions`;
+    let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(Ci.nsIWindowsRegKey);
+
+    // Reading the registry may throw an exception, and that's ok.  In error
+    // cases, we just leave ourselves in the empty state.
+    try {
+      key.open(this._rootKey, path, Ci.nsIWindowsRegKey.ACCESS_READ);
+    } catch (e) {
+      return addons;
+    }
+
+    try {
+      let count = key.valueCount;
+      for (let i = 0; i < count; ++i) {
+        let id = key.getValueName(i);
+        let file = new nsIFile(key.readStringValue(id));
+        if (!file.exists()) {
+          logger.warn(`Ignoring missing add-on in ${file.path}`);
+          continue;
+        }
+
+        addons.set(id, file);
+      }
+    } finally {
+      key.close();
+    }
+
+    return addons;
+  }
 }
 
 /**
  * Keeps track of the state of XPI add-ons on the file system.
  */
 var XPIStates = {
-  // Map(location name -> Map(add-on ID -> XPIState))
-  db: null,
+  // Map(location-name -> XPIStateLocation)
+  db: new Map(),
 
   _jsonFile: 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() {
     let count = 0;
-    if (this.db) {
-      for (let location of this.db.values()) {
-        count += location.size;
-      }
+    for (let location of this.locations()) {
+      count += location.size;
     }
     return count;
   },
 
   /**
    * Migrates state data from the xpiState and bootstrappedAddons
    * preferences and adds it to the DB. Returns a JSON-compatible
    * representation of the current state of the DB.
@@ -1004,118 +1483,112 @@ var XPIStates = {
    *
    * @param {boolean} [ignoreSideloads = true]
    *        If true, ignore changes in scopes where we don't accept
    *        side-loads.
    *
    * @returns {boolean}
    *        True if anything has changed.
    */
-  getInstallState(ignoreSideloads = true) {
-    if (!this.db) {
-      this.db = new Map();
-    }
-
+  scanForChanges(ignoreSideloads = true) {
     let oldState = this.initialStateData || this.loadExtensionState();
     this.initialStateData = oldState;
 
     let changed = false;
     let oldLocations = new Set(Object.keys(oldState));
 
-    for (let location of XPIProvider.installLocations) {
-      oldLocations.delete(location.name);
-
-      // The results of scanning this location.
-      let loc = this.getLocation(location.name, location.path || null,
-                                 oldState[location.name] || undefined);
+    for (let loc of XPIStates.locations()) {
+      oldLocations.delete(loc.name);
+
+      if (oldState[loc.name]) {
+        loc.restore(oldState[loc.name]);
+      }
       changed = changed || loc.changed;
 
       // Don't bother checking scopes where we don't accept side-loads.
-      if (ignoreSideloads && !(location.scope & gStartupScanScopes)) {
+      if (ignoreSideloads && !(loc.scope & gStartupScanScopes)) {
         continue;
       }
 
-      if (location.name == KEY_APP_TEMPORARY) {
+      if (loc.isTemporary) {
         continue;
       }
 
       let knownIds = new Set(loc.keys());
-      for (let [id, file] of location.getAddonLocations(true)) {
+      for (let [id, file] of loc.readAddons()) {
         knownIds.delete(id);
 
         let xpiState = loc.get(id);
         if (!xpiState) {
-          logger.debug("New add-on ${id} in ${location}", {id, location: location.name});
+          logger.debug("New add-on ${id} in ${loc}", {id, location: loc.name});
 
           changed = true;
           xpiState = loc.addFile(id, file);
-          if (!location.isSystem) {
+          if (!loc.isSystem) {
             this.sideLoadedAddons.set(id, xpiState);
           }
         } else {
           let addonChanged = (xpiState.getModTime(file, id) ||
                               file.path != xpiState.path);
           xpiState.file = file.clone();
 
           if (addonChanged) {
             changed = true;
-            logger.debug("Changed add-on ${id} in ${location}", {id, location: location.name});
+            logger.debug("Changed add-on ${id} in ${loc}", {id, location: loc.name});
           } else {
-            logger.debug("Existing add-on ${id} in ${location}", {id, location: location.name});
+            logger.debug("Existing add-on ${id} in ${loc}", {id, location: loc.name});
           }
         }
-        XPIProvider.setTelemetry(id, "location", location.name);
+        XPIProvider.setTelemetry(id, "location", loc.name);
       }
 
       // Anything left behind in oldState was removed from the file system.
       for (let id of knownIds) {
         loc.delete(id);
         changed = true;
       }
     }
 
     // If there's anything left in oldState, an install location that held add-ons
     // was removed from the browser configuration.
     changed = changed || oldLocations.size > 0;
 
-    logger.debug("getInstallState changed: ${rv}, state: ${state}",
+    logger.debug("scanForChanges changed: ${rv}, state: ${state}",
         {rv: changed, state: this.db});
     return changed;
   },
 
+  locations() {
+    return this.db.values();
+  },
+
+  /**
+   * @param {string} name
+   *        The location name.
+   * @param {XPIStateLocation} location
+   *        The location object.
+   */
+  addLocation(name, location) {
+    if (this.db.has(name)) {
+      throw new Error(`Trying to add duplicate location: ${name}`);
+    }
+    this.db.set(name, location);
+  },
+
   /**
    * Get the Map of XPI states for a particular location.
    *
    * @param {string} name
    *        The name of the install location.
-   * @param {string?} [path]
-   *        The expected path of the location, if known.
-   * @param {Object?} [saved]
-   *        The saved data for the location, as read from the
-   *        addonStartup.json file.
    *
    * @returns {XPIStateLocation?}
    *        (id -> XPIState) or null if there are no add-ons in the location.
    */
-  getLocation(name, path, saved) {
-    let location = this.db.get(name);
-
-    if (path && location && location.path != path) {
-      location = null;
-      saved = null;
-    }
-
-    if (!location || (path && location.path != path)) {
-      let loc = XPIProvider.installLocationsByName[name];
-      if (loc) {
-        location = new XPIStateLocation(name, path || loc.path || null, saved);
-        this.db.set(name, location);
-      }
-    }
-    return location;
+  getLocation(name) {
+    return this.db.get(name);
   },
 
   /**
    * Get the XPI state for a specific add-on in a location.
    * If the state is not in our cache, return null.
    *
    * @param {string} aLocation
    *        The name of the location where the add-on is installed.
@@ -1139,49 +1612,48 @@ var XPIStates = {
    *        An optional filter to apply to install locations.  If provided,
    *        addons in locations that do not match the filter are not considered.
    *
    * @returns {XPIState?}
    */
   findAddon(aId, aFilter = location => true) {
     // Fortunately the Map iterator returns in order of insertion, which is
     // also our highest -> lowest priority order.
-    for (let location of this.db.values()) {
+    for (let location of this.locations()) {
       if (!aFilter(location)) {
         continue;
       }
       if (location.has(aId)) {
         return location.get(aId);
       }
     }
     return undefined;
   },
 
   /**
    * Iterates over the list of all enabled add-ons in any location.
    */
   * enabledAddons() {
-    for (let location of this.db.values()) {
+    for (let location of this.locations()) {
       for (let entry of location.values()) {
         if (entry.enabled) {
           yield entry;
         }
       }
     }
   },
 
   /**
    * Add a new XPIState for an add-on and synchronize it with the DBAddonInternal.
    *
    * @param {DBAddonInternal} aAddon
    *        The add-on to add.
    */
   addAddon(aAddon) {
-    let location = this.getLocation(aAddon._installLocation.name);
-    location.addAddon(aAddon);
+    aAddon.location.addAddon(aAddon);
   },
 
   /**
    * Save the current state of installed add-ons.
    */
   save() {
     if (!this._jsonFile) {
       this._jsonFile = new JSONFile({
@@ -1193,40 +1665,37 @@ var XPIStates = {
     }
 
     this._jsonFile.saveSoon();
   },
 
   toJSON() {
     let data = {};
     for (let [key, loc] of this.db.entries()) {
-      if (key != TemporaryInstallLocation.name && (loc.size || loc.hasStaged)) {
+      if (!loc.isTemporary && (loc.size || loc.hasStaged)) {
         data[key] = loc;
       }
     }
     return data;
   },
 
   /**
    * Remove the XPIState for an add-on and save the new state.
    *
    * @param {string} aLocation
    *        The name of the add-on location.
    * @param {string} aId
    *        The ID of the add-on.
    *
    */
   removeAddon(aLocation, aId) {
-    logger.debug("Removing XPIState for " + aLocation + ":" + aId);
+    logger.debug(`Removing XPIState for ${aLocation}: ${aId}`);
     let location = this.db.get(aLocation);
     if (location) {
-      location.delete(aId);
-      if (location.size == 0) {
-        this.db.delete(aLocation);
-      }
+      location.removeAddon(aId);
       this.save();
     }
   },
 
   /**
    * Disable the XPIState for an add-on.
    *
    * @param {string} aId
@@ -1338,25 +1807,24 @@ class BootstrapScope {
           this._pendingDisable = true;
           for (let addon of XPIProvider.getDependentAddons(this.addon)) {
             if (addon.active)
               await XPIDatabase.updateAddonDisabledState(addon);
           }
         }
       }
 
-      let installLocation = addon._installLocation || null;
       let params = {
         id: addon.id,
         version: addon.version,
         installPath: this.file.clone(),
         resourceURI: getURIForResourceInFile(this.file, ""),
         signedState: addon.signedState,
-        temporarilyInstalled: installLocation == TemporaryInstallLocation,
-        builtIn: installLocation instanceof BuiltInInstallLocation,
+        temporarilyInstalled: addon.location.isTemporary,
+        builtIn: addon.location instanceof BuiltInLocation,
       };
 
       if (aMethod == "startup" && addon.startupData) {
         params.startupData = addon.startupData;
       }
 
       Object.assign(params, aExtraParams);
 
@@ -1664,20 +2132,16 @@ class BootstrapScope {
 
 var XPIProvider = {
   get name() {
     return "XPIProvider";
   },
 
   BOOTSTRAP_REASONS: Object.freeze(BOOTSTRAP_REASONS),
 
-  // An array of known install locations
-  installLocations: null,
-  // A dictionary of known install locations by name
-  installLocationsByName: null,
   // A Map of active addons to their bootstrapScope by ID
   activeAddons: new Map(),
   // True if the platform could have activated extensions
   extensionsActive: false,
   // New distribution addons awaiting permissions approval
   newDistroAddons: null,
   // Keep track of startup phases for telemetry
   runPhase: XPI_STARTING,
@@ -1789,99 +2253,93 @@ var XPIProvider = {
         c.cancel();
       } catch (e) {
         logger.warn("Cancel failed", e);
       }
     }
   },
 
   setupInstallLocations(aAppChanged) {
-    function DirectoryLocation(aName, aScope, aKey, aPaths, aLocked) {
+    function DirectoryLoc(aName, aScope, aKey, aPaths, aLocked) {
       try {
         var dir = FileUtils.getDir(aKey, aPaths);
       } catch (e) {
         return null;
       }
-      if (aLocked) {
-        return new DirectoryInstallLocation(aName, dir, aScope);
-      }
-      return new MutableDirectoryInstallLocation(aName, dir, aScope);
+      return new DirectoryLocation(aName, dir, aScope, aLocked);
     }
 
-    function BuiltInLocation(name, scope, key, paths) {
+    function BuiltInLoc(name, scope, key, paths) {
       try {
         var dir = FileUtils.getDir(key, paths);
       } catch (e) {
         return null;
       }
-      return new BuiltInInstallLocation(name, dir, scope);
+      return new BuiltInLocation(name, dir, scope);
     }
 
-    function SystemLocation(aName, aScope, aKey, aPaths) {
+    function SystemLoc(aName, aScope, aKey, aPaths) {
       try {
         var dir = FileUtils.getDir(aKey, aPaths);
       } catch (e) {
         return null;
       }
-      return new SystemAddonInstallLocation(aName, dir, aScope, aAppChanged !== false);
+      return new SystemAddonLocation(aName, dir, aScope, aAppChanged !== false);
     }
 
-    function RegistryLocation(aName, aScope, aKey) {
+    function RegistryLoc(aName, aScope, aKey) {
       if ("nsIWindowsRegKey" in Ci) {
-        return new WinRegInstallLocation(aName, Ci.nsIWindowsRegKey[aKey], aScope);
+        return new WinRegLocation(aName, Ci.nsIWindowsRegKey[aKey], aScope);
       }
     }
 
     let enabledScopes = Services.prefs.getIntPref(PREF_EM_ENABLED_SCOPES,
                                                   AddonManager.SCOPE_ALL);
     // The profile location is always enabled
     enabledScopes |= AddonManager.SCOPE_PROFILE;
 
     // These must be in order of priority, highest to lowest,
     // for processFileChanges etc. to work
     let locations = [
       [() => TemporaryInstallLocation, TemporaryInstallLocation.name, null],
 
-      [DirectoryLocation, KEY_APP_PROFILE, AddonManager.SCOPE_PROFILE,
+      [DirectoryLoc, KEY_APP_PROFILE, AddonManager.SCOPE_PROFILE,
        KEY_PROFILEDIR, [DIR_EXTENSIONS], false],
 
-      [SystemLocation, KEY_APP_SYSTEM_ADDONS, AddonManager.SCOPE_PROFILE,
+      [SystemLoc, KEY_APP_SYSTEM_ADDONS, AddonManager.SCOPE_PROFILE,
        KEY_PROFILEDIR, [DIR_SYSTEM_ADDONS]],
 
-      [BuiltInLocation, KEY_APP_SYSTEM_DEFAULTS, AddonManager.SCOPE_PROFILE,
+      [BuiltInLoc, KEY_APP_SYSTEM_DEFAULTS, AddonManager.SCOPE_PROFILE,
        KEY_APP_FEATURES, []],
 
-      [DirectoryLocation, KEY_APP_SYSTEM_USER, AddonManager.SCOPE_USER,
+      [DirectoryLoc, KEY_APP_SYSTEM_USER, AddonManager.SCOPE_USER,
        "XREUSysExt", [Services.appinfo.ID], true],
 
-      [RegistryLocation, "winreg-app-user", AddonManager.SCOPE_USER,
+      [RegistryLoc, "winreg-app-user", AddonManager.SCOPE_USER,
        "ROOT_KEY_CURRENT_USER"],
 
-      [DirectoryLocation, KEY_APP_GLOBAL, AddonManager.SCOPE_APPLICATION,
+      [DirectoryLoc, KEY_APP_GLOBAL, AddonManager.SCOPE_APPLICATION,
        KEY_ADDON_APP_DIR, [DIR_EXTENSIONS], true],
 
-      [DirectoryLocation, KEY_APP_SYSTEM_SHARE, AddonManager.SCOPE_SYSTEM,
+      [DirectoryLoc, KEY_APP_SYSTEM_SHARE, AddonManager.SCOPE_SYSTEM,
        "XRESysSExtPD", [Services.appinfo.ID], true],
 
-      [DirectoryLocation, KEY_APP_SYSTEM_LOCAL, AddonManager.SCOPE_SYSTEM,
+      [DirectoryLoc, KEY_APP_SYSTEM_LOCAL, AddonManager.SCOPE_SYSTEM,
        "XRESysLExtPD", [Services.appinfo.ID], true],
 
-      [RegistryLocation, "winreg-app-global", AddonManager.SCOPE_SYSTEM,
+      [RegistryLoc, "winreg-app-global", AddonManager.SCOPE_SYSTEM,
        "ROOT_KEY_LOCAL_MACHINE"],
     ];
 
-    this.installLocations = [];
-    this.installLocationsByName = {};
     for (let [constructor, name, scope, ...args] of locations) {
       if (!scope || enabledScopes & scope) {
         try {
           let loc = constructor(name, scope, ...args);
           if (loc) {
-            this.installLocations.push(loc);
-            this.installLocationsByName[name] = loc;
+            XPIStates.addLocation(name, loc);
           }
         } catch (e) {
           logger.warn(`Failed to add ${constructor.name} install location ${name}`, e);
         }
       }
     }
   },
 
@@ -1985,20 +2443,19 @@ var XPIProvider = {
               continue;
             }
 
             // If the add-on was pending disable then shut it down and remove it
             // from the persisted data.
             let reason = BOOTSTRAP_REASONS.APP_SHUTDOWN;
             if (addon._disable) {
               reason = BOOTSTRAP_REASONS.ADDON_DISABLE;
-            } else if (addon.location.name == KEY_APP_TEMPORARY) {
+            } else if (addon.location.isTemporary) {
               reason = BOOTSTRAP_REASONS.ADDON_UNINSTALL;
-              let existing = XPIStates.findAddon(addon.id, loc =>
-                loc.name != TemporaryInstallLocation.name);
+              let existing = XPIStates.findAddon(addon.id, loc => !loc.isTemporary);
               if (existing) {
                 reason = XPIInstall.newVersionReason(addon.version, existing.version);
               }
             }
 
             let promise = BootstrapScope.get(addon).shutdown(reason);
             AsyncShutdown.profileChangeTeardown.addBlocker(
               `Extension shutdown: ${addon.id}`, promise);
@@ -2089,53 +2546,48 @@ var XPIProvider = {
 
     // Ugh, if we reach this point without loading the xpi database,
     // we need to load it know, otherwise the telemetry shutdown blocker
     // will never resolve.
     if (!XPIDatabase.initialized) {
       await XPIDatabase.asyncLoadDB();
     }
 
-    this.installLocations = null;
-    this.installLocationsByName = null;
-
     // This is needed to allow xpcshell tests to simulate a restart
     this.extensionsActive = false;
 
     await XPIDatabase.shutdown();
   },
 
   cleanupTemporaryAddons() {
     let promises = [];
-    let tempLocation = XPIStates.getLocation(TemporaryInstallLocation.name);
-    if (tempLocation) {
-      for (let [id, addon] of tempLocation.entries()) {
-        tempLocation.delete(id);
-
-        let bootstrap = BootstrapScope.get(addon);
-        let existing = XPIStates.findAddon(id, loc => loc != tempLocation);
-
-        let cleanup = () => {
-          TemporaryInstallLocation.uninstallAddon(id);
-          XPIStates.removeAddon(TemporaryInstallLocation.name, id);
-        };
-
-        let promise;
-        if (existing) {
-          promise = bootstrap.update(existing, false, () => {
-            cleanup();
-            XPIDatabase.makeAddonLocationVisible(id, existing.location.name);
-          });
-        } else {
-          promise = bootstrap.uninstall().then(cleanup);
-        }
-        AsyncShutdown.profileChangeTeardown.addBlocker(
-          `Temporary extension shutdown: ${addon.id}`, promise);
-        promises.push(promise);
+    let tempLocation = TemporaryInstallLocation;
+    for (let [id, addon] of tempLocation.entries()) {
+      tempLocation.delete(id);
+
+      let bootstrap = BootstrapScope.get(addon);
+      let existing = XPIStates.findAddon(id, loc => loc != tempLocation);
+
+      let cleanup = () => {
+        tempLocation.installer.uninstallAddon(id);
+        tempLocation.removeAddon(id);
+      };
+
+      let promise;
+      if (existing) {
+        promise = bootstrap.update(existing, false, () => {
+          cleanup();
+          XPIDatabase.makeAddonLocationVisible(id, existing.location);
+        });
+      } else {
+        promise = bootstrap.uninstall().then(cleanup);
       }
+      AsyncShutdown.profileChangeTeardown.addBlocker(
+        `Temporary extension shutdown: ${addon.id}`, promise);
+      promises.push(promise);
     }
     return Promise.all(promises);
   },
 
   /**
    * Adds a list of currently active add-ons to the next crash report.
    */
   addAddonsToCrashReporter() {
@@ -2166,53 +2618,51 @@ var XPIProvider = {
    * @param {Object} aManifests
    *         A dictionary to add detected install manifests to for the purpose
    *         of passing through updated compatibility information
    * @returns {boolean}
    *        True if an add-on was installed or uninstalled
    */
   processPendingFileChanges(aManifests) {
     let changed = false;
-    for (let location of this.installLocations) {
-      aManifests[location.name] = {};
+    for (let loc of XPIStates.locations()) {
+      aManifests[loc.name] = {};
       // We can't install or uninstall anything in locked locations
-      if (location.locked) {
+      if (loc.locked) {
         continue;
       }
 
-      let state = XPIStates.getLocation(location.name);
-
       let cleanNames = [];
       let promises = [];
-      for (let [id, metadata] of state.getStagedAddons()) {
-        state.unstageAddon(id);
-
-        aManifests[location.name][id] = null;
+      for (let [id, metadata] of loc.getStagedAddons()) {
+        loc.unstageAddon(id);
+
+        aManifests[loc.name][id] = null;
         promises.push(
-          XPIInstall.installStagedAddon(id, metadata, location).then(
+          XPIInstall.installStagedAddon(id, metadata, loc).then(
             addon => {
-              aManifests[location.name][id] = addon;
+              aManifests[loc.name][id] = addon;
             },
             error => {
-              delete aManifests[location.name][id];
+              delete aManifests[loc.name][id];
               cleanNames.push(`${id}.xpi`);
 
-              logger.error(`Failed to install staged add-on ${id} in ${location.name}`,
+              logger.error(`Failed to install staged add-on ${id} in ${loc.name}`,
                            error);
             }));
       }
 
       if (promises.length) {
         changed = true;
         awaitPromise(Promise.all(promises));
       }
 
       try {
         if (cleanNames.length) {
-          location.cleanStagingDir(cleanNames);
+          loc.installer.cleanStagingDir(cleanNames);
         }
       } catch (e) {
         // Non-critical, just saves some perf on startup if we clean this up.
         logger.debug("Error cleaning staging dir", e);
       }
     }
     return changed;
   },
@@ -2237,23 +2687,22 @@ var XPIProvider = {
       distroDir = FileUtils.getDir(KEY_APP_DISTRIBUTION, [DIR_EXTENSIONS]);
       if (!distroDir.isDirectory())
         return false;
     } catch (e) {
       return false;
     }
 
     let changed = false;
-    let profileLocation = this.installLocationsByName[KEY_APP_PROFILE];
+    let profileLocation = XPIStates.getLocation(KEY_APP_PROFILE);
 
     let entries = distroDir.directoryEntries
                            .QueryInterface(Ci.nsIDirectoryEnumerator);
     let entry;
     while ((entry = entries.nextFile)) {
-
       let id = entry.leafName;
       if (id.endsWith(".xpi")) {
         id = id.slice(0, -4);
       } else {
         logger.debug("Ignoring distribution add-on that isn't an XPI: " + entry.path);
         continue;
       }
 
@@ -2277,17 +2726,17 @@ var XPIProvider = {
           // and we'll have overwritten that so instead cache our install manifest
           // which will later be put into the database in processFileChanges
           if (!(KEY_APP_PROFILE in aManifests))
             aManifests[KEY_APP_PROFILE] = {};
           aManifests[KEY_APP_PROFILE][id] = addon;
           changed = true;
         }
       } catch (e) {
-        logger.error("Failed to install distribution add-on " + entry.path, e);
+        logger.error(`Failed to install distribution add-on ${entry.path}`, e);
       }
     }
 
     entries.close();
 
     return changed;
   },
 
@@ -2335,17 +2784,17 @@ var XPIProvider = {
 
     // Keep track of whether and why we need to open and update the database at
     // startup time.
     let updateReasons = [];
     if (aAppChanged) {
       updateReasons.push("appChanged");
     }
 
-    let installChanged = XPIStates.getInstallState(aAppChanged === false);
+    let installChanged = XPIStates.scanForChanges(aAppChanged === false);
     if (installChanged) {
       updateReasons.push("directoryState");
     }
 
     // First install any new add-ons into the locations, if there are any
     // changes then we must update the database with the information in the
     // install locations
     let manifests = {};
@@ -2435,17 +2884,17 @@ var XPIProvider = {
   /**
    * 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)) {
+    if (XPIStates.scanForChanges(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(),
@@ -2595,17 +3044,17 @@ var XPIProvider = {
       throw new Error("XPIStates not yet initialized");
     }
 
     let result = [];
     for (let addon of XPIStates.enabledAddons()) {
       if (aTypes && !aTypes.includes(addon.type)) {
         continue;
       }
-      let location = this.installLocationsByName[addon.location.name];
+      let {location} = addon;
       let scope, isSystem;
       if (location) {
         ({scope, isSystem} = location);
       }
       result.push({
         id: addon.id,
         version: addon.version,
         type: addon.type,
@@ -2716,538 +3165,34 @@ var XPIProvider = {
       case PREF_ALLOW_LEGACY:
         this.updateAddonAppDisabledStates();
         break;
       }
     }
   },
 };
 
-for (let meth of ["cancelUninstallAddon", "getInstallForFile",
-                  "getInstallForURL", "getInstallsByTypes",
+for (let meth of ["getInstallForFile", "getInstallForURL", "getInstallsByTypes",
                   "installTemporaryAddon", "isInstallAllowed",
-                  "isInstallEnabled", "uninstallAddon",
-                  "updateSystemAddons"]) {
+                  "isInstallEnabled", "updateSystemAddons"]) {
   XPIProvider[meth] = function() {
     return XPIInstall[meth](...arguments);
   };
 }
 
-function forwardInstallMethods(cls, methods) {
-  let {prototype} = cls;
-  for (let meth of methods) {
-    prototype[meth] = function() {
-      return XPIInstall[cls.name].prototype[meth].apply(this, arguments);
-    };
-  }
-}
-
-/**
- * An object which identifies a directory install location for add-ons. The
- * location consists of a directory which contains the add-ons installed in the
- * location.
- *
- */
-class DirectoryInstallLocation {
-  /**
-   * Each add-on installed in the location is either a directory containing the
-   * add-on's files or a text file containing an absolute path to the directory
-   * containing the add-ons files. The directory or text file must have the same
-   * name as the add-on's ID.
-   *
-   * @param {string} aName
-   *        The string identifier for the install location
-   * @param {nsIFile} aDirectory
-   *        The nsIFile directory for the install location
-   * @param {integer} aScope
-   *        The scope of add-ons installed in this location
-  */
-  constructor(aName, aDirectory, aScope) {
-    this._name = aName;
-    this.locked = true;
-    this._directory = aDirectory;
-    this._scope = aScope;
-    this._IDToFileMap = {};
-    this._linkedAddons = [];
-
-    this.isSystem = (aName == KEY_APP_SYSTEM_ADDONS ||
-                     aName == KEY_APP_SYSTEM_DEFAULTS);
-
-    if (!aDirectory || !aDirectory.exists())
-      return;
-    if (!aDirectory.isDirectory())
-      throw new Error("Location must be a directory.");
-
-    this.initialized = false;
-  }
-
-  get path() {
-    return this._directory && this._directory.path;
-  }
-
-  /**
-   * Reads a directory linked to in a file.
-   *
-   * @param {nsIFile} aFile
-   *        The file containing the directory path
-   * @returns {nsIFile?}
-   *        An nsIFile object representing the linked directory, or null
-   *        on error.
-   */
-  _readDirectoryFromFile(aFile) {
-    let linkedDirectory;
-    if (aFile.isSymlink()) {
-      linkedDirectory = aFile.clone();
-      try {
-        linkedDirectory.normalize();
-      } catch (e) {
-        logger.warn("Symbolic link " + aFile.path + " points to a path" +
-             " which does not exist");
-        return null;
-      }
-    } else {
-      let fis = new FileInputStream(aFile, -1, -1, false);
-      let line = { value: "" };
-      fis.QueryInterface(Ci.nsILineInputStream).readLine(line);
-      fis.close();
-      if (line.value) {
-        linkedDirectory = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
-        try {
-          linkedDirectory.initWithPath(line.value);
-        } catch (e) {
-          linkedDirectory.setRelativeDescriptor(aFile.parent, line.value);
-        }
-      }
-    }
-
-    if (linkedDirectory) {
-      if (!linkedDirectory.exists()) {
-        logger.warn("File pointer " + aFile.path + " points to " + linkedDirectory.path +
-             " which does not exist");
-        return null;
-      }
-
-      if (!linkedDirectory.isDirectory()) {
-        logger.warn("File pointer " + aFile.path + " points to " + linkedDirectory.path +
-             " which is not a directory");
-        return null;
-      }
-
-      return linkedDirectory;
-    }
-
-    logger.warn("File pointer " + aFile.path + " does not contain a path");
-    return null;
-  }
-
-  /**
-   * Finds all the add-ons installed in this location.
-   *
-   * @param {boolean} [rescan = false]
-   *        True if the directory should be re-scanned, even if it has
-   *        already been initialized.
-   */
-  _readAddons(rescan = false) {
-    if ((this.initialized && !rescan) || !this._directory) {
-      return;
-    }
-    this.initialized = true;
-
-    // Use a snapshot of the directory contents to avoid possible issues with
-    // iterating over a directory while removing files from it (the YAFFS2
-    // embedded filesystem has this issue, see bug 772238).
-    let entries = getDirectoryEntries(this._directory);
-    for (let entry of entries) {
-      let id = entry.leafName;
-
-      if (id == DIR_STAGE || id == DIR_TRASH)
-        continue;
-
-      let isFile = id.toLowerCase().endsWith(".xpi");
-      if (isFile) {
-        id = id.substring(0, id.length - 4);
-      }
-
-      if (!gIDTest.test(id)) {
-        logger.debug("Ignoring file entry whose name is not a valid add-on ID: " +
-                     entry.path);
-        continue;
-      }
-
-      if (!isFile && (entry.isFile() || entry.isSymlink())) {
-        let newEntry = this._readDirectoryFromFile(entry);
-        if (!newEntry) {
-          logger.debug("Deleting stale pointer file " + entry.path);
-          try {
-            entry.remove(true);
-          } catch (e) {
-            logger.warn("Failed to remove stale pointer file " + entry.path, e);
-            // Failing to remove the stale pointer file is ignorable
-          }
-          continue;
-        }
-
-        entry = newEntry;
-        this._linkedAddons.push(id);
-      }
-
-      this._IDToFileMap[id] = entry;
-    }
-  }
-
-  /**
-   * Gets the name of this install location.
-   */
-  get name() {
-    return this._name;
-  }
-
-  /**
-   * Gets the scope of this install location.
-   */
-  get scope() {
-    return this._scope;
-  }
-
-  /**
-   * Gets an map of files for add-ons installed in this location.
-   *
-   * @param {boolean} [rescan = false]
-   *        True if the directory should be re-scanned, even if it has
-   *        already been initialized.
-   *
-   * @returns {Map<string, nsIFile>}
-   *        A map of all add-ons in the location, with each add-on's ID
-   *        as the key and an nsIFile for its location as the value.
-   */
-  getAddonLocations(rescan = false) {
-    this._readAddons(rescan);
-
-    let locations = new Map();
-    for (let id in this._IDToFileMap) {
-      locations.set(id, this._IDToFileMap[id].clone());
-    }
-    return locations;
-  }
-
-  /**
-   * Gets the directory that the add-on with the given ID is installed in.
-   *
-   * @param {string} aId
-   *        The ID of the add-on
-   * @returns {nsIFile}
-   * @throws if the ID does not match any of the add-ons installed
-   */
-  getLocationForID(aId) {
-    if (!(aId in this._IDToFileMap))
-      this._readAddons();
-
-    if (aId in this._IDToFileMap)
-      return this._IDToFileMap[aId].clone();
-    throw new Error("Unknown add-on ID " + aId);
-  }
-
-  /**
-   * Returns true if the given addon was installed in this location by a text
-   * file pointing to its real path.
-   *
-   * @param {string} aId
-   *        The ID of the addon
-   * @returns {boolean}
-   */
-  isLinkedAddon(aId) {
-    return this._linkedAddons.includes(aId);
-  }
-}
-
-/**
- * An extension of DirectoryInstallLocation which adds methods to installing
- * and removing add-ons from the directory at runtime.
- */
-class MutableDirectoryInstallLocation extends DirectoryInstallLocation {
-  /**
-   * @param {string} aName
-   *        The string identifier for the install location
-   * @param {nsIFile} aDirectory
-   *        The nsIFile directory for the install location
-   * @param {integer} aScope
-   *        The scope of add-ons installed in this location
-   */
-  constructor(aName, aDirectory, aScope) {
-    super(aName, aDirectory, aScope);
-
-    this.locked = false;
-    this._stagingDirLock = 0;
-  }
-}
-forwardInstallMethods(MutableDirectoryInstallLocation,
-                      ["cleanStagingDir", "getStagingDir", "getTrashDir",
-                       "installAddon", "releaseStagingDir", "requestStagingDir",
-                       "uninstallAddon"]);
-
-/**
- * An object which identifies a built-in install location for add-ons, such
- * as default system add-ons.
- *
- * This location should point either to a XPI, or a directory in a local build.
- */
-class BuiltInInstallLocation extends DirectoryInstallLocation {
-  /**
-   * Read the manifest of allowed add-ons and build a mapping between ID and URI
-   * for each.
-   */
-  _readAddons() {
-    let manifest;
-    try {
-      let url = Services.io.newURI(BUILT_IN_ADDONS_URI);
-      let data = Cu.readUTF8URI(url);
-      manifest = JSON.parse(data);
-    } catch (e) {
-      logger.warn("List of valid built-in add-ons could not be parsed.", e);
-      return;
-    }
-
-    if (!("system" in manifest)) {
-      logger.warn("No list of valid system add-ons found.");
-      return;
-    }
-
-    for (let id of manifest.system) {
-      let file = new FileUtils.File(this._directory.path);
-      file.append(`${id}.xpi`);
-
-      // Only attempt to load unpacked directory if unofficial build.
-      if (!AppConstants.MOZILLA_OFFICIAL && !file.exists()) {
-        file = new FileUtils.File(this._directory.path);
-        file.append(`${id}`);
-      }
-
-      this._IDToFileMap[id] = file;
-    }
-  }
-}
-
-/**
- * An object which identifies a directory install location for system add-ons
- * updates.
- */
-class SystemAddonInstallLocation extends MutableDirectoryInstallLocation {
-  /**
-    * The location consists of a directory which contains the add-ons installed.
-    *
-    * @param {string} aName
-    *        The string identifier for the install location
-    * @param {nsIFile} aDirectory
-    *        The nsIFile directory for the install location
-    * @param {integer} aScope
-    *        The scope of add-ons installed in this location
-    * @param {boolean} aResetSet
-    *        True to throw away the current add-on set
-    */
-  constructor(aName, aDirectory, aScope, aResetSet) {
-    let addonSet = SystemAddonInstallLocation._loadAddonSet();
-    let directory = null;
-
-    // The system add-on update directory is stored in a pref.
-    // Therefore, this is looked up before calling the
-    // constructor on the superclass.
-    if (addonSet.directory) {
-      directory = getFile(addonSet.directory, aDirectory);
-      logger.info("SystemAddonInstallLocation scanning directory " + directory.path);
-    } else {
-      logger.info("SystemAddonInstallLocation directory is missing");
-    }
-
-    super(aName, directory, aScope);
-
-    this._addonSet = addonSet;
-    this._baseDir = aDirectory;
-    this._nextDir = null;
-    this._directory = directory;
-
-    this._stagingDirLock = 0;
-
-    if (aResetSet) {
-      this.resetAddonSet();
-    }
-
-    this.locked = false;
-  }
-
-  /**
-   * Reads the current set of system add-ons
-   *
-   * @returns {Object}
-   */
-  static _loadAddonSet() {
-    try {
-      let setStr = Services.prefs.getStringPref(PREF_SYSTEM_ADDON_SET, null);
-      if (setStr) {
-        let addonSet = JSON.parse(setStr);
-        if ((typeof addonSet == "object") && addonSet.schema == 1) {
-          return addonSet;
-        }
-      }
-    } catch (e) {
-      logger.error("Malformed system add-on set, resetting.");
-    }
-
-    return { schema: 1, addons: {} };
-  }
-
-  getAddonLocations() {
-    // Updated system add-ons are ignored in safe mode
-    if (Services.appinfo.inSafeMode) {
-      return new Map();
-    }
-
-    let addons = super.getAddonLocations();
-
-    // Strip out any unexpected add-ons from the list
-    for (let id of addons.keys()) {
-      if (!(id in this._addonSet.addons)) {
-        addons.delete(id);
-      }
-    }
-
-    return addons;
-  }
-
-  /**
-   * Tests whether updated system add-ons are expected.
-   *
-   * @returns {boolean}
-   */
-  isActive() {
-    return this._directory != null;
-  }
-}
-
-forwardInstallMethods(SystemAddonInstallLocation,
-                      ["cleanDirectories", "cleanStagingDir", "getStagingDir",
-                       "getTrashDir", "installAddon", "installAddon",
-                       "installAddonSet", "isValid", "isValidAddon",
-                       "releaseStagingDir", "requestStagingDir",
-                       "resetAddonSet", "resumeAddonSet", "uninstallAddon",
-                       "uninstallAddon"]);
-
-/** An object which identifies an install location for temporary add-ons.
- */
-const TemporaryInstallLocation = { locked: false, name: KEY_APP_TEMPORARY,
-  scope: AddonManager.SCOPE_TEMPORARY,
-  getAddonLocations: () => [], isLinkedAddon: () => false, installAddon:
-    () => {}, uninstallAddon: (aAddon) => {}, getStagingDir: () => {},
-};
-
-/**
- * An object that identifies a registry install location for add-ons. The location
- * consists of a registry key which contains string values mapping ID to the
- * path where an add-on is installed
- *
- */
-class WinRegInstallLocation extends DirectoryInstallLocation {
-  /**
-    * @param {string} aName
-    *        The string identifier of this Install Location.
-    * @param {integer} aRootKey
-    *        The root key (one of the ROOT_KEY_ values from nsIWindowsRegKey).
-    * @param {integer} aScope
-    *        The scope of add-ons installed in this location
-    */
-  constructor(aName, aRootKey, aScope) {
-    super(aName, undefined, aScope);
-
-    this.locked = true;
-    this._name = aName;
-    this._rootKey = aRootKey;
-    this._scope = aScope;
-    this._IDToFileMap = {};
-
-    let path = this._appKeyPath + "\\Extensions";
-    let key = Cc["@mozilla.org/windows-registry-key;1"].createInstance(Ci.nsIWindowsRegKey);
-
-    // Reading the registry may throw an exception, and that's ok.  In error
-    // cases, we just leave ourselves in the empty state.
-    try {
-      key.open(this._rootKey, path, Ci.nsIWindowsRegKey.ACCESS_READ);
-    } catch (e) {
-      return;
-    }
-
-    this._readAddons(key);
-    key.close();
-  }
-
-  /**
-   * Retrieves the path of this Application's data key in the registry.
-   */
-  get _appKeyPath() {
-    let appVendor = Services.appinfo.vendor;
-    let appName = Services.appinfo.name;
-
-    // XXX Thunderbird doesn't specify a vendor string
-    if (AppConstants.MOZ_APP_NAME == "thunderbird" && appVendor == "")
-      appVendor = "Mozilla";
-
-    // XULRunner-based apps may intentionally not specify a vendor
-    if (appVendor != "")
-      appVendor += "\\";
-
-    return "SOFTWARE\\" + appVendor + appName;
-  }
-
-  /**
-   * Read the registry and build a mapping between ID and path for each
-   * installed add-on.
-   *
-   * @param {nsIWindowsRegKey} aKey
-   *        The key that contains the ID to path mapping
-   */
-  _readAddons(aKey) {
-    let count = aKey.valueCount;
-    for (let i = 0; i < count; ++i) {
-      let id = aKey.getValueName(i);
-
-      let file = new nsIFile(aKey.readStringValue(id));
-
-      if (!file.exists()) {
-        logger.warn("Ignoring missing add-on in " + file.path);
-        continue;
-      }
-
-      this._IDToFileMap[id] = file;
-    }
-  }
-
-  /**
-   * Gets the name of this install location.
-   */
-  get name() {
-    return this._name;
-  }
-
-  /*
-   * @see DirectoryInstallLocation
-   */
-  isLinkedAddon(aId) {
-    return true;
-  }
-}
-
 var XPIInternal = {
   BOOTSTRAP_REASONS,
   BootstrapScope,
   DB_SCHEMA,
   KEY_APP_SYSTEM_ADDONS,
   KEY_APP_SYSTEM_DEFAULTS,
-  KEY_APP_TEMPORARY,
   PREF_BRANCH_INSTALLED_ADDON,
   PREF_SYSTEM_ADDON_SET,
   SIGNED_TYPES,
-  SystemAddonInstallLocation,
+  SystemAddonLocation,
   TEMPORARY_ADDON_SUFFIX,
   TOOLKIT_ID,
   TemporaryInstallLocation,
   XPIProvider,
   XPIStates,
   XPI_PERMISSION,
   awaitPromise,
   canRunInSafeMode,
--- a/toolkit/mozapps/extensions/test/xpcshell/test_XPIStates.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_XPIStates.js
@@ -60,17 +60,17 @@ var lastTimestamp = Date.now();
  * @param aPath   File path to touch.
  * @param aChange True if we should notice the change, False if we shouldn't.
  */
 function checkChange(XS, aPath, aChange) {
   Assert.ok(aPath.exists());
   lastTimestamp += 10000;
   info("Touching file " + aPath.path + " with " + lastTimestamp);
   aPath.lastModifiedTime = lastTimestamp;
-  Assert.equal(XS.getInstallState(), aChange);
+  Assert.equal(XS.scanForChanges(), aChange);
   // Save the pref so we don't detect this change again
   XS.save();
 }
 
 // Get a reference to the XPIState (loaded by startupManager) so we can unit test it.
 function getXS() {
   let XPI = ChromeUtils.import("resource://gre/modules/addons/XPIProvider.jsm", {});
   return XPI.XPIStates;
@@ -90,17 +90,17 @@ add_task(async function detect_touches()
          ]);
 
   info("Disable test add-ons");
   await pd.disable();
 
   let XS = getXS();
 
   // Should be no changes detected here, because everything should start out up-to-date.
-  Assert.ok(!XS.getInstallState());
+  Assert.ok(!XS.scanForChanges());
 
   let states = XS.getLocation("app-profile");
 
   // State should correctly reflect enabled/disabled
   Assert.ok(states.get("packed-enabled@tests.mozilla.org").enabled);
   Assert.ok(!states.get("packed-disabled@tests.mozilla.org").enabled);
 
   // Touch various files and make sure the change is detected.
--- a/toolkit/mozapps/extensions/test/xpcshell/test_delay_update_webextension.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_delay_update_webextension.js
@@ -11,17 +11,17 @@ ChromeUtils.import("resource://gre/modul
 
 if (AppConstants.platform == "win" && AppConstants.DEBUG) {
   // Shutdown timing is flaky in this test, and remote extensions
   // sometimes wind up leaving the XPI locked at the point when we try
   // to remove it.
   Services.prefs.setBoolPref("extensions.webextensions.remote", false);
 }
 
-PromiseTestUtils.expectUncaughtRejection(/Message manager disconnected/);
+PromiseTestUtils.whitelistRejectionsGlobally(/Message manager disconnected/);
 
 /* globals browser*/
 
 const profileDir = gProfD.clone();
 profileDir.append("extensions");
 const stageDir = profileDir.clone();
 stageDir.append("staged");