Bug 857456 Part 2: Separate handling for non-webextensions from the addons manager r=kmag
authorAndrew Swan <aswan@mozilla.com>
Tue, 20 Nov 2018 20:19:59 -0800
changeset 508056 061b97e02ede2133f50956feba25fd8798745347
parent 508055 51bb2e2f30d2c1486ebbc75568e09976a374fe6c
child 508057 e3b6db3b34ab06b60ab5b0f9d8d5695df754b7e1
push id1905
push userffxbld-merge
push dateMon, 21 Jan 2019 12:33:13 +0000
treeherdermozilla-release@c2fca1944d8c [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewerskmag
bugs857456
milestone65.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 857456 Part 2: Separate handling for non-webextensions from the addons manager r=kmag This patch adds a hook to the addon manager that can be used to teach it how to load xpi-packaged addons that the addon manager doesn't already know about. Handing for install.rdf/bootstrap.js is (temporarily) converted to use this hook rather than being built directly into the addon manager.
toolkit/mozapps/extensions/AddonManager.jsm
toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
toolkit/mozapps/extensions/internal/BootstrapLoader.jsm
toolkit/mozapps/extensions/internal/XPIDatabase.jsm
toolkit/mozapps/extensions/internal/XPIInstall.jsm
toolkit/mozapps/extensions/internal/XPIProvider.jsm
toolkit/mozapps/extensions/internal/moz.build
toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
--- a/toolkit/mozapps/extensions/AddonManager.jsm
+++ b/toolkit/mozapps/extensions/AddonManager.jsm
@@ -546,16 +546,17 @@ var AddonManagerInternal = {
   pendingProviders: new Set(),
   providers: new Set(),
   providerShutdowns: new Map(),
   types: {},
   startupChanges: {},
   // Store telemetry details per addon provider
   telemetryDetails: {},
   upgradeListeners: new Map(),
+  externalExtensionLoaders: new Map(),
 
   recordTimestamp(name, value) {
     this.TelemetryTimestamps.add(name, value);
   },
 
   validateBlocklist() {
     let appBlocklist = FileUtils.getFile(KEY_APPDIR, [FILE_BLOCKLIST]);
 
@@ -774,16 +775,20 @@ var AddonManagerInternal = {
           }
           logger.debug("Loaded provider scope for " + url + ": " + Object.keys(scope).toSource());
         } catch (e) {
           AddonManagerPrivate.recordException("AMI", "provider " + url + " load failed", e);
           logger.error("Exception loading default provider \"" + url + "\"", e);
         }
       }
 
+      // XXX temporary
+      ChromeUtils.import("resource://gre/modules/addons/BootstrapLoader.jsm");
+      AddonManager.addExternalExtensionLoader(BootstrapLoader);
+
       // Load any providers registered in the category manager
       for (let {entry, value: url} of Services.catMan.enumerateCategory(CATEGORY_PROVIDER_MODULE)) {
         try {
           ChromeUtils.import(url, {});
           logger.debug(`Loaded provider scope for ${url}`);
         } catch (e) {
           AddonManagerPrivate.recordException("AMI", "provider " + url + " load failed", e);
           logger.error("Exception loading provider " + entry + " from category \"" +
@@ -2027,16 +2032,20 @@ var AddonManagerInternal = {
     }
     if (this.upgradeListeners.has(addonId)) {
       this.upgradeListeners.delete(addonId);
     } else {
       throw Error(`No upgrade listener registered for addon ID: ${addonId}`);
     }
   },
 
+  addExternalExtensionLoader(loader) {
+    this.externalExtensionLoaders.set(loader.name, loader);
+  },
+
   /**
    * Installs a temporary add-on from a local file or directory.
    *
    * @param  aFile
    *         An nsIFile for the file or directory of the add-on to be
    *         temporarily installed.
    * @returns a Promise that rejects if the add-on is not a valid restartless
    *          add-on or if the same ID is already temporarily installed.
@@ -2936,16 +2945,20 @@ var AddonManagerPrivate = {
   hasUpgradeListener(aId) {
     return AddonManagerInternal.upgradeListeners.has(aId);
   },
 
   getUpgradeListener(aId) {
     return AddonManagerInternal.upgradeListeners.get(aId);
   },
 
+  get externalExtensionLoaders() {
+    return AddonManagerInternal.externalExtensionLoaders;
+  },
+
   /**
    * Predicate that returns true if we think the given extension ID
    * might have been generated by XPIProvider.
    */
   isTemporaryInstallID(extensionId) {
      if (!gStarted)
        throw Components.Exception("AddonManager is not initialized",
                                   Cr.NS_ERROR_NOT_INITIALIZED);
@@ -3348,16 +3361,21 @@ var AddonManager = {
 
   addUpgradeListener(aInstanceID, aCallback) {
     AddonManagerInternal.addUpgradeListener(aInstanceID, aCallback);
   },
 
   removeUpgradeListener(aInstanceID) {
     return AddonManagerInternal.removeUpgradeListener(aInstanceID);
   },
+
+  addExternalExtensionLoader(types, loader) {
+    return AddonManagerInternal.addExternalExtensionLoader(types, loader);
+  },
+
   addAddonListener(aListener) {
     AddonManagerInternal.addAddonListener(aListener);
   },
 
   removeAddonListener(aListener) {
     AddonManagerInternal.removeAddonListener(aListener);
   },
 
--- a/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
+++ b/toolkit/mozapps/extensions/internal/AddonTestUtils.jsm
@@ -838,16 +838,19 @@ var AddonTestUtils = {
     // the AddonManagerInternal.shutdown() promise
     let shutdownError = XPIscope.XPIDatabase._saveError;
 
     AddonManagerPrivate.unregisterProvider(XPIscope.XPIProvider);
     Cu.unload("resource://gre/modules/addons/XPIProvider.jsm");
     Cu.unload("resource://gre/modules/addons/XPIDatabase.jsm");
     Cu.unload("resource://gre/modules/addons/XPIInstall.jsm");
 
+    // XXX
+    Cu.unload("resource://gre/modules/addons/BootstrapLoader.jsm");
+
     let ExtensionScope = ChromeUtils.import("resource://gre/modules/Extension.jsm", null);
     ChromeUtils.defineModuleGetter(ExtensionScope, "XPIProvider",
                                    "resource://gre/modules/addons/XPIProvider.jsm");
 
     if (shutdownError)
       throw shutdownError;
 
     return true;
new file mode 100644
--- /dev/null
+++ b/toolkit/mozapps/extensions/internal/BootstrapLoader.jsm
@@ -0,0 +1,363 @@
+
+"use strict";
+
+var EXPORTED_SYMBOLS = ["BootstrapLoader"];
+
+ChromeUtils.import("resource://gre/modules/AddonManager.jsm");
+ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
+
+XPCOMUtils.defineLazyModuleGetters(this, {
+  AddonInternal: "resource://gre/modules/addons/XPIDatabase.jsm",
+  Blocklist: "resource://gre/modules/Blocklist.jsm",
+  ConsoleAPI: "resource://gre/modules/Console.jsm",
+  InstallRDF: "resource://gre/modules/addons/RDFManifestConverter.jsm",
+  Services: "resource://gre/modules/Services.jsm",
+});
+
+XPCOMUtils.defineLazyGetter(this, "BOOTSTRAP_REASONS", () => {
+  const {XPIProvider} = ChromeUtils.import("resource://gre/modules/addons/XPIProvider.jsm", {});
+  return XPIProvider.BOOTSTRAP_REASONS;
+});
+
+ChromeUtils.import("resource://gre/modules/Log.jsm");
+var logger = Log.repository.getLogger("addons.bootstrap");
+
+/**
+ * Valid IDs fit this pattern.
+ */
+var gIDTest = /^(\{[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\}|[a-z0-9-\._]*\@[a-z0-9-\._]+)$/i;
+
+// Properties that exist in the install manifest
+const PROP_METADATA      = ["id", "version", "type", "internalName", "updateURL",
+                            "optionsURL", "optionsType", "aboutURL", "iconURL"];
+const PROP_LOCALE_SINGLE = ["name", "description", "creator", "homepageURL"];
+const PROP_LOCALE_MULTI  = ["developers", "translators", "contributors"];
+
+// Map new string type identifiers to old style nsIUpdateItem types.
+// Retired values:
+// 32 = multipackage xpi file
+// 8 = locale
+// 256 = apiextension
+// 128 = experiment
+// theme = 4
+const TYPES = {
+  extension: 2,
+  dictionary: 64,
+};
+
+const COMPATIBLE_BY_DEFAULT_TYPES = {
+  extension: true,
+  dictionary: true,
+};
+
+const hasOwnProperty = Function.call.bind(Object.prototype.hasOwnProperty);
+
+function isXPI(filename) {
+  let ext = filename.slice(-4).toLowerCase();
+  return ext === ".xpi" || ext === ".zip";
+}
+
+/**
+ * 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 {nsIFile} aFile
+ *        The file containing the resources, must be either a directory or an
+ *        XPI file
+ * @param {string} aPath
+ *        The path to find the resource at, "/" separated. If aPath is empty
+ *        then the uri to the root of the contained files will be returned
+ * @returns {nsIURI}
+ *        An nsIURI pointing at the resource
+ */
+function getURIForResourceInFile(aFile, aPath) {
+  if (!isXPI(aFile.leafName)) {
+    let resource = aFile.clone();
+    if (aPath)
+      aPath.split("/").forEach(part => resource.append(part));
+
+    return Services.io.newFileURI(resource);
+  }
+
+  return buildJarURI(aFile, aPath);
+}
+
+/**
+ * Creates a jar: URI for a file inside a ZIP file.
+ *
+ * @param {nsIFile} aJarfile
+ *        The ZIP file as an nsIFile
+ * @param {string} aPath
+ *        The path inside the ZIP file
+ * @returns {nsIURI}
+ *        An nsIURI for the file
+ */
+function buildJarURI(aJarfile, aPath) {
+  let uri = Services.io.newFileURI(aJarfile);
+  uri = "jar:" + uri.spec + "!/" + aPath;
+  return Services.io.newURI(uri);
+}
+
+var BootstrapLoader = {
+  name: "bootstrap",
+  manifestFile: "install.rdf",
+  async loadManifest(pkg) {
+    /**
+     * Reads locale properties from either the main install manifest root or
+     * an em:localized section in the install manifest.
+     *
+     * @param {Object} aSource
+     *        The resource to read the properties from.
+     * @param {boolean} isDefault
+     *        True if the locale is to be read from the main install manifest
+     *        root
+     * @param {string[]} aSeenLocales
+     *        An array of locale names already seen for this install manifest.
+     *        Any locale names seen as a part of this function will be added to
+     *        this array
+     * @returns {Object}
+     *        an object containing the locale properties
+     */
+    function readLocale(aSource, isDefault, aSeenLocales) {
+      let locale = {};
+      if (!isDefault) {
+        locale.locales = [];
+        for (let localeName of aSource.locales || []) {
+          if (!localeName) {
+            logger.warn("Ignoring empty locale in localized properties");
+            continue;
+          }
+          if (aSeenLocales.includes(localeName)) {
+            logger.warn("Ignoring duplicate locale in localized properties");
+            continue;
+          }
+          aSeenLocales.push(localeName);
+          locale.locales.push(localeName);
+        }
+
+        if (locale.locales.length == 0) {
+          logger.warn("Ignoring localized properties with no listed locales");
+          return null;
+        }
+      }
+
+      for (let prop of [...PROP_LOCALE_SINGLE, ...PROP_LOCALE_MULTI]) {
+        if (hasOwnProperty(aSource, prop)) {
+          locale[prop] = aSource[prop];
+        }
+      }
+
+      return locale;
+    }
+
+    let manifestData = await pkg.readString("install.rdf");
+    let manifest = InstallRDF.loadFromString(manifestData).decode();
+
+    let addon = new AddonInternal();
+    for (let prop of PROP_METADATA) {
+      if (hasOwnProperty(manifest, prop)) {
+        addon[prop] = manifest[prop];
+      }
+    }
+
+    if (!addon.type) {
+      addon.type = "extension";
+    } else {
+      let type = addon.type;
+      addon.type = null;
+      for (let name in TYPES) {
+        if (TYPES[name] == type) {
+          addon.type = name;
+          break;
+        }
+      }
+    }
+
+    if (!(addon.type in TYPES))
+      throw new Error("Install manifest specifies unknown type: " + addon.type);
+
+    if (!addon.id)
+      throw new Error("No ID in install manifest");
+    if (!gIDTest.test(addon.id))
+      throw new Error("Illegal add-on ID " + addon.id);
+    if (!addon.version)
+      throw new Error("No version in install manifest");
+
+    addon.strictCompatibility = (!(addon.type in COMPATIBLE_BY_DEFAULT_TYPES) ||
+                                 manifest.strictCompatibility == "true");
+
+    // Only read these properties for extensions.
+    if (addon.type == "extension") {
+      if (manifest.bootstrap != "true") {
+        throw new Error("Non-restartless extensions no longer supported");
+      }
+
+      if (addon.optionsType &&
+          addon.optionsType != AddonManager.OPTIONS_TYPE_INLINE_BROWSER &&
+          addon.optionsType != AddonManager.OPTIONS_TYPE_TAB) {
+            throw new Error("Install manifest specifies unknown optionsType: " + addon.optionsType);
+      }
+    } else {
+      // Convert legacy dictionaries into a format the WebExtension
+      // dictionary loader can process.
+      if (addon.type === "dictionary") {
+        addon.loader = null;
+        let dictionaries = {};
+        await pkg.iterFiles(({path}) => {
+          let match = /^dictionaries\/([^\/]+)\.dic$/.exec(path);
+          if (match) {
+            let lang = match[1].replace(/_/g, "-");
+            dictionaries[lang] = match[0];
+          }
+        });
+        addon.startupData = {dictionaries};
+      }
+
+      // Only extensions are allowed to provide an optionsURL, optionsType,
+      // optionsBrowserStyle, or aboutURL. For all other types they are silently ignored
+      addon.aboutURL = null;
+      addon.optionsBrowserStyle = null;
+      addon.optionsType = null;
+      addon.optionsURL = null;
+    }
+
+    addon.defaultLocale = readLocale(manifest, true);
+
+    let seenLocales = [];
+    addon.locales = [];
+    for (let localeData of manifest.localized || []) {
+      let locale = readLocale(localeData, false, seenLocales);
+      if (locale)
+        addon.locales.push(locale);
+    }
+
+    let dependencies = new Set(manifest.dependencies);
+    addon.dependencies = Object.freeze(Array.from(dependencies));
+
+    let seenApplications = [];
+    addon.targetApplications = [];
+    for (let targetApp of manifest.targetApplications || []) {
+      if (!targetApp.id || !targetApp.minVersion ||
+          !targetApp.maxVersion) {
+            logger.warn("Ignoring invalid targetApplication entry in install manifest");
+            continue;
+      }
+      if (seenApplications.includes(targetApp.id)) {
+        logger.warn("Ignoring duplicate targetApplication entry for " + targetApp.id +
+                    " in install manifest");
+        continue;
+      }
+      seenApplications.push(targetApp.id);
+      addon.targetApplications.push(targetApp);
+    }
+
+    // Note that we don't need to check for duplicate targetPlatform entries since
+    // the RDF service coalesces them for us.
+    addon.targetPlatforms = [];
+    for (let targetPlatform of manifest.targetPlatforms || []) {
+      let platform = {
+        os: null,
+        abi: null,
+      };
+
+      let pos = targetPlatform.indexOf("_");
+      if (pos != -1) {
+        platform.os = targetPlatform.substring(0, pos);
+        platform.abi = targetPlatform.substring(pos + 1);
+      } else {
+        platform.os = targetPlatform;
+      }
+
+      addon.targetPlatforms.push(platform);
+    }
+
+    addon.userDisabled = false;
+    addon.softDisabled = addon.blocklistState == Blocklist.STATE_SOFTBLOCKED;
+    addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
+
+    addon.userPermissions = null;
+
+    addon.icons = {};
+    if (await pkg.hasResource("icon.png")) {
+      addon.icons[32] = "icon.png";
+      addon.icons[48] = "icon.png";
+    }
+
+    if (await pkg.hasResource("icon64.png")) {
+      addon.icons[64] = "icon64.png";
+    }
+
+    return addon;
+  },
+
+  loadScope(addon, file) {
+    let uri = getURIForResourceInFile(file, "bootstrap.js").spec;
+    let principal = Services.scriptSecurityManager.getSystemPrincipal();
+
+    let sandbox = new Cu.Sandbox(principal, {
+      sandboxName: uri,
+      addonId: addon.id,
+      wantGlobalProperties: ["ChromeUtils"],
+      metadata: { addonID: addon.id, URI: uri },
+    });
+
+    try {
+      Object.assign(sandbox, BOOTSTRAP_REASONS);
+
+      XPCOMUtils.defineLazyGetter(sandbox, "console", () =>
+        new ConsoleAPI({ consoleID: `addon/${addon.id}` }));
+
+      Services.scriptloader.loadSubScript(uri, sandbox);
+    } catch (e) {
+      logger.warn(`Error loading bootstrap.js for ${addon.id}`, e);
+    }
+
+    function findMethod(name) {
+      if (sandbox.name) {
+        return sandbox.name;
+      }
+
+      try {
+        let method = Cu.evalInSandbox(name, sandbox);
+        return method;
+      } catch (err) { }
+
+      return () => {
+        logger.warn(`Add-on ${addon.id} is missing bootstrap method ${name}`);
+      };
+    }
+
+    let install = findMethod("install");
+    let uninstall = findMethod("uninstall");
+    let startup = findMethod("startup");
+    let shutdown = findMethod("shutdown");
+
+    return {
+      install: (...args) => install(...args),
+      uninstall: (...args) => uninstall(...args),
+
+      startup(...args) {
+        if (addon.type == "extension") {
+          logger.debug(`Registering manifest for ${file.path}\n`);
+          Components.manager.addBootstrappedManifestLocation(file);
+        }
+        return startup(...args);
+      },
+
+      shutdown(data, reason) {
+        try {
+          return shutdown(data, reason);
+        } catch (err) {
+          throw err;
+        } finally {
+          if (reason != BOOTSTRAP_REASONS.APP_SHUTDOWN) {
+            logger.debug(`Removing manifest for ${file.path}\n`);
+            Components.manager.removeBootstrappedManifestLocation(file);
+          }
+        }
+      },
+    };
+  },
+};
+
--- a/toolkit/mozapps/extensions/internal/XPIDatabase.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIDatabase.jsm
@@ -97,17 +97,17 @@ const PENDING_INSTALL_METADATA =
 
 const COMPATIBLE_BY_DEFAULT_TYPES = {
   extension: true,
   dictionary: true,
 };
 
 // Properties to save in JSON file
 const PROP_JSON_FIELDS = ["id", "syncGUID", "version", "type",
-                          "isWebExtension", "updateURL", "optionsURL",
+                          "loader", "updateURL", "optionsURL",
                           "optionsType", "optionsBrowserStyle", "aboutURL",
                           "defaultLocale", "visible", "active", "userDisabled",
                           "appDisabled", "pendingUninstall", "installDate",
                           "updateDate", "applyBackgroundUpdates", "path",
                           "skinnable", "sourceURI", "releaseNotesURI",
                           "softDisabled", "foreignInstall",
                           "strictCompatibility", "locales", "targetApplications",
                           "targetPlatforms", "signedState",
@@ -276,16 +276,20 @@ class AddonInternal {
     return this._wrapper;
   }
 
   addedToDatabase() {
     this._key = `${this.location.name}:${this.id}`;
     this.inDatabase = true;
   }
 
+  get isWebExtension() {
+    return this.loader == null;
+  }
+
   get selectedLocale() {
     if (this._selectedLocale)
       return this._selectedLocale;
 
     /**
      * this.locales is a list of objects that have property `locales`.
      * It's value is an array of locale codes.
      *
@@ -1313,17 +1317,52 @@ this.XPIDatabase = {
       let inputAddons = JSON.parse(aData);
 
       if (!("schemaVersion" in inputAddons) || !("addons" in inputAddons)) {
         let error = new Error("Bad JSON file contents");
         error.rebuildReason = "XPIDB_rebuildBadJSON_MS";
         throw error;
       }
 
-      if (inputAddons.schemaVersion != DB_SCHEMA) {
+      if (inputAddons.schemaVersion == 27) {
+        // Types were translated in bug 857456.
+        for (let addon of inputAddons.addons) {
+          switch (addon.type) {
+            case "extension":
+            case "dictionary":
+            case "locale":
+            case "theme":
+              addon.loader = "bootstrap";
+              break;
+
+            case "webbextension":
+              addon.type = "extension";
+              addon.loader = null;
+              break;
+
+            case "webextension-dictionary":
+              addon.type = "dictionary";
+              addon.loader = null;
+              break;
+
+            case "webextension-langpack":
+              addon.type = "locale";
+              addon.loader = null;
+              break;
+
+            case "webextension-theme":
+              addon.type = "theme";
+              addon.loader = null;
+              break;
+
+            default:
+              logger.warn(`Not converting unknown addon type ${addon.type}`);
+          }
+        }
+      } else if (inputAddons.schemaVersion != DB_SCHEMA) {
         // For now, we assume compatibility for JSON data with a
         // mismatched schema version, though we throw away any fields we
         // don't know about (bug 902956)
         this._recordStartupError(`schemaMismatch-${inputAddons.schemaVersion}`);
         logger.debug(`JSON schema mismatch: expected ${DB_SCHEMA}, actual ${inputAddons.schemaVersion}`);
       }
 
       let forEach = this.syncLoadingDB ? arrayForEach : idleForEach;
--- a/toolkit/mozapps/extensions/internal/XPIInstall.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIInstall.jsm
@@ -38,17 +38,16 @@ XPCOMUtils.defineLazyModuleGetters(this,
   ExtensionData: "resource://gre/modules/Extension.jsm",
   FileUtils: "resource://gre/modules/FileUtils.jsm",
   NetUtil: "resource://gre/modules/NetUtil.jsm",
   OS: "resource://gre/modules/osfile.jsm",
   ProductAddonChecker: "resource://gre/modules/addons/ProductAddonChecker.jsm",
   UpdateUtils: "resource://gre/modules/UpdateUtils.jsm",
 
   AddonInternal: "resource://gre/modules/addons/XPIDatabase.jsm",
-  InstallRDF: "resource://gre/modules/addons/RDFManifestConverter.jsm",
   XPIDatabase: "resource://gre/modules/addons/XPIDatabase.jsm",
   XPIInternal: "resource://gre/modules/addons/XPIProvider.jsm",
 });
 
 XPCOMUtils.defineLazyServiceGetter(this, "uuidGen",
                                    "@mozilla.org/uuid-generator;1", "nsIUUIDGenerator");
 
 XPCOMUtils.defineLazyGetter(this, "IconDetails", () => {
@@ -70,18 +69,16 @@ const FileOutputStream = Components.Cons
                                                 "nsIFileOutputStream", "init");
 const ZipReader = Components.Constructor("@mozilla.org/libjar/zip-reader;1",
                                          "nsIZipReader", "open");
 
 XPCOMUtils.defineLazyServiceGetters(this, {
   gCertDB: ["@mozilla.org/security/x509certdb;1", "nsIX509CertDB"],
 });
 
-const hasOwnProperty = Function.call.bind(Object.prototype.hasOwnProperty);
-
 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";
 
@@ -152,34 +149,16 @@ const PREF_INSTALL_REQUIREBUILTINCERTS =
 const KEY_PROFILEDIR                  = "ProfD";
 const KEY_TEMPDIR                     = "TmpD";
 
 const KEY_APP_PROFILE                 = "app-profile";
 
 const DIR_STAGE                       = "staged";
 const DIR_TRASH                       = "trash";
 
-// Properties that exist in the install manifest
-const PROP_METADATA      = ["id", "version", "type", "internalName", "updateURL",
-                            "optionsURL", "optionsType", "aboutURL", "iconURL"];
-const PROP_LOCALE_SINGLE = ["name", "description", "creator", "homepageURL"];
-const PROP_LOCALE_MULTI  = ["developers", "translators", "contributors"];
-
-// Map new string type identifiers to old style nsIUpdateItem types.
-// Retired values:
-// 32 = multipackage xpi file
-// 8 = locale
-// 256 = apiextension
-// 128 = experiment
-// theme = 4
-const TYPES = {
-  extension: 2,
-  dictionary: 64,
-};
-
 const COMPATIBLE_BY_DEFAULT_TYPES = {
   extension: true,
   dictionary: true,
 };
 
 // This is a random number array that can be used as "salt" when generating
 // an automatic ID based on the directory path of an add-on. It will prevent
 // someone from creating an ID for a permanent add-on that could be replaced
@@ -401,17 +380,17 @@ function waitForAllPromises(promises) {
       })
     );
     Promise.all(newPromises)
            .then((results) => shouldReject ? reject(rejectValue) : resolve(results));
   });
 }
 
 /**
- * Reads an AddonInternal object from a manifest stream.
+ * Reads an AddonInternal object from a webextension manifest.json
  *
  * @param {nsIURI} aUri
  *        A |file:| or |jar:| URL for the manifest
  * @param {Package} aPackage
  *        The install package for the add-on
  * @returns {AddonInternal}
  * @throws if the install manifest in the stream is corrupt or could not
  *         be read
@@ -444,17 +423,17 @@ async function loadManifestFromWebManife
   if (bss.strict_min_version && bss.strict_min_version.split(".").some(part => part == "*")) {
     throw new Error("The use of '*' in strict_min_version is invalid");
   }
 
   let addon = new AddonInternal();
   addon.id = bss.id;
   addon.version = manifest.version;
   addon.type = extension.type === "langpack" ? "locale" : extension.type;
-  addon.isWebExtension = true;
+  addon.loader = null;
   addon.strictCompatibility = true;
   addon.internalName = null;
   addon.updateURL = bss.update_url;
   addon.optionsBrowserStyle = true;
   addon.optionsURL = null;
   addon.optionsType = null;
   addon.aboutURL = null;
   addon.dependencies = Object.freeze(Array.from(extension.dependencies));
@@ -531,210 +510,16 @@ async function loadManifestFromWebManife
   addon.targetPlatforms = [];
   // Themes are disabled by default, except when they're installed from a web page.
   addon.userDisabled = (extension.type === "theme");
   addon.softDisabled = addon.blocklistState == nsIBlocklistService.STATE_SOFTBLOCKED;
 
   return addon;
 }
 
-/**
- * Reads an AddonInternal object from an RDF stream.
- *
- * @param {nsIURI} aUri
- *        The URI that the manifest is being read from
- * @param {string} aData
- *        The manifest text
- * @param {InstallPackage} aPackage
- *        An install package instance for the extension.
- * @returns {AddonInternal}
- * @throws if the install manifest in the RDF stream is corrupt or could not
- *         be read
- */
-async function loadManifestFromRDF(aUri, aData, aPackage) {
-  /**
-   * Reads locale properties from either the main install manifest root or
-   * an em:localized section in the install manifest.
-   *
-   * @param {Object} aSource
-   *        The resource to read the properties from.
-   * @param {boolean} isDefault
-   *        True if the locale is to be read from the main install manifest
-   *        root
-   * @param {string[]} aSeenLocales
-   *        An array of locale names already seen for this install manifest.
-   *        Any locale names seen as a part of this function will be added to
-   *        this array
-   * @returns {Object}
-   *        an object containing the locale properties
-   */
-  function readLocale(aSource, isDefault, aSeenLocales) {
-    let locale = {};
-    if (!isDefault) {
-      locale.locales = [];
-      for (let localeName of aSource.locales || []) {
-        if (!localeName) {
-          logger.warn("Ignoring empty locale in localized properties");
-          continue;
-        }
-        if (aSeenLocales.includes(localeName)) {
-          logger.warn("Ignoring duplicate locale in localized properties");
-          continue;
-        }
-        aSeenLocales.push(localeName);
-        locale.locales.push(localeName);
-      }
-
-      if (locale.locales.length == 0) {
-        logger.warn("Ignoring localized properties with no listed locales");
-        return null;
-      }
-    }
-
-    for (let prop of [...PROP_LOCALE_SINGLE, ...PROP_LOCALE_MULTI]) {
-      if (hasOwnProperty(aSource, prop)) {
-        locale[prop] = aSource[prop];
-      }
-    }
-
-    return locale;
-  }
-
-  let manifest = InstallRDF.loadFromString(aData).decode();
-
-  let addon = new AddonInternal();
-  for (let prop of PROP_METADATA) {
-    if (hasOwnProperty(manifest, prop)) {
-      addon[prop] = manifest[prop];
-    }
-  }
-
-  if (!addon.type) {
-    addon.type = "extension";
-  } else {
-    let type = addon.type;
-    addon.type = null;
-    for (let name in TYPES) {
-      if (TYPES[name] == type) {
-        addon.type = name;
-        break;
-      }
-    }
-  }
-  addon.isWebExtension = false;
-
-  if (!(addon.type in TYPES))
-    throw new Error("Install manifest specifies unknown type: " + addon.type);
-
-  if (!addon.id)
-    throw new Error("No ID in install manifest");
-  if (!gIDTest.test(addon.id))
-    throw new Error("Illegal add-on ID " + addon.id);
-  if (!addon.version)
-    throw new Error("No version in install manifest");
-
-  addon.strictCompatibility = (!(addon.type in COMPATIBLE_BY_DEFAULT_TYPES) ||
-                               manifest.strictCompatibility == "true");
-
-  // Only read these properties for extensions.
-  if (addon.type == "extension") {
-    if (manifest.bootstrap != "true") {
-      throw new Error("Non-restartless extensions no longer supported");
-    }
-
-    if (addon.optionsType &&
-        addon.optionsType != AddonManager.OPTIONS_TYPE_INLINE_BROWSER &&
-        addon.optionsType != AddonManager.OPTIONS_TYPE_TAB) {
-      throw new Error("Install manifest specifies unknown optionsType: " + addon.optionsType);
-    }
-  } else {
-    // Convert legacy dictionaries into a format the WebExtension
-    // dictionary loader can process.
-    if (addon.type === "dictionary") {
-      addon.isWebExtension = true;
-      let dictionaries = {};
-      await aPackage.iterFiles(({path}) => {
-        let match = /^dictionaries\/([^\/]+)\.dic$/.exec(path);
-        if (match) {
-          let lang = match[1].replace(/_/g, "-");
-          dictionaries[lang] = match[0];
-        }
-      });
-      addon.startupData = {dictionaries};
-    }
-
-    // Only extensions are allowed to provide an optionsURL, optionsType,
-    // optionsBrowserStyle, or aboutURL. For all other types they are silently ignored
-    addon.aboutURL = null;
-    addon.optionsBrowserStyle = null;
-    addon.optionsType = null;
-    addon.optionsURL = null;
-  }
-
-  addon.defaultLocale = readLocale(manifest, true);
-
-  let seenLocales = [];
-  addon.locales = [];
-  for (let localeData of manifest.localized || []) {
-    let locale = readLocale(localeData, false, seenLocales);
-    if (locale)
-      addon.locales.push(locale);
-  }
-
-  let dependencies = new Set(manifest.dependencies);
-  addon.dependencies = Object.freeze(Array.from(dependencies));
-
-  let seenApplications = [];
-  addon.targetApplications = [];
-  for (let targetApp of manifest.targetApplications || []) {
-    if (!targetApp.id || !targetApp.minVersion ||
-        !targetApp.maxVersion) {
-      logger.warn("Ignoring invalid targetApplication entry in install manifest");
-      continue;
-    }
-    if (seenApplications.includes(targetApp.id)) {
-      logger.warn("Ignoring duplicate targetApplication entry for " + targetApp.id +
-           " in install manifest");
-      continue;
-    }
-    seenApplications.push(targetApp.id);
-    addon.targetApplications.push(targetApp);
-  }
-
-  // Note that we don't need to check for duplicate targetPlatform entries since
-  // the RDF service coalesces them for us.
-  addon.targetPlatforms = [];
-  for (let targetPlatform of manifest.targetPlatforms || []) {
-    let platform = {
-      os: null,
-      abi: null,
-    };
-
-    let pos = targetPlatform.indexOf("_");
-    if (pos != -1) {
-      platform.os = targetPlatform.substring(0, pos);
-      platform.abi = targetPlatform.substring(pos + 1);
-    } else {
-      platform.os = targetPlatform;
-    }
-
-    addon.targetPlatforms.push(platform);
-  }
-
-  addon.userDisabled = false;
-  addon.softDisabled = addon.blocklistState == nsIBlocklistService.STATE_SOFTBLOCKED;
-  addon.applyBackgroundUpdates = AddonManager.AUTOUPDATE_DEFAULT;
-
-  // icons will be filled by the calling function
-  addon.icons = {};
-  addon.userPermissions = null;
-
-  return addon;
-}
-
 function defineSyncGUID(aAddon) {
   // Define .syncGUID as a lazy property which is also settable
   Object.defineProperty(aAddon, "syncGUID", {
     get: () => {
       aAddon.syncGUID = uuidGen.generateUUID().toString();
       return aAddon.syncGUID;
     },
     set: (val) => {
@@ -755,56 +540,47 @@ function generateTemporaryInstallID(aFil
   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, 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";
+  let addon;
+  if (await aPackage.hasResource("manifest.json")) {
+    addon = await loadManifestFromWebManifest(aPackage.rootURI, aPackage);
+  } else {
+    for (let loader of AddonManagerPrivate.externalExtensionLoaders.values()) {
+      if (await aPackage.hasResource(loader.manifestFile)) {
+        addon = await loader.loadManifest(aPackage);
+        addon.loader = loader.name;
+        break;
+      }
     }
-
-    if (await aPackage.hasResource("icon64.png")) {
-      addon.icons[64] = "icon64.png";
-    }
-
-    return addon;
   }
 
-  let entry = await aPackage.getManifestFile();
-  if (!entry) {
+  if (!addon) {
     throw new Error(`File ${aPackage.filePath} does not contain a valid manifest`);
   }
 
-  let isWebExtension = entry == "manifest.json";
-  let addon = isWebExtension ?
-              await loadManifestFromWebManifest(aPackage.rootURI, aPackage) :
-              await loadFromRDF(aPackage.getURI("install.rdf"));
-
   addon._sourceBundle = aPackage.file;
   addon.location = aLocation;
 
   let {signedState, cert} = await aPackage.verifySignedState(addon);
   addon.signedState = signedState;
   if (signedState != AddonManager.SIGNEDSTATE_PRIVILEGED) {
     addon.hidden = false;
   }
 
-  if (isWebExtension && !addon.id) {
+  if (!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})`);
+        throw new Error(`Extension is signed with an invalid id (${addon.id})`);
       }
     }
     if (!addon.id && aLocation.isTemporary) {
       addon.id = generateTemporaryInstallID(aPackage.file);
     }
   }
 
   addon.propagateDisabledState(aOldAddon);
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -28,17 +28,16 @@ ChromeUtils.import("resource://gre/modul
 XPCOMUtils.defineLazyModuleGetters(this, {
   AppConstants: "resource://gre/modules/AppConstants.jsm",
   AsyncShutdown: "resource://gre/modules/AsyncShutdown.jsm",
   Dictionary: "resource://gre/modules/Extension.jsm",
   Extension: "resource://gre/modules/Extension.jsm",
   Langpack: "resource://gre/modules/Extension.jsm",
   FileUtils: "resource://gre/modules/FileUtils.jsm",
   OS: "resource://gre/modules/osfile.jsm",
-  ConsoleAPI: "resource://gre/modules/Console.jsm",
   JSONFile: "resource://gre/modules/JSONFile.jsm",
   TelemetrySession: "resource://gre/modules/TelemetrySession.jsm",
 
   XPIDatabase: "resource://gre/modules/addons/XPIDatabase.jsm",
   XPIDatabaseReconcile: "resource://gre/modules/addons/XPIDatabase.jsm",
   XPIInstall: "resource://gre/modules/addons/XPIInstall.jsm",
 });
 
@@ -100,17 +99,17 @@ const STARTUP_MTIME_SCOPES = [KEY_APP_GL
                               KEY_APP_SYSTEM_SHARE,
                               KEY_APP_SYSTEM_USER];
 
 const NOTIFICATION_FLUSH_PERMISSIONS  = "flush-pending-permissions";
 const XPI_PERMISSION                  = "install";
 
 const XPI_SIGNATURE_CHECK_PERIOD      = 24 * 60 * 60;
 
-const DB_SCHEMA = 27;
+const DB_SCHEMA = 28;
 
 const NOTIFICATION_TOOLBOX_CONNECTION_CHANGE      = "toolbox-connection-change";
 
 function encoded(strings, ...values) {
   let result = [];
 
   for (let [i, string] of strings.entries()) {
     result.push(string);
@@ -358,17 +357,17 @@ function* iterDirectory(aDir) {
  * The on-disk state of an individual XPI, created from an Object
  * as stored in the addonStartup.json file.
  */
 const JSON_FIELDS = Object.freeze([
   "changed",
   "dependencies",
   "enabled",
   "file",
-  "isWebExtension",
+  "loader",
   "lastModifiedTime",
   "path",
   "runInSafeMode",
   "signedState",
   "startupData",
   "telemetryKey",
   "type",
   "version",
@@ -442,33 +441,37 @@ class XPIState {
    * data, to be saved to addonStartup.json.
    *
    * @returns {Object}
    */
   toJSON() {
     let json = {
       dependencies: this.dependencies,
       enabled: this.enabled,
-      isWebExtension: this.isWebExtension,
       lastModifiedTime: this.lastModifiedTime,
+      loader: this.loader,
       path: this.relativePath,
       runInSafeMode: this.runInSafeMode,
       signedState: this.signedState,
       telemetryKey: this.telemetryKey,
       version: this.version,
     };
     if (this.type != "extension") {
       json.type = this.type;
     }
     if (this.startupData) {
       json.startupData = this.startupData;
     }
     return json;
   }
 
+  get isWebExtension() {
+    return this.loader == null;
+  }
+
   /**
    * Update the last modified time for an add-on on disk.
    *
    * @param {nsIFile} aFile
    *        The location of the add-on.
    * @returns {boolean}
    *       True if the time stamp has changed.
    */
@@ -516,17 +519,18 @@ class XPIState {
     // did a full recursive scan in that case, so we don't need to do it again.
     // We don't use aDBAddon.active here because it's not updated until after restart.
     let mustGetMod = (aDBAddon.visible && !aDBAddon.disabled && !this.enabled);
 
     this.enabled = aDBAddon.visible && !aDBAddon.disabled;
 
     this.version = aDBAddon.version;
     this.type = aDBAddon.type;
-    this.isWebExtension = aDBAddon.isWebExtension;
+    this.loader = aDBAddon.loader;
+
     if (aDBAddon.startupData) {
       this.startupData = aDBAddon.startupData;
     }
 
     this.telemetryKey = this.getTelemetryKey();
 
     this.dependencies = aDBAddon.dependencies;
     this.runInSafeMode = canRunInSafeMode(aDBAddon);
@@ -1507,35 +1511,29 @@ class BootstrapScope {
    * @returns {any}
    *        The return value of the bootstrap method.
    */
   async callBootstrapMethod(aMethod, aReason, aExtraParams = {}) {
     let {addon, runInSafeMode} = this;
     if (Services.appinfo.inSafeMode && !runInSafeMode)
       return null;
 
-    if (!addon.isWebExtension && addon.type == "extension" &&
-        aMethod == "startup") {
-      logger.debug(`Registering manifest for ${this.file.path}`);
-      Components.manager.addBootstrappedManifestLocation(this.file);
-    }
-
     try {
       if (!this.scope) {
         this.loadBootstrapScope(aReason);
       }
 
       if (aMethod == "startup" || aMethod == "shutdown") {
         aExtraParams.instanceID = this.instanceID;
       }
 
       let method = undefined;
       let {scope} = this;
       try {
-        method = scope[aMethod] || Cu.evalInSandbox(`${aMethod};`, scope);
+        method = scope[aMethod];
       } catch (e) {
         // An exception will be caught if the expected method is not defined.
         // That will be logged below.
       }
 
       if (aMethod == "startup") {
         this.started = true;
       } else if (aMethod == "shutdown") {
@@ -1584,22 +1582,16 @@ class BootstrapScope {
       return result;
     } finally {
       // Extensions are automatically initialized in the correct order at startup.
       if (aMethod == "startup" && aReason != BOOTSTRAP_REASONS.APP_STARTUP) {
         for (let addon of XPIProvider.getDependentAddons(this.addon)) {
           XPIDatabase.updateAddonDisabledState(addon);
         }
       }
-
-      if (addon.type == "extension" && aMethod == "shutdown" &&
-          aReason != BOOTSTRAP_REASONS.APP_SHUTDOWN) {
-        logger.debug(`Removing manifest for ${this.file.path}`);
-        Components.manager.removeBootstrappedManifestLocation(this.file);
-      }
     }
   }
 
   // No-op method to be overridden by tests.
   _beforeCallBootstrapMethod() {}
 
   /**
    * Loads a bootstrapped add-on's bootstrap.js into a sandbox and the reason
@@ -1638,36 +1630,22 @@ class BootstrapScope {
         case "dictionary":
           this.scope = Dictionary.getBootstrapScope(this.addon.id, this.file);
           break;
 
         default:
           throw new Error(`Unknown webextension type ${this.addon.type}`);
       }
     } else {
-      let uri = getURIForResourceInFile(this.file, "bootstrap.js").spec;
-
-      let principal = Services.scriptSecurityManager.getSystemPrincipal();
-      this.scope =
-        new Cu.Sandbox(principal, { sandboxName: uri,
-                                    addonId: this.addon.id,
-                                    wantGlobalProperties: ["ChromeUtils"],
-                                    metadata: { addonID: this.addon.id, URI: uri } });
+      let loader = AddonManagerPrivate.externalExtensionLoaders.get(this.addon.loader);
+      if (!loader) {
+        throw new Error(`Cannot find loader for ${this.addon.loader}`);
+      }
 
-      try {
-        Object.assign(this.scope, BOOTSTRAP_REASONS);
-
-        XPCOMUtils.defineLazyGetter(
-          this.scope, "console",
-          () => new ConsoleAPI({ consoleID: `addon/${this.addon.id}` }));
-
-        Services.scriptloader.loadSubScript(uri, this.scope);
-      } catch (e) {
-        logger.warn(`Error loading bootstrap.js for ${this.addon.id}`, e);
-      }
+      this.scope = loader.loadScope(this.addon, this.file);
     }
 
     // Notify the BrowserToolboxProcess that a new addon has been loaded.
     let wrappedJSObject = { id: this.addon.id, options: { global: this.scope }};
     Services.obs.notifyObservers({ wrappedJSObject }, "toolbox-update-addon-options");
   }
 
   /**
--- a/toolkit/mozapps/extensions/internal/moz.build
+++ b/toolkit/mozapps/extensions/internal/moz.build
@@ -3,16 +3,17 @@
 # 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/.
 
 EXTRA_JS_MODULES.addons += [
     'AddonRepository.jsm',
     'AddonSettings.jsm',
     'AddonUpdateChecker.jsm',
+    'BootstrapLoader.jsm',
     'Content.js',
     'GMPProvider.jsm',
     'LightweightThemeImageOptimizer.jsm',
     'LightweightThemePersister.jsm',
     'ProductAddonChecker.jsm',
     'RDFDataSource.jsm',
     'RDFManifestConverter.jsm',
     'XPIDatabase.jsm',
--- a/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
+++ b/toolkit/mozapps/extensions/test/xpcshell/test_shutdown.js
@@ -5,16 +5,17 @@
 // Verify that API functions fail if the Add-ons Manager isn't initialised.
 
 const IGNORE = ["getPreferredIconURL", "escapeAddonURI",
                 "shouldAutoUpdate", "getStartupChanges",
                 "addTypeListener", "removeTypeListener",
                 "addAddonListener", "removeAddonListener",
                 "addInstallListener", "removeInstallListener",
                 "addManagerListener", "removeManagerListener",
+                "addExternalExtensionLoader",
                 "shutdown", "init",
                 "stateToString", "errorToString", "getUpgradeListener",
                 "addUpgradeListener", "removeUpgradeListener",
                 "getInstallSourceFromHost", "getInstallSourceFromPrincipal",
                ];
 
 const IGNORE_PRIVATE = ["AddonAuthor", "AddonCompatibilityOverride",
                         "AddonScreenshot", "AddonType", "startup", "shutdown",