Bug 1363925: Part 3 - Move more install logic from XPIProvider to XPIInstall. r=aswan
authorKris Maglione <maglione.k@gmail.com>
Sat, 21 Apr 2018 18:29:33 -0700
changeset 415209 cffb4b6e971a38219fab7fa49fd8d6565e986394
parent 415208 af2ff7d301a7b481423a8ec172f92c8bbacaf285
child 415210 801a6d33fa88b3030a3989d602a34237fb467e5c
push id33892
push userebalazs@mozilla.com
push dateTue, 24 Apr 2018 09:42:58 +0000
treeherdermozilla-central@26e53729a109 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan
bugs1363925
milestone61.0a1
first release with
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
last release without
nightly linux32
nightly linux64
nightly mac
nightly win32
nightly win64
Bug 1363925: Part 3 - Move more install logic from XPIProvider to XPIInstall. r=aswan MozReview-Commit-ID: 87PXV43Lpn9
toolkit/mozapps/extensions/internal/XPIInstall.jsm
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/internal/XPIProviderUtils.js
xpcom/io/nsIBinaryOutputStream.idl
--- a/toolkit/mozapps/extensions/internal/XPIInstall.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm
@@ -44,18 +44,23 @@ ChromeUtils.defineModuleGetter(this, "OS
                                "resource://gre/modules/osfile.jsm");
 ChromeUtils.defineModuleGetter(this, "ZipUtils",
                                "resource://gre/modules/ZipUtils.jsm");
 
 const {nsIBlocklistService} = Ci;
 
 const nsIFile = Components.Constructor("@mozilla.org/file/local;1", "nsIFile",
                                        "initWithPath");
+
+const BinaryOutputStream = Components.Constructor("@mozilla.org/binaryoutputstream;1",
+                                                  "nsIBinaryOutputStream", "setOutputStream");
 const CryptoHash = Components.Constructor("@mozilla.org/security/hash;1",
                                           "nsICryptoHash", "initWithString");
+const FileOutputStream = Components.Constructor("@mozilla.org/network/file-output-stream;1",
+                                                "nsIFileOutputStream", "init");
 const ZipReader = Components.Constructor("@mozilla.org/libjar/zip-reader;1",
                                          "nsIZipReader", "open");
 
 const RDFDataSource = Components.Constructor(
   "@mozilla.org/rdf/datasource;1?name=in-memory-datasource", "nsIRDFDataSource");
 const parseRDFString = Components.Constructor(
   "@mozilla.org/rdf/xml-parser;1", "nsIRDFXMLParser", "parseString");
 
@@ -65,24 +70,27 @@ XPCOMUtils.defineLazyServiceGetters(this
 });
 
 ChromeUtils.defineModuleGetter(this, "XPIInternal",
                                "resource://gre/modules/addons/XPIProvider.jsm");
 ChromeUtils.defineModuleGetter(this, "XPIProvider",
                                "resource://gre/modules/addons/XPIProvider.jsm");
 
 const PREF_ALLOW_NON_RESTARTLESS      = "extensions.legacy.non-restartless.enabled";
-
-/* globals AddonInternal, BOOTSTRAP_REASONS, KEY_APP_SYSTEM_ADDONS, KEY_APP_SYSTEM_DEFAULTS, KEY_APP_TEMPORARY, TEMPORARY_ADDON_SUFFIX, SIGNED_TYPES, TOOLKIT_ID, XPIDatabase, XPIStates, getExternalType, isTheme, isUsableAddon, isWebExtension, mustSign, recordAddonTelemetry */
+const PREF_DISTRO_ADDONS_PERMS        = "extensions.distroAddons.promptForPermissions";
+
+/* globals AddonInternal, 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, XPIDatabase, XPIStates, getExternalType, isTheme, isUsableAddon, isWebExtension, mustSign, recordAddonTelemetry */
 const XPI_INTERNAL_SYMBOLS = [
   "AddonInternal",
   "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",
   "XPIDatabase",
   "XPIStates",
   "getExternalType",
   "isTheme",
   "isUsableAddon",
@@ -136,18 +144,24 @@ function flushJarCache(aJarFile) {
 }
 
 const PREF_EM_UPDATE_BACKGROUND_URL   = "extensions.update.background.url";
 const PREF_EM_UPDATE_URL              = "extensions.update.url";
 const PREF_XPI_SIGNATURES_DEV_ROOT    = "xpinstall.signatures.dev-root";
 const PREF_INSTALL_REQUIREBUILTINCERTS = "extensions.install.requireBuiltInCerts";
 const FILE_WEB_MANIFEST               = "manifest.json";
 
+const KEY_PROFILEDIR                  = "ProfD";
 const KEY_TEMPDIR                     = "TmpD";
 
+const KEY_APP_PROFILE                 = "app-profile";
+
+const DIR_STAGE                       = "staged";
+const DIR_TRASH                       = "trash";
+
 const RDFURI_INSTALL_MANIFEST_ROOT    = "urn:mozilla:install-manifest";
 const PREFIX_NS_EM                    = "http://www.mozilla.org/2004/em-rdf#";
 
 // Properties that exist in the install manifest
 const PROP_METADATA      = ["id", "version", "type", "internalName", "updateURL",
                             "optionsURL", "optionsType", "aboutURL",
                             "iconURL", "icon64URL"];
 const PROP_LOCALE_SINGLE = ["name", "description", "creator", "homepageURL"];
@@ -386,31 +400,32 @@ XPIPackage = class XPIPackage extends Pa
   }
 
   flushCache() {
     flushJarCache(this.file);
     this.needFlush = false;
   }
 };
 
-/**
- * Sets permissions on a file
- *
- * @param  aFile
- *         The file or directory to operate on.
- * @param  aPermissions
- *         The permissions to set
- */
-function setFilePermissions(aFile, aPermissions) {
-  try {
-    aFile.permissions = aPermissions;
-  } catch (e) {
-    logger.warn("Failed to set permissions " + aPermissions.toString(8) + " on " +
-         aFile.path, e);
-  }
+// Behaves like Promise.all except waits for all promises to resolve/reject
+// before resolving/rejecting itself
+function waitForAllPromises(promises) {
+  return new Promise((resolve, reject) => {
+    let shouldReject = false;
+    let rejectValue = null;
+
+    let newPromises = promises.map(
+      p => p.catch(value => {
+        shouldReject = true;
+        rejectValue = value;
+      })
+    );
+    Promise.all(newPromises)
+           .then((results) => shouldReject ? reject(rejectValue) : resolve(results));
+  });
 }
 
 function EM_R(aProperty) {
   return gRDF.GetResource(PREFIX_NS_EM + aProperty);
 }
 
 /**
  * Converts an RDF literal, resource or integer into a string.
@@ -910,16 +925,24 @@ var loadManifestFromFile = async functio
   try {
     let addon = await loadManifest(pkg, aInstallLocation, aOldAddon);
     return addon;
   } finally {
     pkg.close();
   }
 };
 
+/**
+ * A synchronous method for loading an add-on's manifest. This should only ever
+ * be used during startup or a sync load of the add-ons DB
+ */
+function syncLoadManifestFromFile(aFile, aInstallLocation, aOldAddon) {
+  return XPIInternal.awaitPromise(loadManifestFromFile(aFile, aInstallLocation, 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
   Services.mm.broadcastAsyncMessage(MSG_MESSAGE_MANAGER_CACHES_FLUSH, null);
 }
@@ -1123,16 +1146,260 @@ function recursiveRemove(aFile) {
     aFile.remove(true);
   } catch (e) {
     logger.error("Failed to remove empty directory " + aFile.path, e);
     throw e;
   }
 }
 
 /**
+ * Sets permissions on a file
+ *
+ * @param  aFile
+ *         The file or directory to operate on.
+ * @param  aPermissions
+ *         The permissions to set
+ */
+function setFilePermissions(aFile, aPermissions) {
+  try {
+    aFile.permissions = aPermissions;
+  } catch (e) {
+    logger.warn("Failed to set permissions " + aPermissions.toString(8) + " on " +
+         aFile.path, e);
+  }
+}
+
+/**
+ * Write a given string to a file
+ *
+ * @param  file
+ *         The nsIFile instance to write into
+ * @param  string
+ *         The string to write
+ */
+function writeStringToFile(file, string) {
+  let fileStream = new FileOutputStream(
+    file, (FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE |
+           FileUtils.MODE_TRUNCATE),
+    FileUtils.PERMS_FILE, 0);
+
+  try {
+    let binStream = new BinaryOutputStream(fileStream);
+
+    binStream.writeByteArray(new TextEncoder().encode(string));
+  } finally {
+    fileStream.close();
+  }
+}
+
+/**
+ * A safe way to install a file or the contents of a directory to a new
+ * directory. The file or directory is moved or copied recursively and if
+ * anything fails an attempt is made to rollback the entire operation. The
+ * operation may also be rolled back to its original state after it has
+ * completed by calling the rollback method.
+ *
+ * Operations can be chained. Calling move or copy multiple times will remember
+ * the whole set and if one fails all of the operations will be rolled back.
+ */
+function SafeInstallOperation() {
+  this._installedFiles = [];
+  this._createdDirs = [];
+}
+
+SafeInstallOperation.prototype = {
+  _installedFiles: null,
+  _createdDirs: null,
+
+  _installFile(aFile, aTargetDirectory, aCopy) {
+    let oldFile = aCopy ? null : aFile.clone();
+    let newFile = aFile.clone();
+    try {
+      if (aCopy) {
+        newFile.copyTo(aTargetDirectory, null);
+        // copyTo does not update the nsIFile with the new.
+        newFile = getFile(aFile.leafName, aTargetDirectory);
+        // Windows roaming profiles won't properly sync directories if a new file
+        // has an older lastModifiedTime than a previous file, so update.
+        newFile.lastModifiedTime = Date.now();
+      } else {
+        newFile.moveTo(aTargetDirectory, null);
+      }
+    } catch (e) {
+      logger.error("Failed to " + (aCopy ? "copy" : "move") + " file " + aFile.path +
+            " to " + aTargetDirectory.path, e);
+      throw e;
+    }
+    this._installedFiles.push({ oldFile, newFile });
+  },
+
+  _installDirectory(aDirectory, aTargetDirectory, aCopy) {
+    if (aDirectory.contains(aTargetDirectory)) {
+      let err = new Error(`Not installing ${aDirectory} into its own descendent ${aTargetDirectory}`);
+      logger.error(err);
+      throw err;
+    }
+
+    let newDir = getFile(aDirectory.leafName, aTargetDirectory);
+    try {
+      newDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+    } catch (e) {
+      logger.error("Failed to create directory " + newDir.path, e);
+      throw e;
+    }
+    this._createdDirs.push(newDir);
+
+    // 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), and to remove
+    // normal files before their resource forks on OSX (see bug 733436).
+    let entries = getDirectoryEntries(aDirectory, true);
+    for (let entry of entries) {
+      try {
+        this._installDirEntry(entry, newDir, aCopy);
+      } catch (e) {
+        logger.error("Failed to " + (aCopy ? "copy" : "move") + " entry " +
+                     entry.path, e);
+        throw e;
+      }
+    }
+
+    // If this is only a copy operation then there is nothing else to do
+    if (aCopy)
+      return;
+
+    // The directory should be empty by this point. If it isn't this will throw
+    // and all of the operations will be rolled back
+    try {
+      setFilePermissions(aDirectory, FileUtils.PERMS_DIRECTORY);
+      aDirectory.remove(false);
+    } catch (e) {
+      logger.error("Failed to remove directory " + aDirectory.path, e);
+      throw e;
+    }
+
+    // Note we put the directory move in after all the file moves so the
+    // directory is recreated before all the files are moved back
+    this._installedFiles.push({ oldFile: aDirectory, newFile: newDir });
+  },
+
+  _installDirEntry(aDirEntry, aTargetDirectory, aCopy) {
+    let isDir = null;
+
+    try {
+      isDir = aDirEntry.isDirectory() && !aDirEntry.isSymlink();
+    } catch (e) {
+      // If the file has already gone away then don't worry about it, this can
+      // happen on OSX where the resource fork is automatically moved with the
+      // data fork for the file. See bug 733436.
+      if (e.result == Cr.NS_ERROR_FILE_TARGET_DOES_NOT_EXIST)
+        return;
+
+      logger.error("Failure " + (aCopy ? "copying" : "moving") + " " + aDirEntry.path +
+            " to " + aTargetDirectory.path);
+      throw e;
+    }
+
+    try {
+      if (isDir)
+        this._installDirectory(aDirEntry, aTargetDirectory, aCopy);
+      else
+        this._installFile(aDirEntry, aTargetDirectory, aCopy);
+    } catch (e) {
+      logger.error("Failure " + (aCopy ? "copying" : "moving") + " " + aDirEntry.path +
+            " to " + aTargetDirectory.path);
+      throw e;
+    }
+  },
+
+  /**
+   * Moves a file or directory into a new directory. If an error occurs then all
+   * files that have been moved will be moved back to their original location.
+   *
+   * @param  aFile
+   *         The file or directory to be moved.
+   * @param  aTargetDirectory
+   *         The directory to move into, this is expected to be an empty
+   *         directory.
+   */
+  moveUnder(aFile, aTargetDirectory) {
+    try {
+      this._installDirEntry(aFile, aTargetDirectory, false);
+    } catch (e) {
+      this.rollback();
+      throw e;
+    }
+  },
+
+  /**
+   * Renames a file to a new location.  If an error occurs then all
+   * files that have been moved will be moved back to their original location.
+   *
+   * @param  aOldLocation
+   *         The old location of the file.
+   * @param  aNewLocation
+   *         The new location of the file.
+   */
+  moveTo(aOldLocation, aNewLocation) {
+    try {
+      let oldFile = aOldLocation.clone(), newFile = aNewLocation.clone();
+      oldFile.moveTo(newFile.parent, newFile.leafName);
+      this._installedFiles.push({ oldFile, newFile, isMoveTo: true});
+    } catch (e) {
+      this.rollback();
+      throw e;
+    }
+  },
+
+  /**
+   * Copies a file or directory into a new directory. If an error occurs then
+   * all new files that have been created will be removed.
+   *
+   * @param  aFile
+   *         The file or directory to be copied.
+   * @param  aTargetDirectory
+   *         The directory to copy into, this is expected to be an empty
+   *         directory.
+   */
+  copy(aFile, aTargetDirectory) {
+    try {
+      this._installDirEntry(aFile, aTargetDirectory, true);
+    } catch (e) {
+      this.rollback();
+      throw e;
+    }
+  },
+
+  /**
+   * Rolls back all the moves that this operation performed. If an exception
+   * occurs here then both old and new directories are left in an indeterminate
+   * state
+   */
+  rollback() {
+    while (this._installedFiles.length > 0) {
+      let move = this._installedFiles.pop();
+      if (move.isMoveTo) {
+        move.newFile.moveTo(move.oldDir.parent, move.oldDir.leafName);
+      } else if (move.newFile.isDirectory() && !move.newFile.isSymlink()) {
+        let oldDir = getFile(move.oldFile.leafName, move.oldFile.parent);
+        oldDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+      } else if (!move.oldFile) {
+        // No old file means this was a copied file
+        move.newFile.remove(true);
+      } else {
+        move.newFile.moveTo(move.oldFile.parent, null);
+      }
+    }
+
+    while (this._createdDirs.length > 0)
+      recursiveRemove(this._createdDirs.pop());
+  }
+};
+
+/**
  * Gets a snapshot of directory entries.
  *
  * @param  aDir
  *         Directory to look at
  * @param  aSortEntries
  *         True to sort entries by filename
  * @return An array of nsIFile, or an empty array if aDir is not a readable directory
  */
@@ -2631,13 +2898,730 @@ UpdateChecker.prototype = {
     if (parser) {
       this._parser = null;
       // This will call back to onUpdateCheckError with a CANCELLED error
       parser.cancel();
     }
   }
 };
 
+/**
+ * Creates a new AddonInstall to install an add-on from a local file.
+ *
+ * @param  file
+ *         The file to install
+ * @param  location
+ *         The location to install to
+ * @returns Promise
+ *          A Promise that resolves with the new install object.
+ */
+function createLocalInstall(file, location) {
+  if (!location) {
+    location = XPIProvider.installLocationsByName[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);
+    XPIProvider.removeActiveInstall(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 {
+  /**
+   * Gets the staging directory to put add-ons that are pending install and
+   * uninstall into.
+   *
+   * @return an nsIFile
+   */
+  getStagingDir() {
+    return getFile(DIR_STAGE, this._directory);
+  }
+
+  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);
+    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;
+    });
+  }
+
+  releaseStagingDir() {
+    this._stagingDirLock--;
+
+    if (this._stagingDirLock == 0) {
+      this._stagingDirPromise = null;
+      this.cleanStagingDir();
+    }
+
+    return Promise.resolve();
+  }
+
+  /**
+   * Removes the specified files or directories in the staging directory and
+   * then if the staging directory is empty attempts to remove it.
+   *
+   * @param  aLeafNames
+   *         An array of file or directory to remove from the directory, the
+   *         array may be empty
+   */
+  cleanStagingDir(aLeafNames = []) {
+    let dir = this.getStagingDir();
+
+    for (let name of aLeafNames) {
+      let file = getFile(name, dir);
+      recursiveRemove(file);
+    }
+
+    if (this._stagingDirLock > 0)
+      return;
+
+    let dirEntries = dir.directoryEntries.QueryInterface(Ci.nsIDirectoryEnumerator);
+    try {
+      if (dirEntries.nextFile)
+        return;
+    } finally {
+      dirEntries.close();
+    }
+
+    try {
+      setFilePermissions(dir, FileUtils.PERMS_DIRECTORY);
+      dir.remove(false);
+    } catch (e) {
+      logger.warn("Failed to remove staging dir", e);
+      // Failing to remove the staging directory is ignorable
+    }
+  }
+
+  /**
+   * 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.
+   *
+   * @return an nsIFile
+   */
+  getTrashDir() {
+    let trashDir = getFile(DIR_TRASH, this._directory);
+    let trashDirExists = trashDir.exists();
+    try {
+      if (trashDirExists)
+        recursiveRemove(trashDir);
+      trashDirExists = false;
+    } catch (e) {
+      logger.warn("Failed to remove trash directory", e);
+    }
+    if (!trashDirExists)
+      trashDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+    return trashDir;
+  }
+
+  /**
+   * Installs an add-on into the install location.
+   *
+   * @param  id
+   *         The ID of the add-on to install
+   * @param  source
+   *         The source nsIFile to install from
+   * @param  existingAddonID
+   *         The ID of an existing add-on to uninstall at the same time
+   * @param  action
+   *         What to we do with the given source file:
+   *           "move"
+   *           Default action, the source files will be moved to the new
+   *           location,
+   *           "copy"
+   *           The source files will be copied,
+   *           "proxy"
+   *           A "proxy file" is going to refer to the source file path
+   * @return 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);
+      if (file.exists())
+        transaction.moveUnder(file, trashDir);
+
+      file = getFile(`${aId}.xpi`, this._directory);
+      if (file.exists()) {
+        flushJarCache(file);
+        transaction.moveUnder(file, trashDir);
+      }
+    };
+
+    // If any of these operations fails the finally block will clean up the
+    // temporary directory
+    try {
+      moveOldAddon(id);
+      if (existingAddonID && existingAddonID != id) {
+        moveOldAddon(existingAddonID);
+
+        {
+          // Move the data directories.
+          /* XXX ajvincent We can't use OS.File:  installAddon isn't compatible
+           * with Promises, nor is SafeInstallOperation.  Bug 945540 has been filed
+           * for porting to OS.File.
+           */
+          let oldDataDir = FileUtils.getDir(
+            KEY_PROFILEDIR, ["extension-data", existingAddonID], false, true
+          );
+
+          if (oldDataDir.exists()) {
+            let newDataDir = FileUtils.getDir(
+              KEY_PROFILEDIR, ["extension-data", id], false, true
+            );
+            if (newDataDir.exists()) {
+              let trashData = getFile("data-directory", trashDir);
+              transaction.moveUnder(newDataDir, trashData);
+            }
+
+            transaction.moveTo(oldDataDir, newDataDir);
+          }
+        }
+      }
+
+      if (action == "copy") {
+        transaction.copy(source, this._directory);
+      } else if (action == "move") {
+        if (source.isFile())
+          flushJarCache(source);
+
+        transaction.moveUnder(source, this._directory);
+      }
+      // 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);
+      }
+    }
+
+    let newFile = this._directory.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];
+    }
+
+    return newFile;
+  }
+
+  /**
+   * Uninstalls an add-on from this location.
+   *
+   * @param  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);
+    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];
+      return;
+    }
+
+    let trashDir = this.getTrashDir();
+
+    if (file.leafName != 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);
+      }
+    }
+
+    XPIStates.removeAddon(this.name, aId);
+
+    delete this._IDToFileMap[aId];
+  }
+}
+
+class SystemAddonInstallLocation extends MutableDirectoryInstallLocation {
+  /**
+   * 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();
+  }
+
+  /**
+   * Gets the staging directory to put add-ons that are pending install and
+   * uninstall into.
+   *
+   * @return {nsIFile} - staging directory for system add-on upgrades.
+   */
+  getStagingDir() {
+    this._addonSet = SystemAddonInstallLocation._loadAddonSet();
+    let dir = null;
+    if (this._addonSet.directory) {
+      this._directory = getFile(this._addonSet.directory, this._baseDir);
+      dir = getFile(DIR_STAGE, this._directory);
+    } else {
+      logger.info("SystemAddonInstallLocation directory is missing");
+    }
+
+    return dir;
+  }
+
+  requestStagingDir() {
+    this._addonSet = SystemAddonInstallLocation._loadAddonSet();
+    if (this._addonSet.directory) {
+      this._directory = 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;
+    }
+
+    return true;
+  }
+
+  /**
+   * Tests whether the loaded add-on information matches what is expected.
+   */
+  isValid(aAddons) {
+    for (let id of Object.keys(this._addonSet.addons)) {
+      if (!aAddons.has(id)) {
+        logger.warn(`Expected add-on ${id} is missing from the system add-on location.`);
+        return false;
+      }
+
+      let addon = aAddons.get(id);
+      if (addon.version != this._addonSet.addons[id].version) {
+        logger.warn(`Expected system add-on ${id} to be version ${this._addonSet.addons[id].version} but was ${addon.version}.`);
+        return false;
+      }
+
+      if (!this.isValidAddon(addon))
+        return false;
+    }
+
+    return true;
+  }
+
+  /**
+   * Resets the add-on set so on the next startup the default set will be used.
+   */
+  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);
+
+    // 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.
+    if (this._addonSet) {
+      let ids = Object.keys(this._addonSet.addons);
+      for (let addon of await AddonManager.getAddonsByIDs(ids)) {
+        if (addon) {
+          addon.uninstall();
+        }
+      }
+    }
+  }
+
+  /**
+   * Removes any directories not currently in use or pending use after a
+   * restart. Any errors that happen here don't really matter as we'll attempt
+   * to cleanup again next time.
+   */
+  async cleanDirectories() {
+    // System add-ons directory does not exist
+    if (!(await OS.File.exists(this._baseDir.path))) {
+      return;
+    }
+
+    let iterator;
+    try {
+      iterator = new OS.File.DirectoryIterator(this._baseDir.path);
+    } catch (e) {
+      logger.error("Failed to clean updated system add-ons directories.", e);
+      return;
+    }
+
+    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) {
+          continue;
+        }
+
+        // Skip the next directory
+        if (this._nextDir && this._nextDir.path == entry.path) {
+          continue;
+        }
+
+        if (entry.isDir) {
+          await OS.File.removeDir(entry.path, {
+            ignoreAbsent: true,
+            ignorePermissions: true,
+          });
+        } else {
+          await OS.File.remove(entry.path, {
+            ignoreAbsent: true,
+          });
+        }
+      }
+
+    } catch (e) {
+      logger.error("Failed to clean updated system add-ons directories.", e);
+    } finally {
+      iterator.close();
+    }
+  }
+
+  /**
+   * Installs a new set of system add-ons into the location and updates the
+   * 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();
+
+    // 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());
+      }
+    }
+
+    let newDir = this._baseDir.clone();
+
+    let uuidGen = Cc["@mozilla.org/uuid-generator;1"].
+                  getService(Ci.nsIUUIDGenerator);
+    newDir.append("blank");
+
+    while (true) {
+      newDir.leafName = uuidGen.generateUUID().toString();
+
+      try {
+        await OS.File.makeDir(newDir.path, { ignoreExisting: false });
+        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);
+
+    this._nextDir = newDir;
+    let location = this;
+
+    let installs = [];
+    for (let addon of aAddons) {
+      let install = await createLocalInstall(addon._sourceBundle, 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);
+        };
+      }
+      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);
+
+      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);
+      }
+      // 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) {
+        logger.warn(`Failed to remove failed system add-on directory ${newDir.path}.`, e);
+      }
+      throw e;
+    }
+  }
+
+ /**
+  * Resumes upgrade of a previously-delayed add-on set.
+  */
+  async resumeAddonSet(installs) {
+    async function resumeAddon(install) {
+      install.state = AddonManager.STATE_DOWNLOADED;
+      install.installLocation.releaseStagingDir();
+      install.install();
+    }
+
+    let blockers = installs.filter(
+      install => AddonManagerPrivate.hasUpgradeListener(install.addon.id)
+    );
+
+    if (blockers.length > 1) {
+      logger.warn("Attempted to resume system add-on install but upgrade blockers are still present");
+    } else {
+      await waitForAllPromises(installs.map(resumeAddon));
+    }
+  }
+
+  /**
+   * 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.
+   *
+   * @return an nsIFile
+   */
+  getTrashDir() {
+    let trashDir = getFile(DIR_TRASH, this._directory);
+    let trashDirExists = trashDir.exists();
+    try {
+      if (trashDirExists)
+        recursiveRemove(trashDir);
+      trashDirExists = false;
+    } catch (e) {
+      logger.warn("Failed to remove trash directory", e);
+    }
+    if (!trashDirExists)
+      trashDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
+
+    return trashDir;
+  }
+
+  /**
+   * Installs an add-on into the install location.
+   *
+   * @param  id
+   *         The ID of the add-on to install
+   * @param  source
+   *         The source nsIFile to install from
+   * @return an nsIFile indicating where the add-on was installed to
+   */
+  installAddon({id, source}) {
+    let trashDir = this.getTrashDir();
+    let transaction = new SafeInstallOperation();
+
+    // If any of these operations fails the finally block will clean up the
+    // temporary directory
+    try {
+      if (source.isFile()) {
+        flushJarCache(source);
+      }
+
+      transaction.moveUnder(source, this._directory);
+    } 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);
+      }
+    }
+
+    let newFile = getFile(source.leafName, this._directory);
+
+    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) {}
+}
+
 var XPIInstall = {
+  createLocalInstall,
   flushChromeCaches,
   flushJarCache,
   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
+   *        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) {
+      try {
+        let existingAddon = await loadManifestFromFile(existingEntry, 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);
+      }
+    } 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" });
+    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);
+
+    Services.prefs.setBoolPref(PREF_BRANCH_INSTALLED_ADDON + id, true);
+
+    return addon;
+  },
+
+  MutableDirectoryInstallLocation,
+  SystemAddonInstallLocation,
 };
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -15,18 +15,16 @@ ChromeUtils.import("resource://gre/modul
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm",
   AddonSettings: "resource://gre/modules/addons/AddonSettings.jsm",
   AppConstants: "resource://gre/modules/AppConstants.jsm",
   Extension: "resource://gre/modules/Extension.jsm",
   Langpack: "resource://gre/modules/Extension.jsm",
   LightweightThemeManager: "resource://gre/modules/LightweightThemeManager.jsm",
   FileUtils: "resource://gre/modules/FileUtils.jsm",
-  ZipUtils: "resource://gre/modules/ZipUtils.jsm",
-  NetUtil: "resource://gre/modules/NetUtil.jsm",
   PermissionsUtils: "resource://gre/modules/PermissionsUtils.jsm",
   OS: "resource://gre/modules/osfile.jsm",
   ConsoleAPI: "resource://gre/modules/Console.jsm",
   ProductAddonChecker: "resource://gre/modules/addons/ProductAddonChecker.jsm",
   UpdateUtils: "resource://gre/modules/UpdateUtils.jsm",
   JSONFile: "resource://gre/modules/JSONFile.jsm",
   LegacyExtensionsUtils: "resource://gre/modules/LegacyExtensionsUtils.jsm",
   setTimeout: "resource://gre/modules/Timer.jsm",
@@ -72,17 +70,16 @@ const PREF_XPI_FILE_WHITELISTED       = 
 // xpinstall.signatures.required only supported in dev builds
 const PREF_XPI_SIGNATURES_REQUIRED    = "xpinstall.signatures.required";
 const PREF_XPI_SIGNATURES_DEV_ROOT    = "xpinstall.signatures.dev-root";
 const PREF_LANGPACK_SIGNATURES        = "extensions.langpacks.signatures.required";
 const PREF_XPI_PERMISSIONS_BRANCH     = "xpinstall.";
 const PREF_INSTALL_REQUIRESECUREORIGIN = "extensions.install.requireSecureOrigin";
 const PREF_INSTALL_DISTRO_ADDONS      = "extensions.installDistroAddons";
 const PREF_BRANCH_INSTALLED_ADDON     = "extensions.installedDistroAddon.";
-const PREF_DISTRO_ADDONS_PERMS        = "extensions.distroAddons.promptForPermissions";
 const PREF_SYSTEM_ADDON_SET           = "extensions.systemAddonSet";
 const PREF_SYSTEM_ADDON_UPDATE_URL    = "extensions.systemAddon.update.url";
 const PREF_ALLOW_LEGACY               = "extensions.legacy.enabled";
 
 const PREF_EM_MIN_COMPAT_APP_VERSION      = "extensions.minCompatibleAppVersion";
 const PREF_EM_MIN_COMPAT_PLATFORM_VERSION = "extensions.minCompatiblePlatformVersion";
 
 const PREF_EM_LAST_APP_BUILD_ID       = "extensions.lastAppBuildId";
@@ -284,20 +281,18 @@ function loadLazyObjects() {
   Object.assign(scope, {
     ADDON_SIGNING: AddonSettings.ADDON_SIGNING,
     SIGNED_TYPES,
     BOOTSTRAP_REASONS,
     DB_SCHEMA,
     AddonInternal,
     XPIProvider,
     XPIStates,
-    syncLoadManifestFromFile,
     isUsableAddon,
     recordAddonTelemetry,
-    flushChromeCaches: XPIInstall.flushChromeCaches,
     descriptorToPath,
   });
 
   Services.scriptloader.loadSubScript(uri, scope);
 
   for (let name of LAZY_OBJECTS) {
     delete gGlobalScope[name];
     gGlobalScope[name] = scope[name];
@@ -312,16 +307,45 @@ LAZY_OBJECTS.forEach(name => {
       let objs = loadLazyObjects();
       return objs[name];
     },
     configurable: true
   });
 });
 
 /**
+ * Spins the event loop until the given promise resolves, and then eiter returns
+ * its success value or throws its rejection value.
+ *
+ * @param {Promise} promise
+ *        The promise to await.
+ * @returns {any}
+ *        The promise's resolution value, if any.
+ */
+function awaitPromise(promise) {
+  let success = undefined;
+  let result = null;
+
+  promise.then(val => {
+    success = true;
+    result = val;
+  }, val => {
+    success = false;
+    result = val;
+  });
+
+  Services.tm.spinEventLoopUntil(() => success !== undefined);
+
+  if (!success)
+    throw result;
+  return result;
+
+}
+
+/**
  * Returns a nsIFile instance for the given path, relative to the given
  * base file, if provided.
  *
  * @param {string} path
  *        The (possibly relative) path of the file.
  * @param {nsIFile} [base]
  *        An optional file to use as a base path if `path` is relative.
  * @returns {nsIFile}
@@ -401,35 +425,16 @@ function descriptorToPath(descriptor, di
     let file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsIFile);
     file.persistentDescriptor = descriptor;
     return getRelativePath(file, dir);
   } catch (e) {
     return null;
   }
 }
 
-
-// Behaves like Promise.all except waits for all promises to resolve/reject
-// before resolving/rejecting itself
-function waitForAllPromises(promises) {
-  return new Promise((resolve, reject) => {
-    let shouldReject = false;
-    let rejectValue = null;
-
-    let newPromises = promises.map(
-      p => p.catch(value => {
-        shouldReject = true;
-        rejectValue = value;
-      })
-    );
-    Promise.all(newPromises)
-           .then((results) => shouldReject ? reject(rejectValue) : resolve(results));
-  });
-}
-
 function findMatchingStaticBlocklistItem(aAddon) {
   for (let item of STATIC_BLOCKLIST_PATTERNS) {
     if ("creator" in item && typeof item.creator == "string") {
       if ((aAddon.defaultLocale && aAddon.defaultLocale.creator == item.creator) ||
           (aAddon.selectedLocale && aAddon.selectedLocale.creator == item.creator)) {
         return item;
       }
     }
@@ -480,263 +485,16 @@ var gThemeAliases = null;
  */
 function isTheme(type) {
   if (!gThemeAliases)
     gThemeAliases = getAllAliasesForTypes(["theme"]);
   return gThemeAliases.includes(type);
 }
 
 /**
- * Sets permissions on a file
- *
- * @param  aFile
- *         The file or directory to operate on.
- * @param  aPermissions
- *         The permissions to set
- */
-function setFilePermissions(aFile, aPermissions) {
-  try {
-    aFile.permissions = aPermissions;
-  } catch (e) {
-    logger.warn("Failed to set permissions " + aPermissions.toString(8) + " on " +
-         aFile.path, e);
-  }
-}
-
-/**
- * Write a given string to a file
- *
- * @param  file
- *         The nsIFile instance to write into
- * @param  string
- *         The string to write
- */
-function writeStringToFile(file, string) {
-  let stream = Cc["@mozilla.org/network/file-output-stream;1"].
-               createInstance(Ci.nsIFileOutputStream);
-  let converter = Cc["@mozilla.org/intl/converter-output-stream;1"].
-                  createInstance(Ci.nsIConverterOutputStream);
-
-  try {
-    stream.init(file, FileUtils.MODE_WRONLY | FileUtils.MODE_CREATE |
-                            FileUtils.MODE_TRUNCATE, FileUtils.PERMS_FILE,
-                           0);
-    converter.init(stream, "UTF-8");
-    converter.writeString(string);
-  } finally {
-    converter.close();
-    stream.close();
-  }
-}
-
-/**
- * A safe way to install a file or the contents of a directory to a new
- * directory. The file or directory is moved or copied recursively and if
- * anything fails an attempt is made to rollback the entire operation. The
- * operation may also be rolled back to its original state after it has
- * completed by calling the rollback method.
- *
- * Operations can be chained. Calling move or copy multiple times will remember
- * the whole set and if one fails all of the operations will be rolled back.
- */
-function SafeInstallOperation() {
-  this._installedFiles = [];
-  this._createdDirs = [];
-}
-
-SafeInstallOperation.prototype = {
-  _installedFiles: null,
-  _createdDirs: null,
-
-  _installFile(aFile, aTargetDirectory, aCopy) {
-    let oldFile = aCopy ? null : aFile.clone();
-    let newFile = aFile.clone();
-    try {
-      if (aCopy) {
-        newFile.copyTo(aTargetDirectory, null);
-        // copyTo does not update the nsIFile with the new.
-        newFile = getFile(aFile.leafName, aTargetDirectory);
-        // Windows roaming profiles won't properly sync directories if a new file
-        // has an older lastModifiedTime than a previous file, so update.
-        newFile.lastModifiedTime = Date.now();
-      } else {
-        newFile.moveTo(aTargetDirectory, null);
-      }
-    } catch (e) {
-      logger.error("Failed to " + (aCopy ? "copy" : "move") + " file " + aFile.path +
-            " to " + aTargetDirectory.path, e);
-      throw e;
-    }
-    this._installedFiles.push({ oldFile, newFile });
-  },
-
-  _installDirectory(aDirectory, aTargetDirectory, aCopy) {
-    if (aDirectory.contains(aTargetDirectory)) {
-      let err = new Error(`Not installing ${aDirectory} into its own descendent ${aTargetDirectory}`);
-      logger.error(err);
-      throw err;
-    }
-
-    let newDir = getFile(aDirectory.leafName, aTargetDirectory);
-    try {
-      newDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
-    } catch (e) {
-      logger.error("Failed to create directory " + newDir.path, e);
-      throw e;
-    }
-    this._createdDirs.push(newDir);
-
-    // 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), and to remove
-    // normal files before their resource forks on OSX (see bug 733436).
-    let entries = getDirectoryEntries(aDirectory, true);
-    for (let entry of entries) {
-      try {
-        this._installDirEntry(entry, newDir, aCopy);
-      } catch (e) {
-        logger.error("Failed to " + (aCopy ? "copy" : "move") + " entry " +
-                     entry.path, e);
-        throw e;
-      }
-    }
-
-    // If this is only a copy operation then there is nothing else to do
-    if (aCopy)
-      return;
-
-    // The directory should be empty by this point. If it isn't this will throw
-    // and all of the operations will be rolled back
-    try {
-      setFilePermissions(aDirectory, FileUtils.PERMS_DIRECTORY);
-      aDirectory.remove(false);
-    } catch (e) {
-      logger.error("Failed to remove directory " + aDirectory.path, e);
-      throw e;
-    }
-
-    // Note we put the directory move in after all the file moves so the
-    // directory is recreated before all the files are moved back
-    this._installedFiles.push({ oldFile: aDirectory, newFile: newDir });
-  },
-
-  _installDirEntry(aDirEntry, aTargetDirectory, aCopy) {
-    let isDir = null;
-
-    try {
-      isDir = aDirEntry.isDirectory() && !aDirEntry.isSymlink();
-    } catch (e) {
-      // If the file has already gone away then don't worry about it, this can
-      // happen on OSX where the resource fork is automatically moved with the
-      // data fork for the file. See bug 733436.
-      if (e.result == Cr.NS_ERROR_FILE_TARGET_DOES_NOT_EXIST)
-        return;
-
-      logger.error("Failure " + (aCopy ? "copying" : "moving") + " " + aDirEntry.path +
-            " to " + aTargetDirectory.path);
-      throw e;
-    }
-
-    try {
-      if (isDir)
-        this._installDirectory(aDirEntry, aTargetDirectory, aCopy);
-      else
-        this._installFile(aDirEntry, aTargetDirectory, aCopy);
-    } catch (e) {
-      logger.error("Failure " + (aCopy ? "copying" : "moving") + " " + aDirEntry.path +
-            " to " + aTargetDirectory.path);
-      throw e;
-    }
-  },
-
-  /**
-   * Moves a file or directory into a new directory. If an error occurs then all
-   * files that have been moved will be moved back to their original location.
-   *
-   * @param  aFile
-   *         The file or directory to be moved.
-   * @param  aTargetDirectory
-   *         The directory to move into, this is expected to be an empty
-   *         directory.
-   */
-  moveUnder(aFile, aTargetDirectory) {
-    try {
-      this._installDirEntry(aFile, aTargetDirectory, false);
-    } catch (e) {
-      this.rollback();
-      throw e;
-    }
-  },
-
-  /**
-   * Renames a file to a new location.  If an error occurs then all
-   * files that have been moved will be moved back to their original location.
-   *
-   * @param  aOldLocation
-   *         The old location of the file.
-   * @param  aNewLocation
-   *         The new location of the file.
-   */
-  moveTo(aOldLocation, aNewLocation) {
-    try {
-      let oldFile = aOldLocation.clone(), newFile = aNewLocation.clone();
-      oldFile.moveTo(newFile.parent, newFile.leafName);
-      this._installedFiles.push({ oldFile, newFile, isMoveTo: true});
-    } catch (e) {
-      this.rollback();
-      throw e;
-    }
-  },
-
-  /**
-   * Copies a file or directory into a new directory. If an error occurs then
-   * all new files that have been created will be removed.
-   *
-   * @param  aFile
-   *         The file or directory to be copied.
-   * @param  aTargetDirectory
-   *         The directory to copy into, this is expected to be an empty
-   *         directory.
-   */
-  copy(aFile, aTargetDirectory) {
-    try {
-      this._installDirEntry(aFile, aTargetDirectory, true);
-    } catch (e) {
-      this.rollback();
-      throw e;
-    }
-  },
-
-  /**
-   * Rolls back all the moves that this operation performed. If an exception
-   * occurs here then both old and new directories are left in an indeterminate
-   * state
-   */
-  rollback() {
-    while (this._installedFiles.length > 0) {
-      let move = this._installedFiles.pop();
-      if (move.isMoveTo) {
-        move.newFile.moveTo(move.oldDir.parent, move.oldDir.leafName);
-      } else if (move.newFile.isDirectory() && !move.newFile.isSymlink()) {
-        let oldDir = getFile(move.oldFile.leafName, move.oldFile.parent);
-        oldDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
-      } else if (!move.oldFile) {
-        // No old file means this was a copied file
-        move.newFile.remove(true);
-      } else {
-        move.newFile.moveTo(move.oldFile.parent, null);
-      }
-    }
-
-    while (this._createdDirs.length > 0)
-      XPIInstall.recursiveRemove(this._createdDirs.pop());
-  }
-};
-
-/**
  * Evaluates whether an add-on is allowed to run in safe mode.
  *
  * @param  aAddon
  *         The add-on to check
  * @return true if the add-on should run in safe mode
  */
 function canRunInSafeMode(aAddon) {
   // Even though the updated system add-ons aren't generally run in safe mode we
@@ -893,39 +651,16 @@ function getAllAliasesForTypes(aTypes) {
     if (typeset.has(TYPE_ALIASES[alias]))
       typeset.add(alias);
   }
 
   return [...typeset];
 }
 
 /**
- * A synchronous method for loading an add-on's manifest. This should only ever
- * be used during startup or a sync load of the add-ons DB
- */
-function syncLoadManifestFromFile(aFile, aInstallLocation, aOldAddon) {
-  let success = undefined;
-  let result = null;
-
-  loadManifestFromFile(aFile, aInstallLocation, aOldAddon).then(val => {
-    success = true;
-    result = val;
-  }, val => {
-    success = false;
-    result = val;
-  });
-
-  Services.tm.spinEventLoopUntil(() => success !== undefined);
-
-  if (!success)
-    throw result;
-  return result;
-}
-
-/**
  * Gets an nsIURI for a file within another file, either a directory or an XPI
  * file. If aFile is a directory then this will return a file: URI, if it is an
  * XPI file then it will return a jar: URI.
  *
  * @param  aFile
  *         The file containing the resources, must be either a directory or an
  *         XPI file
  * @param  aPath
@@ -2636,17 +2371,17 @@ var XPIProvider = {
           continue;
         }
 
         changed = true;
         aManifests[location.name][id] = null;
 
         let addon;
         try {
-          addon = syncLoadManifestFromFile(source, location);
+          addon = XPIInstall.syncLoadManifestFromFile(source, location);
         } catch (e) {
           logger.error(`Unable to read add-on manifest from ${source.path}`, e);
           cleanNames.push(source.leafName);
           continue;
         }
 
         if (mustSign(addon.type) &&
             addon.signedState <= AddonManager.SIGNEDSTATE_MISSING) {
@@ -2748,18 +2483,18 @@ var XPIProvider = {
     let entries = distroDir.directoryEntries
                            .QueryInterface(Ci.nsIDirectoryEnumerator);
     let entry;
     while ((entry = entries.nextFile)) {
 
       let id = entry.leafName;
 
       if (entry.isFile()) {
-        if (id.substring(id.length - 4).toLowerCase() == ".xpi") {
-          id = id.substring(0, id.length - 4);
+        if (id.endsWith(".xpi")) {
+          id = id.slice(0, -4);
         } else {
           logger.debug("Ignoring distribution add-on that isn't an XPI: " + entry.path);
           continue;
         }
       } else if (!entry.isDirectory()) {
         logger.debug("Ignoring distribution add-on that isn't a file or directory: " +
             entry.path);
         continue;
@@ -2772,75 +2507,28 @@ var XPIProvider = {
       }
 
       /* If this is not an upgrade and we've already handled this extension
        * just continue */
       if (!aAppChanged && Services.prefs.prefHasUserValue(PREF_BRANCH_INSTALLED_ADDON + id)) {
         continue;
       }
 
-      let addon;
       try {
-        addon = syncLoadManifestFromFile(entry, profileLocation);
-      } catch (e) {
-        logger.warn("File entry " + entry.path + " contains an invalid add-on", e);
-        continue;
-      }
-
-      if (addon.id != id) {
-        logger.warn("File entry " + entry.path + " contains an add-on with an " +
-             "incorrect ID");
-        continue;
-      }
-
-      let existingEntry = null;
-      try {
-        existingEntry = profileLocation.getLocationForID(id);
-      } catch (e) {
-      }
-
-      if (existingEntry) {
-        let existingAddon;
-        try {
-          existingAddon = syncLoadManifestFromFile(existingEntry, profileLocation);
-
-          if (Services.vc.compare(addon.version, existingAddon.version) <= 0)
-            continue;
-        } 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);
+        let addon = awaitPromise(XPIInstall.installDistributionAddon(id, entry, profileLocation));
+
+        if (addon) {
+          // aManifests may contain a copy of a newly installed add-on's manifest
+          // 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;
         }
-      } else if (Services.prefs.getBoolPref(PREF_BRANCH_INSTALLED_ADDON + id, false)) {
-        continue;
-      }
-
-      // Install the add-on
-      try {
-        addon._sourceBundle = profileLocation.installAddon({ id, source: entry, action: "copy" });
-        if (Services.prefs.getBoolPref(PREF_DISTRO_ADDONS_PERMS, false)) {
-          addon.userDisabled = true;
-          if (!this.newDistroAddons) {
-            this.newDistroAddons = new Set();
-          }
-          this.newDistroAddons.add(id);
-        }
-
-        XPIStates.addAddon(addon);
-        logger.debug("Installed distribution add-on " + id);
-
-        Services.prefs.setBoolPref(PREF_BRANCH_INSTALLED_ADDON + id, true);
-
-        // aManifests may contain a copy of a newly installed add-on's manifest
-        // 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);
       }
     }
 
     entries.close();
 
     return changed;
@@ -3138,17 +2826,17 @@ var XPIProvider = {
 
   /**
    * Called to get an AddonInstall to install an add-on from a local file.
    *
    * @param  aFile
    *         The file to be installed
    */
   async getInstallForFile(aFile) {
-    let install = await createLocalInstall(aFile);
+    let install = await XPIInstall.createLocalInstall(aFile);
     return install ? install.wrapper : null;
   },
 
   /**
    * Temporarily installs add-on from a local XPI file or directory.
    * As this is intended for development, the signature is not checked and
    * the add-on does not persist on application restart.
    *
@@ -4163,42 +3851,16 @@ var XPIProvider = {
     }
 
     // Notify any other providers that this theme is now enabled again.
     if (isTheme(aAddon.type) && aAddon.active)
       AddonManagerPrivate.notifyAddonChanged(aAddon.id, aAddon.type, false);
   }
 };
 
-/**
- * Creates a new AddonInstall to install an add-on from a local file.
- *
- * @param  file
- *         The file to install
- * @param  location
- *         The location to install to
- * @returns Promise
- *          A Promise that resolves with the new install object.
- */
-function createLocalInstall(file, location) {
-  if (!location) {
-    location = XPIProvider.installLocationsByName[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);
-    XPIProvider.removeActiveInstall(this);
-    return Promise.resolve(null);
-  }
-}
-
 // Maps instances of AddonInternal to AddonWrapper
 const wrapperMap = new WeakMap();
 let addonFor = wrapper => wrapperMap.get(wrapper);
 
 /**
  * The AddonInternal is an internal only representation of add-ons. It may
  * have come from the database (see DBAddonInternal in XPIProviderUtils.jsm)
  * or an install manifest.
@@ -5197,16 +4859,24 @@ PROP_LOCALE_MULTI.forEach(function(aProp
         return new AddonManagerPrivate.AddonAuthor(aResult);
       });
     }
 
     return results;
   });
 });
 
+function forwardInstallMethods(cls, methods) {
+  for (let meth of methods) {
+    cls.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 {
   /**
@@ -5426,281 +5096,21 @@ class MutableDirectoryInstallLocation ex
    *         The scope of add-ons installed in this location
    */
   constructor(aName, aDirectory, aScope) {
     super(aName, aDirectory, aScope);
 
     this.locked = false;
     this._stagingDirLock = 0;
   }
-
-  /**
-   * Gets the staging directory to put add-ons that are pending install and
-   * uninstall into.
-   *
-   * @return an nsIFile
-   */
-  getStagingDir() {
-    return getFile(DIR_STAGE, this._directory);
-  }
-
-  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);
-    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;
-    });
-  }
-
-  releaseStagingDir() {
-    this._stagingDirLock--;
-
-    if (this._stagingDirLock == 0) {
-      this._stagingDirPromise = null;
-      this.cleanStagingDir();
-    }
-
-    return Promise.resolve();
-  }
-
-  /**
-   * Removes the specified files or directories in the staging directory and
-   * then if the staging directory is empty attempts to remove it.
-   *
-   * @param  aLeafNames
-   *         An array of file or directory to remove from the directory, the
-   *         array may be empty
-   */
-  cleanStagingDir(aLeafNames = []) {
-    let dir = this.getStagingDir();
-
-    for (let name of aLeafNames) {
-      let file = getFile(name, dir);
-      XPIInstall.recursiveRemove(file);
-    }
-
-    if (this._stagingDirLock > 0)
-      return;
-
-    let dirEntries = dir.directoryEntries.QueryInterface(Ci.nsIDirectoryEnumerator);
-    try {
-      if (dirEntries.nextFile)
-        return;
-    } finally {
-      dirEntries.close();
-    }
-
-    try {
-      setFilePermissions(dir, FileUtils.PERMS_DIRECTORY);
-      dir.remove(false);
-    } catch (e) {
-      logger.warn("Failed to remove staging dir", e);
-      // Failing to remove the staging directory is ignorable
-    }
-  }
-
-  /**
-   * 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.
-   *
-   * @return an nsIFile
-   */
-  getTrashDir() {
-    let trashDir = getFile(DIR_TRASH, this._directory);
-    let trashDirExists = trashDir.exists();
-    try {
-      if (trashDirExists)
-        XPIInstall.recursiveRemove(trashDir);
-      trashDirExists = false;
-    } catch (e) {
-      logger.warn("Failed to remove trash directory", e);
-    }
-    if (!trashDirExists)
-      trashDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
-
-    return trashDir;
-  }
-
-  /**
-   * Installs an add-on into the install location.
-   *
-   * @param  id
-   *         The ID of the add-on to install
-   * @param  source
-   *         The source nsIFile to install from
-   * @param  existingAddonID
-   *         The ID of an existing add-on to uninstall at the same time
-   * @param  action
-   *         What to we do with the given source file:
-   *           "move"
-   *           Default action, the source files will be moved to the new
-   *           location,
-   *           "copy"
-   *           The source files will be copied,
-   *           "proxy"
-   *           A "proxy file" is going to refer to the source file path
-   * @return 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);
-      if (file.exists())
-        transaction.moveUnder(file, trashDir);
-
-      file = getFile(`${aId}.xpi`, this._directory);
-      if (file.exists()) {
-        XPIInstall.flushJarCache(file);
-        transaction.moveUnder(file, trashDir);
-      }
-    };
-
-    // If any of these operations fails the finally block will clean up the
-    // temporary directory
-    try {
-      moveOldAddon(id);
-      if (existingAddonID && existingAddonID != id) {
-        moveOldAddon(existingAddonID);
-
-        {
-          // Move the data directories.
-          /* XXX ajvincent We can't use OS.File:  installAddon isn't compatible
-           * with Promises, nor is SafeInstallOperation.  Bug 945540 has been filed
-           * for porting to OS.File.
-           */
-          let oldDataDir = FileUtils.getDir(
-            KEY_PROFILEDIR, ["extension-data", existingAddonID], false, true
-          );
-
-          if (oldDataDir.exists()) {
-            let newDataDir = FileUtils.getDir(
-              KEY_PROFILEDIR, ["extension-data", id], false, true
-            );
-            if (newDataDir.exists()) {
-              let trashData = getFile("data-directory", trashDir);
-              transaction.moveUnder(newDataDir, trashData);
-            }
-
-            transaction.moveTo(oldDataDir, newDataDir);
-          }
-        }
-      }
-
-      if (action == "copy") {
-        transaction.copy(source, this._directory);
-      } else if (action == "move") {
-        if (source.isFile())
-          XPIInstall.flushJarCache(source);
-
-        transaction.moveUnder(source, this._directory);
-      }
-      // 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 {
-        XPIInstall.recursiveRemove(trashDir);
-      } catch (e) {
-        logger.warn("Failed to remove trash directory when installing " + id, e);
-      }
-    }
-
-    let newFile = this._directory.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];
-    }
-
-    return newFile;
-  }
-
-  /**
-   * Uninstalls an add-on from this location.
-   *
-   * @param  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);
-    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];
-      return;
-    }
-
-    let trashDir = this.getTrashDir();
-
-    if (file.leafName != aId) {
-      logger.debug("uninstallAddon: flushing jar cache " + file.path + " for addon " + aId);
-      XPIInstall.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 {
-        XPIInstall.recursiveRemove(trashDir);
-      } catch (e) {
-        logger.warn("Failed to remove trash directory when uninstalling " + aId, e);
-      }
-    }
-
-    XPIStates.removeAddon(this.name, aId);
-
-    delete this._IDToFileMap[aId];
-  }
 }
+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 {
@@ -5782,43 +5192,16 @@ class SystemAddonInstallLocation extends
     if (aResetSet) {
       this.resetAddonSet();
     }
 
     this.locked = false;
   }
 
   /**
-   * Gets the staging directory to put add-ons that are pending install and
-   * uninstall into.
-   *
-   * @return {nsIFile} - staging directory for system add-on upgrades.
-   */
-  getStagingDir() {
-    this._addonSet = SystemAddonInstallLocation._loadAddonSet();
-    let dir = null;
-    if (this._addonSet.directory) {
-      this._directory = getFile(this._addonSet.directory, this._baseDir);
-      dir = getFile(DIR_STAGE, this._directory);
-    } else {
-      logger.info("SystemAddonInstallLocation directory is missing");
-    }
-
-    return dir;
-  }
-
-  requestStagingDir() {
-    this._addonSet = SystemAddonInstallLocation._loadAddonSet();
-    if (this._addonSet.directory) {
-      this._directory = getFile(this._addonSet.directory, this._baseDir);
-    }
-    return super.requestStagingDir();
-  }
-
-  /**
    * Reads the current set of system add-ons
    */
   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) {
@@ -5827,26 +5210,16 @@ class SystemAddonInstallLocation extends
       }
     } catch (e) {
       logger.error("Malformed system add-on set, resetting.");
     }
 
     return { schema: 1, addons: {} };
   }
 
-  /**
-   * 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));
-  }
-
   getAddonLocations() {
     // Updated system add-ons are ignored in safe mode
     if (Services.appinfo.inSafeMode) {
       return new Map();
     }
 
     let addons = super.getAddonLocations();
 
@@ -5861,345 +5234,32 @@ class SystemAddonInstallLocation extends
   }
 
   /**
    * Tests whether updated system add-ons are expected.
    */
   isActive() {
     return this._directory != null;
   }
-
-  isValidAddon(aAddon) {
-    if (aAddon.appDisabled) {
-      logger.warn(`System add-on ${aAddon.id} isn't compatible with the application.`);
-      return false;
-    }
-
-    return true;
-  }
-
-  /**
-   * Tests whether the loaded add-on information matches what is expected.
-   */
-  isValid(aAddons) {
-    for (let id of Object.keys(this._addonSet.addons)) {
-      if (!aAddons.has(id)) {
-        logger.warn(`Expected add-on ${id} is missing from the system add-on location.`);
-        return false;
-      }
-
-      let addon = aAddons.get(id);
-      if (addon.version != this._addonSet.addons[id].version) {
-        logger.warn(`Expected system add-on ${id} to be version ${this._addonSet.addons[id].version} but was ${addon.version}.`);
-        return false;
-      }
-
-      if (!this.isValidAddon(addon))
-        return false;
-    }
-
-    return true;
-  }
-
-  /**
-   * Resets the add-on set so on the next startup the default set will be used.
-   */
-  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);
-
-    // 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.
-    if (this._addonSet) {
-      let ids = Object.keys(this._addonSet.addons);
-      for (let addon of await AddonManager.getAddonsByIDs(ids)) {
-        if (addon) {
-          addon.uninstall();
-        }
-      }
-    }
-  }
-
-  /**
-   * Removes any directories not currently in use or pending use after a
-   * restart. Any errors that happen here don't really matter as we'll attempt
-   * to cleanup again next time.
-   */
-  async cleanDirectories() {
-    // System add-ons directory does not exist
-    if (!(await OS.File.exists(this._baseDir.path))) {
-      return;
-    }
-
-    let iterator;
-    try {
-      iterator = new OS.File.DirectoryIterator(this._baseDir.path);
-    } catch (e) {
-      logger.error("Failed to clean updated system add-ons directories.", e);
-      return;
-    }
-
-    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) {
-          continue;
-        }
-
-        // Skip the next directory
-        if (this._nextDir && this._nextDir.path == entry.path) {
-          continue;
-        }
-
-        if (entry.isDir) {
-          await OS.File.removeDir(entry.path, {
-            ignoreAbsent: true,
-            ignorePermissions: true,
-          });
-        } else {
-          await OS.File.remove(entry.path, {
-            ignoreAbsent: true,
-          });
-        }
-      }
-
-    } catch (e) {
-      logger.error("Failed to clean updated system add-ons directories.", e);
-    } finally {
-      iterator.close();
-    }
-  }
-
-  /**
-   * Installs a new set of system add-ons into the location and updates the
-   * 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();
-
-    // 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());
-      }
-    }
-
-    let newDir = this._baseDir.clone();
-
-    let uuidGen = Cc["@mozilla.org/uuid-generator;1"].
-                  getService(Ci.nsIUUIDGenerator);
-    newDir.append("blank");
-
-    while (true) {
-      newDir.leafName = uuidGen.generateUUID().toString();
-
-      try {
-        await OS.File.makeDir(newDir.path, { ignoreExisting: false });
-        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);
-
-    this._nextDir = newDir;
-    let location = this;
-
-    let installs = [];
-    for (let addon of aAddons) {
-      let install = await createLocalInstall(addon._sourceBundle, 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);
-        };
-      }
-      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);
-
-      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);
-      }
-      // 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) {
-        logger.warn(`Failed to remove failed system add-on directory ${newDir.path}.`, e);
-      }
-      throw e;
-    }
-  }
-
- /**
-  * Resumes upgrade of a previously-delayed add-on set.
-  */
-  async resumeAddonSet(installs) {
-    async function resumeAddon(install) {
-      install.state = AddonManager.STATE_DOWNLOADED;
-      install.installLocation.releaseStagingDir();
-      install.install();
-    }
-
-    let blockers = installs.filter(
-      install => AddonManagerPrivate.hasUpgradeListener(install.addon.id)
-    );
-
-    if (blockers.length > 1) {
-      logger.warn("Attempted to resume system add-on install but upgrade blockers are still present");
-    } else {
-      await waitForAllPromises(installs.map(resumeAddon));
-    }
-  }
-
-  /**
-   * 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.
-   *
-   * @return an nsIFile
-   */
-  getTrashDir() {
-    let trashDir = getFile(DIR_TRASH, this._directory);
-    let trashDirExists = trashDir.exists();
-    try {
-      if (trashDirExists)
-        XPIInstall.recursiveRemove(trashDir);
-      trashDirExists = false;
-    } catch (e) {
-      logger.warn("Failed to remove trash directory", e);
-    }
-    if (!trashDirExists)
-      trashDir.create(Ci.nsIFile.DIRECTORY_TYPE, FileUtils.PERMS_DIRECTORY);
-
-    return trashDir;
-  }
-
-  /**
-   * Installs an add-on into the install location.
-   *
-   * @param  id
-   *         The ID of the add-on to install
-   * @param  source
-   *         The source nsIFile to install from
-   * @return an nsIFile indicating where the add-on was installed to
-   */
-  installAddon({id, source}) {
-    let trashDir = this.getTrashDir();
-    let transaction = new SafeInstallOperation();
-
-    // If any of these operations fails the finally block will clean up the
-    // temporary directory
-    try {
-      if (source.isFile()) {
-        XPIInstall.flushJarCache(source);
-      }
-
-      transaction.moveUnder(source, this._directory);
-    } finally {
-      // It isn't ideal if this cleanup fails but it isn't worth rolling back
-      // the install because of it.
-      try {
-        XPIInstall.recursiveRemove(trashDir);
-      } catch (e) {
-        logger.warn("Failed to remove trash directory when installing " + id, e);
-      }
-    }
-
-    let newFile = getFile(source.leafName, this._directory);
-
-    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) {}
 }
 
-/**
- * An object which identifies an install location for temporary add-ons.
+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,
+const TemporaryInstallLocation = { locked: false, name: KEY_APP_TEMPORARY,
   scope: AddonManager.SCOPE_TEMPORARY,
-  getAddonLocations: () => [],
-  isLinkedAddon: () => false,
-  installAddon: () => {},
-  uninstallAddon: (aAddon) => {},
-  getStagingDir: () => {},
+  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
  *
  */
@@ -6294,20 +5354,24 @@ class WinRegInstallLocation extends Dire
 }
 
 var XPIInternal = {
   AddonInternal,
   BOOTSTRAP_REASONS,
   KEY_APP_SYSTEM_ADDONS,
   KEY_APP_SYSTEM_DEFAULTS,
   KEY_APP_TEMPORARY,
+  PREF_BRANCH_INSTALLED_ADDON,
+  PREF_SYSTEM_ADDON_SET,
   SIGNED_TYPES,
+  SystemAddonInstallLocation,
   TEMPORARY_ADDON_SUFFIX,
   TOOLKIT_ID,
   XPIStates,
+  awaitPromise,
   getExternalType,
   isTheme,
   isUsableAddon,
   isWebExtension,
   mustSign,
   recordAddonTelemetry,
 
   get XPIDatabase() { return gGlobalScope.XPIDatabase; },
--- a/toolkit/mozapps/extensions/internal/XPIProviderUtils.js
+++ b/toolkit/mozapps/extensions/internal/XPIProviderUtils.js
@@ -1,30 +1,31 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this file,
  * You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
 // These are injected from XPIProvider.jsm
 /* globals ADDON_SIGNING, SIGNED_TYPES, BOOTSTRAP_REASONS, DB_SCHEMA,
-          AddonInternal, XPIProvider, XPIStates, syncLoadManifestFromFile,
+          AddonInternal, XPIProvider, XPIStates,
           isUsableAddon, recordAddonTelemetry,
-          flushChromeCaches, descriptorToPath */
+          descriptorToPath */
 
 ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
 
 XPCOMUtils.defineLazyModuleGetters(this, {
   AddonManager: "resource://gre/modules/AddonManager.jsm",
   AddonManagerPrivate: "resource://gre/modules/AddonManager.jsm",
   AddonRepository: "resource://gre/modules/addons/AddonRepository.jsm",
   DeferredTask: "resource://gre/modules/DeferredTask.jsm",
   FileUtils: "resource://gre/modules/FileUtils.jsm",
   OS: "resource://gre/modules/osfile.jsm",
   Services: "resource://gre/modules/Services.jsm",
+  XPIInstall: "resource://gre/modules/addons/XPIInstall.jsm",
 });
 
 ChromeUtils.import("resource://gre/modules/Log.jsm");
 const LOGGER_ID = "addons.xpi-utils";
 
 const nsIFile = Components.Constructor("@mozilla.org/file/local;1", "nsIFile",
                                        "initWithPath");
 
@@ -1034,17 +1035,17 @@ this.XPIDatabaseReconcile = {
     // must be something dropped directly into the install location
     let isDetectedInstall = isNewInstall && !aNewAddon;
 
     // Load the manifest if necessary and sanity check the add-on ID
     try {
       if (!aNewAddon) {
         // Load the manifest from the add-on.
         let file = new nsIFile(aAddonState.path);
-        aNewAddon = syncLoadManifestFromFile(file, aInstallLocation);
+        aNewAddon = XPIInstall.syncLoadManifestFromFile(file, aInstallLocation);
       }
       // 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");
       }
     } catch (e) {
       logger.warn("addMetadata: Add-on " + aId + " is invalid", e);
@@ -1121,17 +1122,17 @@ this.XPIDatabaseReconcile = {
    */
   updateMetadata(aInstallLocation, aOldAddon, aAddonState, aNewAddon) {
     logger.debug("Add-on " + aOldAddon.id + " modified in " + aInstallLocation.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 = syncLoadManifestFromFile(file, aInstallLocation, aOldAddon);
+        aNewAddon = XPIInstall.syncLoadManifestFromFile(file, aInstallLocation, 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);
     } catch (e) {
       logger.warn("updateMetadata: Add-on " + aOldAddon.id + " is invalid", e);
@@ -1195,17 +1196,17 @@ this.XPIDatabaseReconcile = {
 
     let checkSigning = aOldAddon.signedState === undefined && ADDON_SIGNING &&
                        SIGNED_TYPES.has(aOldAddon.type);
 
     let manifest = null;
     if (checkSigning || aReloadMetadata) {
       try {
         let file = new nsIFile(aAddonState.path);
-        manifest = syncLoadManifestFromFile(file, aInstallLocation);
+        manifest = XPIInstall.syncLoadManifestFromFile(file, aInstallLocation);
       } catch (err) {
         // If we can no longer read the manifest, it is no longer compatible.
         aOldAddon.brokenManifest = true;
         aOldAddon.appDisabled = true;
         return aOldAddon;
       }
     }
 
@@ -1480,17 +1481,17 @@ this.XPIDatabaseReconcile = {
 
             XPIProvider.callBootstrapMethod(previousAddon, previousAddon._sourceBundle,
                                             "uninstall", installReason,
                                             { newVersion: currentAddon.version });
             XPIProvider.unloadBootstrapScope(previousAddon.id);
           }
 
           // Make sure to flush the cache when an old add-on has gone away
-          flushChromeCaches();
+          XPIInstall.flushChromeCaches();
 
           if (currentAddon.bootstrap) {
             // Visible bootstrapped add-ons need to have their install method called
             let file = currentAddon._sourceBundle.clone();
             XPIProvider.callBootstrapMethod(currentAddon, file,
                                             "install", installReason,
                                             { oldVersion: previousAddon.version });
             if (currentAddon.disabled)
@@ -1523,17 +1524,17 @@ this.XPIDatabaseReconcile = {
         XPIProvider.callBootstrapMethod(previousAddon, previousAddon._sourceBundle,
                                         "uninstall", BOOTSTRAP_REASONS.ADDON_UNINSTALL);
         XPIProvider.unloadBootstrapScope(previousAddon.id);
       }
       AddonManagerPrivate.addStartupChange(AddonManager.STARTUP_CHANGE_UNINSTALLED, id);
       XPIStates.removeAddon(previousAddon.location, id);
 
       // Make sure to flush the cache when an old add-on has gone away
-      flushChromeCaches();
+      XPIInstall.flushChromeCaches();
     }
 
     // Make sure add-ons from hidden locations are marked invisible and inactive
     let locationAddonMap = currentAddons.get(hideLocation);
     if (locationAddonMap) {
       for (let addon of locationAddonMap.values()) {
         addon.visible = false;
         addon.active = false;
--- a/xpcom/io/nsIBinaryOutputStream.idl
+++ b/xpcom/io/nsIBinaryOutputStream.idl
@@ -50,23 +50,24 @@ interface nsIBinaryOutputStream : nsIOut
      * Write an 8-bit pascal style string (UTF8-encoded) to the stream.
      * 32-bit length field, followed by length 8-bit chars.
      */
     void writeUtf8Z(in wstring aString);
 
     /**
      * Write an opaque byte array to the stream.
      */
-    void writeBytes([size_is(aLength)] in string aString, in uint32_t aLength);
+    void writeBytes([size_is(aLength)] in string aString,
+                    [optional] in uint32_t aLength);
 
     /**
      * Write an opaque byte array to the stream.
      */
     void writeByteArray([array, size_is(aLength)] in uint8_t aBytes,
-                        in uint32_t aLength);
+                        [optional] in uint32_t aLength);
 
 };
 
 %{C++
 
 inline nsresult
 NS_WriteOptionalStringZ(nsIBinaryOutputStream* aStream, const char* aString)
 {