Bug 1263011: Part 1 - Refactor Extension and ExtensionData to use ES6 classes. r=aswan
authorKris Maglione <maglione.k@gmail.com>
Fri, 17 Jun 2016 16:02:16 +0100
changeset 397568 6ef2413dc1b4a116e34eceea6cb178520e509df5
parent 397567 446f5f465b92e93c7389037bb3c8721ed43235ef
child 397569 fdbc19bfac55ae5a43b5c5cdd8cfeb52b21d0c91
push id25332
push usermaglione.k@gmail.com
push dateSat, 06 Aug 2016 21:21:51 +0000
reviewersaswan
bugs1263011
milestone51.0a1
Bug 1263011: Part 1 - Refactor Extension and ExtensionData to use ES6 classes. r=aswan MozReview-Commit-ID: f5NqRuPLU7
toolkit/components/extensions/Extension.jsm
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -20,17 +20,16 @@ const Ci = Components.interfaces;
 const Cc = Components.classes;
 const Cu = Components.utils;
 const Cr = Components.results;
 
 Cu.importGlobalProperties(["TextEncoder"]);
 
 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
 Cu.import("resource://gre/modules/Services.jsm");
-Cu.import("resource://gre/modules/ExtensionContent.jsm");
 
 XPCOMUtils.defineLazyModuleGetter(this, "Locale",
                                   "resource://gre/modules/Locale.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "Log",
                                   "resource://gre/modules/Log.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MatchGlobs",
                                   "resource://gre/modules/MatchPattern.jsm");
 XPCOMUtils.defineLazyModuleGetter(this, "MatchPattern",
@@ -72,17 +71,16 @@ const CATEGORY_EXTENSION_SCRIPTS = "webe
 Cu.import("resource://gre/modules/ExtensionUtils.jsm");
 var {
   BaseContext,
   EventEmitter,
   LocaleData,
   Messenger,
   injectAPI,
   instanceOf,
-  extend,
   flushJarCache,
 } = ExtensionUtils;
 
 const LOGGER_ID_BASE = "addons.webextension.";
 
 const COMMENT_REGEXP = new RegExp(String.raw`
     ^
     (
@@ -728,46 +726,48 @@ function getExtensionUUID(id) {
 // Represents the data contained in an extension, contained either
 // in a directory or a zip file, which may or may not be installed.
 // This class implements the functionality of the Extension class,
 // primarily related to manifest parsing and localization, which is
 // useful prior to extension installation or initialization.
 //
 // No functionality of this class is guaranteed to work before
 // |readManifest| has been called, and completed.
-this.ExtensionData = function(rootURI) {
-  this.rootURI = rootURI;
+this.ExtensionData = class {
+  constructor(rootURI) {
+    this.rootURI = rootURI;
 
-  this.manifest = null;
-  this.id = null;
-  this.uuid = null;
-  this.localeData = null;
-  this._promiseLocales = null;
+    this.manifest = null;
+    this.id = null;
+    this.uuid = null;
+    this.localeData = null;
+    this._promiseLocales = null;
 
-  this.errors = [];
-};
+    this.errors = [];
+  }
 
-ExtensionData.prototype = {
-  builtinMessages: null,
+  get builtinMessages() {
+    return null;
+  }
 
   get logger() {
     let id = this.id || "<unknown>";
     return Log.repository.getLogger(LOGGER_ID_BASE + id);
-  },
+  }
 
   // Report an error about the extension's manifest file.
   manifestError(message) {
     this.packagingError(`Reading manifest: ${message}`);
-  },
+  }
 
   // Report an error about the extension's general packaging.
   packagingError(message) {
     this.errors.push(message);
     this.logger.error(`Loading extension '${this.id}': ${message}`);
-  },
+  }
 
   /**
    * Returns the moz-extension: URL for the given path within this
    * extension.
    *
    * Must not be called unless either the `id` or `uuid` property has
    * already been set.
    *
@@ -777,85 +777,87 @@ ExtensionData.prototype = {
   getURL(path = "") {
     if (!(this.id || this.uuid)) {
       throw new Error("getURL may not be called before an `id` or `uuid` has been set");
     }
     if (!this.uuid) {
       this.uuid = getExtensionUUID(this.id);
     }
     return `moz-extension://${this.uuid}/${path}`;
-  },
+  }
 
-  readDirectory: Task.async(function* (path) {
-    if (this.rootURI instanceof Ci.nsIFileURL) {
-      let uri = NetUtil.newURI(this.rootURI.resolve("./" + path));
-      let fullPath = uri.QueryInterface(Ci.nsIFileURL).file.path;
+  readDirectory(path) {
+    return Task.spawn(function* () {
+      if (this.rootURI instanceof Ci.nsIFileURL) {
+        let uri = NetUtil.newURI(this.rootURI.resolve("./" + path));
+        let fullPath = uri.QueryInterface(Ci.nsIFileURL).file.path;
 
-      let iter = new OS.File.DirectoryIterator(fullPath);
-      let results = [];
+        let iter = new OS.File.DirectoryIterator(fullPath);
+        let results = [];
 
-      try {
-        yield iter.forEach(entry => {
-          results.push(entry);
-        });
-      } catch (e) {
-        // Always return a list, even if the directory does not exist (or is
-        // not a directory) for symmetry with the ZipReader behavior.
+        try {
+          yield iter.forEach(entry => {
+            results.push(entry);
+          });
+        } catch (e) {
+          // Always return a list, even if the directory does not exist (or is
+          // not a directory) for symmetry with the ZipReader behavior.
+        }
+        iter.close();
+
+        return results;
       }
-      iter.close();
 
-      return results;
-    }
+      if (!(this.rootURI instanceof Ci.nsIJARURI &&
+            this.rootURI.JARFile instanceof Ci.nsIFileURL)) {
+        // This currently happens for app:// URLs passed to us by
+        // UserCustomizations.jsm
+        return [];
+      }
 
-    if (!(this.rootURI instanceof Ci.nsIJARURI &&
-          this.rootURI.JARFile instanceof Ci.nsIFileURL)) {
-      // This currently happens for app:// URLs passed to us by
-      // UserCustomizations.jsm
-      return [];
-    }
+      // FIXME: We need a way to do this without main thread IO.
+
+      let file = this.rootURI.JARFile.file;
+      let zipReader = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance(Ci.nsIZipReader);
+      try {
+        zipReader.open(file);
 
-    // FIXME: We need a way to do this without main thread IO.
+        let results = [];
 
-    let file = this.rootURI.JARFile.file;
-    let zipReader = Cc["@mozilla.org/libjar/zip-reader;1"].createInstance(Ci.nsIZipReader);
-    try {
-      zipReader.open(file);
+        // Normalize the directory path.
+        path = path.replace(/\/\/+/g, "/").replace(/^\/|\/$/g, "") + "/";
+
+        // Escape pattern metacharacters.
+        let pattern = path.replace(/[[\]()?*~|$\\]/g, "\\$&");
 
-      let results = [];
-
-      // Normalize the directory path.
-      path = path.replace(/\/\/+/g, "/").replace(/^\/|\/$/g, "") + "/";
+        let enumerator = zipReader.findEntries(pattern + "*");
+        while (enumerator.hasMore()) {
+          let name = enumerator.getNext();
+          if (!name.startsWith(path)) {
+            throw new Error("Unexpected ZipReader entry");
+          }
 
-      // Escape pattern metacharacters.
-      let pattern = path.replace(/[[\]()?*~|$\\]/g, "\\$&");
-
-      let enumerator = zipReader.findEntries(pattern + "*");
-      while (enumerator.hasMore()) {
-        let name = enumerator.getNext();
-        if (!name.startsWith(path)) {
-          throw new Error("Unexpected ZipReader entry");
+          // The enumerator returns the full path of all entries.
+          // Trim off the leading path, and filter out entries from
+          // subdirectories.
+          name = name.slice(path.length);
+          if (name && !/\/./.test(name)) {
+            results.push({
+              name: name.replace("/", ""),
+              isDir: name.endsWith("/"),
+            });
+          }
         }
 
-        // The enumerator returns the full path of all entries.
-        // Trim off the leading path, and filter out entries from
-        // subdirectories.
-        name = name.slice(path.length);
-        if (name && !/\/./.test(name)) {
-          results.push({
-            name: name.replace("/", ""),
-            isDir: name.endsWith("/"),
-          });
-        }
+        return results;
+      } finally {
+        zipReader.close();
       }
-
-      return results;
-    } finally {
-      zipReader.close();
-    }
-  }),
+    }.bind(this));
+  }
 
   readJSON(path) {
     return new Promise((resolve, reject) => {
       let uri = this.rootURI.resolve(`./${path}`);
 
       NetUtil.asyncFetch({uri, loadUsingSystemPrincipal: true}, (inputStream, status) => {
         if (!Components.isSuccessCode(status)) {
           reject(new Error(status));
@@ -868,17 +870,17 @@ ExtensionData.prototype = {
           text = text.replace(COMMENT_REGEXP, "$1");
 
           resolve(JSON.parse(text));
         } catch (e) {
           reject(e);
         }
       });
     });
-  },
+  }
 
   // Reads the extension's |manifest.json| file, and stores its
   // parsed contents in |this.manifest|.
   readManifest() {
     return Promise.all([
       this.readJSON("manifest.json"),
       Management.lazyInit(),
     ]).then(([manifest]) => {
@@ -918,58 +920,60 @@ ExtensionData.prototype = {
           this.id = this.manifest.applications.gecko.id;
         }
       } catch (e) {
         // Errors are handled by the type checks above.
       }
 
       return this.manifest;
     });
-  },
+  }
 
   localizeMessage(...args) {
     return this.localeData.localizeMessage(...args);
-  },
+  }
 
   localize(...args) {
     return this.localeData.localize(...args);
-  },
+  }
 
   // If a "default_locale" is specified in that manifest, returns it
   // as a Gecko-compatible locale string. Otherwise, returns null.
   get defaultLocale() {
     if (this.manifest.default_locale != null) {
       return this.normalizeLocaleCode(this.manifest.default_locale);
     }
 
     return null;
-  },
+  }
 
   // Normalizes a Chrome-compatible locale code to the appropriate
   // Gecko-compatible variant. Currently, this means simply
   // replacing underscores with hyphens.
   normalizeLocaleCode(locale) {
     return String.replace(locale, /_/g, "-");
-  },
+  }
 
   // Reads the locale file for the given Gecko-compatible locale code, and
   // stores its parsed contents in |this.localeMessages.get(locale)|.
-  readLocaleFile: Task.async(function* (locale) {
-    let locales = yield this.promiseLocales();
-    let dir = locales.get(locale) || locale;
-    let file = `_locales/${dir}/messages.json`;
+  readLocaleFile(locale) {
+    return Task.spawn(function* () {
+      let locales = yield this.promiseLocales();
+      let dir = locales.get(locale) || locale;
+      let file = `_locales/${dir}/messages.json`;
 
-    try {
-      let messages = yield this.readJSON(file);
-      return this.localeData.addLocale(locale, messages, this);
-    } catch (e) {
-      this.packagingError(`Loading locale file ${file}: ${e}`);
-      return new Map();
-    }
-  }),
+      try {
+        let messages = yield this.readJSON(file);
+        return this.localeData.addLocale(locale, messages, this);
+      } catch (e) {
+        this.packagingError(`Loading locale file ${file}: ${e}`);
+        return new Map();
+      }
+    }.bind(this));
+  }
 
   // Reads the list of locales available in the extension, and returns a
   // Promise which resolves to a Map upon completion.
   // Each map key is a Gecko-compatible locale code, and each value is the
   // "_locales" subdirectory containing that locale:
   //
   // Map(gecko-locale-code -> locale-directory-name)
   promiseLocales() {
@@ -991,270 +995,124 @@ ExtensionData.prototype = {
           builtinMessages: this.builtinMessages,
         });
 
         return locales;
       }.bind(this));
     }
 
     return this._promiseLocales;
-  },
+  }
 
   // Reads the locale messages for all locales, and returns a promise which
   // resolves to a Map of locale messages upon completion. Each key in the map
   // is a Gecko-compatible locale code, and each value is a locale data object
   // as returned by |readLocaleFile|.
-  initAllLocales: Task.async(function* () {
-    let locales = yield this.promiseLocales();
+  initAllLocales() {
+    return Task.spawn(function* () {
+      let locales = yield this.promiseLocales();
 
-    yield Promise.all(Array.from(locales.keys(),
-                                 locale => this.readLocaleFile(locale)));
+      yield Promise.all(Array.from(locales.keys(),
+                                   locale => this.readLocaleFile(locale)));
 
-    let defaultLocale = this.defaultLocale;
-    if (defaultLocale) {
-      if (!locales.has(defaultLocale)) {
-        this.manifestError('Value for "default_locale" property must correspond to ' +
-                           'a directory in "_locales/". Not found: ' +
-                           JSON.stringify(`_locales/${this.manifest.default_locale}/`));
+      let defaultLocale = this.defaultLocale;
+      if (defaultLocale) {
+        if (!locales.has(defaultLocale)) {
+          this.manifestError('Value for "default_locale" property must correspond to ' +
+                             'a directory in "_locales/". Not found: ' +
+                             JSON.stringify(`_locales/${this.manifest.default_locale}/`));
+        }
+      } else if (locales.size) {
+        this.manifestError('The "default_locale" property is required when a ' +
+                           '"_locales/" directory is present.');
       }
-    } else if (locales.size) {
-      this.manifestError('The "default_locale" property is required when a ' +
-                         '"_locales/" directory is present.');
-    }
 
-    return this.localeData.messages;
-  }),
+      return this.localeData.messages;
+    }.bind(this));
+  }
 
   // Reads the locale file for the given Gecko-compatible locale code, or the
   // default locale if no locale code is given, and sets it as the currently
   // selected locale on success.
   //
   // Pre-loads the default locale for fallback message processing, regardless
   // of the locale specified.
   //
   // If no locales are unavailable, resolves to |null|.
-  initLocale: Task.async(function* (locale = this.defaultLocale) {
-    if (locale == null) {
-      return null;
-    }
-
-    let promises = [this.readLocaleFile(locale)];
-
-    let {defaultLocale} = this;
-    if (locale != defaultLocale && !this.localeData.has(defaultLocale)) {
-      promises.push(this.readLocaleFile(defaultLocale));
-    }
+  initLocale(locale = this.defaultLocale) {
+    return Task.spawn(function* () {
+      if (locale == null) {
+        return null;
+      }
 
-    let results = yield Promise.all(promises);
-
-    this.localeData.selectedLocale = locale;
-    return results[0];
-  }),
-};
-
-// We create one instance of this class per extension. |addonData|
-// comes directly from bootstrap.js when initializing.
-this.Extension = function(addonData) {
-  ExtensionData.call(this, addonData.resourceURI);
-
-  this.uuid = getExtensionUUID(addonData.id);
+      let promises = [this.readLocaleFile(locale)];
 
-  if (addonData.cleanupFile) {
-    Services.obs.addObserver(this, "xpcom-shutdown", false);
-    this.cleanupFile = addonData.cleanupFile || null;
-    delete addonData.cleanupFile;
-  }
-
-  this.addonData = addonData;
-  this.id = addonData.id;
-  this.baseURI = NetUtil.newURI(this.getURL("")).QueryInterface(Ci.nsIURL);
-  this.principal = this.createPrincipal();
-
-  this.views = new Set();
+      let {defaultLocale} = this;
+      if (locale != defaultLocale && !this.localeData.has(defaultLocale)) {
+        promises.push(this.readLocaleFile(defaultLocale));
+      }
 
-  this.onStartup = null;
-
-  this.hasShutdown = false;
-  this.onShutdown = new Set();
+      let results = yield Promise.all(promises);
 
-  this.uninstallURL = null;
-
-  this.permissions = new Set();
-  this.whiteListedHosts = null;
-  this.webAccessibleResources = null;
-
-  this.emitter = new EventEmitter();
+      this.localeData.selectedLocale = locale;
+      return results[0];
+    }.bind(this));
+  }
 };
 
-/**
- * This code is designed to make it easy to test a WebExtension
- * without creating a bunch of files. Everything is contained in a
- * single JSON blob.
- *
- * Properties:
- *   "background": "<JS code>"
- *     A script to be loaded as the background script.
- *     The "background" section of the "manifest" property is overwritten
- *     if this is provided.
- *   "manifest": {...}
- *     Contents of manifest.json
- *   "files": {"filename1": "contents1", ...}
- *     Data to be included as files. Can be referenced from the manifest.
- *     If a manifest file is provided here, it takes precedence over
- *     a generated one. Always use "/" as a directory separator.
- *     Directories should appear here only implicitly (as a prefix
- *     to file names)
- *
- * To make things easier, the value of "background" and "files"[] can
- * be a function, which is converted to source that is run.
- *
- * The generated extension is stored in the system temporary directory,
- * and an nsIFile object pointing to it is returned.
- *
- * @param {string} id
- * @param {object} data
- * @returns {nsIFile}
- */
-this.Extension.generateXPI = function(id, data) {
-  let manifest = data.manifest;
-  if (!manifest) {
-    manifest = {};
-  }
-
-  let files = data.files;
-  if (!files) {
-    files = {};
-  }
-
-  function provide(obj, keys, value, override = false) {
-    if (keys.length == 1) {
-      if (!(keys[0] in obj) || override) {
-        obj[keys[0]] = value;
-      }
-    } else {
-      if (!(keys[0] in obj)) {
-        obj[keys[0]] = {};
-      }
-      provide(obj[keys[0]], keys.slice(1), value, override);
-    }
-  }
-
-  provide(manifest, ["applications", "gecko", "id"], id);
-
-  provide(manifest, ["name"], "Generated extension");
-  provide(manifest, ["manifest_version"], 2);
-  provide(manifest, ["version"], "1.0");
-
-  if (data.background) {
-    let uuidGenerator = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
-    let bgScript = uuidGenerator.generateUUID().number + ".js";
-
-    provide(manifest, ["background", "scripts"], [bgScript], true);
-    files[bgScript] = data.background;
-  }
-
-  provide(files, ["manifest.json"], manifest);
-
-  let ZipWriter = Components.Constructor("@mozilla.org/zipwriter;1", "nsIZipWriter");
-  let zipW = new ZipWriter();
-
-  let file = FileUtils.getFile("TmpD", ["generated-extension.xpi"]);
-  file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
-
-  const MODE_WRONLY = 0x02;
-  const MODE_TRUNCATE = 0x20;
-  zipW.open(file, MODE_WRONLY | MODE_TRUNCATE);
-
-  // Needs to be in microseconds for some reason.
-  let time = Date.now() * 1000;
-
-  function generateFile(filename) {
-    let components = filename.split("/");
-    let path = "";
-    for (let component of components.slice(0, -1)) {
-      path += component + "/";
-      if (!zipW.hasEntry(path)) {
-        zipW.addEntryDirectory(path, time, false);
-      }
-    }
-  }
-
-  for (let filename in files) {
-    let script = files[filename];
-    if (typeof(script) == "function") {
-      script = "(" + script.toString() + ")()";
-    } else if (instanceOf(script, "Object")) {
-      script = JSON.stringify(script);
-    }
-
-    if (!instanceOf(script, "ArrayBuffer")) {
-      script = new TextEncoder("utf-8").encode(script).buffer;
-    }
-
-    let stream = Cc["@mozilla.org/io/arraybuffer-input-stream;1"].createInstance(Ci.nsIArrayBufferInputStream);
-    stream.setData(script, 0, script.byteLength);
-
-    generateFile(filename);
-    zipW.addEntryStream(filename, time, 0, stream, false);
-  }
-
-  zipW.close();
-
-  return file;
-};
 
 /**
  * A skeleton Extension-like object, used for testing, which installs an
  * add-on via the add-on manager when startup() is called, and
  * uninstalles it on shutdown().
  *
  * @param {string} id
  * @param {nsIFile} file
  * @param {nsIURI} rootURI
  * @param {string} installType
  */
-function MockExtension(id, file, rootURI, installType) {
-  this.id = id;
-  this.file = file;
-  this.rootURI = rootURI;
-  this.installType = installType;
+class MockExtension {
+  constructor(id, file, rootURI, installType) {
+    this.id = id;
+    this.file = file;
+    this.rootURI = rootURI;
+    this.installType = installType;
 
-  let promiseEvent = eventName => new Promise(resolve => {
-    let onstartup = (msg, extension) => {
-      if (extension.id == this.id) {
-        Management.off(eventName, onstartup);
+    let promiseEvent = eventName => new Promise(resolve => {
+      let onstartup = (msg, extension) => {
+        if (extension.id == this.id) {
+          Management.off(eventName, onstartup);
 
-        this._extension = extension;
-        resolve(extension);
-      }
-    };
-    Management.on(eventName, onstartup);
-  });
+          this._extension = extension;
+          resolve(extension);
+        }
+      };
+      Management.on(eventName, onstartup);
+    });
 
-  this._extension = null;
-  this._extensionPromise = promiseEvent("startup");
-  this._readyPromise = promiseEvent("ready");
-}
+    this._extension = null;
+    this._extensionPromise = promiseEvent("startup");
+    this._readyPromise = promiseEvent("ready");
+  }
 
-MockExtension.prototype = {
   testMessage(...args) {
     return this._extension.testMessage(...args);
-  },
+  }
 
   on(...args) {
     this._extensionPromise.then(extension => {
       extension.on(...args);
     });
-  },
+  }
 
   off(...args) {
     this._extensionPromise.then(extension => {
       extension.off(...args);
     });
-  },
+  }
 
   startup() {
     if (this.installType == "temporary") {
       return AddonManager.installTemporaryAddon(this.file).then(addon => {
         this.addon = addon;
         return this._readyPromise;
       });
     } else if (this.installType == "permanent") {
@@ -1269,87 +1127,238 @@ MockExtension.prototype = {
           };
 
           install.addListener(listener);
           install.install();
         });
       });
     }
     throw new Error("installType must be one of: temporary, permanent");
-  },
+  }
 
   shutdown() {
     this.addon.uninstall(true);
     return this.cleanupGeneratedFile();
-  },
+  }
 
   cleanupGeneratedFile() {
     flushJarCache(this.file);
     return OS.File.remove(this.file.path);
-  },
-};
+  }
+}
+
+// We create one instance of this class per extension. |addonData|
+// comes directly from bootstrap.js when initializing.
+this.Extension = class extends ExtensionData {
+  constructor(addonData) {
+    super(addonData.resourceURI);
+
+    this.uuid = getExtensionUUID(addonData.id);
+
+    if (addonData.cleanupFile) {
+      Services.obs.addObserver(this, "xpcom-shutdown", false);
+      this.cleanupFile = addonData.cleanupFile || null;
+      delete addonData.cleanupFile;
+    }
 
-/**
- * Generates a new extension using |Extension.generateXPI|, and initializes a
- * new |Extension| instance which will execute it.
- *
- * @param {string} id
- * @param {object} data
- * @returns {Extension}
- */
-this.Extension.generate = function(id, data) {
-  let file = this.generateXPI(id, data);
+    this.addonData = addonData;
+    this.id = addonData.id;
+    this.baseURI = NetUtil.newURI(this.getURL("")).QueryInterface(Ci.nsIURL);
+    this.principal = this.createPrincipal();
+
+    this.views = new Set();
+
+    this.onStartup = null;
 
-  flushJarCache(file);
-  Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", {path: file.path});
+    this.hasShutdown = false;
+    this.onShutdown = new Set();
+
+    this.uninstallURL = null;
 
-  let fileURI = Services.io.newFileURI(file);
-  let jarURI = Services.io.newURI("jar:" + fileURI.spec + "!/", null, null);
+    this.permissions = new Set();
+    this.whiteListedHosts = null;
+    this.webAccessibleResources = null;
 
-  // This may be "temporary" or "permanent".
-  if (data.useAddonManager) {
-    return new MockExtension(id, file, jarURI, data.useAddonManager);
+    this.emitter = new EventEmitter();
   }
 
-  return new Extension({
-    id,
-    resourceURI: jarURI,
-    cleanupFile: file,
-  });
-};
+  /**
+   * This code is designed to make it easy to test a WebExtension
+   * without creating a bunch of files. Everything is contained in a
+   * single JSON blob.
+   *
+   * Properties:
+   *   "background": "<JS code>"
+   *     A script to be loaded as the background script.
+   *     The "background" section of the "manifest" property is overwritten
+   *     if this is provided.
+   *   "manifest": {...}
+   *     Contents of manifest.json
+   *   "files": {"filename1": "contents1", ...}
+   *     Data to be included as files. Can be referenced from the manifest.
+   *     If a manifest file is provided here, it takes precedence over
+   *     a generated one. Always use "/" as a directory separator.
+   *     Directories should appear here only implicitly (as a prefix
+   *     to file names)
+   *
+   * To make things easier, the value of "background" and "files"[] can
+   * be a function, which is converted to source that is run.
+   *
+   * The generated extension is stored in the system temporary directory,
+   * and an nsIFile object pointing to it is returned.
+   *
+   * @param {string} id
+   * @param {object} data
+   * @returns {nsIFile}
+   */
+  static generateXPI(id, data) {
+    let manifest = data.manifest;
+    if (!manifest) {
+      manifest = {};
+    }
+
+    let files = data.files;
+    if (!files) {
+      files = {};
+    }
+
+    function provide(obj, keys, value, override = false) {
+      if (keys.length == 1) {
+        if (!(keys[0] in obj) || override) {
+          obj[keys[0]] = value;
+        }
+      } else {
+        if (!(keys[0] in obj)) {
+          obj[keys[0]] = {};
+        }
+        provide(obj[keys[0]], keys.slice(1), value, override);
+      }
+    }
+
+    provide(manifest, ["applications", "gecko", "id"], id);
+
+    provide(manifest, ["name"], "Generated extension");
+    provide(manifest, ["manifest_version"], 2);
+    provide(manifest, ["version"], "1.0");
+
+    if (data.background) {
+      let uuidGenerator = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator);
+      let bgScript = uuidGenerator.generateUUID().number + ".js";
+
+      provide(manifest, ["background", "scripts"], [bgScript], true);
+      files[bgScript] = data.background;
+    }
+
+    provide(files, ["manifest.json"], manifest);
+
+    let ZipWriter = Components.Constructor("@mozilla.org/zipwriter;1", "nsIZipWriter");
+    let zipW = new ZipWriter();
 
-Extension.prototype = extend(Object.create(ExtensionData.prototype), {
+    let file = FileUtils.getFile("TmpD", ["generated-extension.xpi"]);
+    file.createUnique(Ci.nsIFile.NORMAL_FILE_TYPE, FileUtils.PERMS_FILE);
+
+    const MODE_WRONLY = 0x02;
+    const MODE_TRUNCATE = 0x20;
+    zipW.open(file, MODE_WRONLY | MODE_TRUNCATE);
+
+    // Needs to be in microseconds for some reason.
+    let time = Date.now() * 1000;
+
+    function generateFile(filename) {
+      let components = filename.split("/");
+      let path = "";
+      for (let component of components.slice(0, -1)) {
+        path += component + "/";
+        if (!zipW.hasEntry(path)) {
+          zipW.addEntryDirectory(path, time, false);
+        }
+      }
+    }
+
+    for (let filename in files) {
+      let script = files[filename];
+      if (typeof(script) == "function") {
+        script = "(" + script.toString() + ")()";
+      } else if (instanceOf(script, "Object")) {
+        script = JSON.stringify(script);
+      }
+
+      if (!instanceOf(script, "ArrayBuffer")) {
+        script = new TextEncoder("utf-8").encode(script).buffer;
+      }
+
+      let stream = Cc["@mozilla.org/io/arraybuffer-input-stream;1"].createInstance(Ci.nsIArrayBufferInputStream);
+      stream.setData(script, 0, script.byteLength);
+
+      generateFile(filename);
+      zipW.addEntryStream(filename, time, 0, stream, false);
+    }
+
+    zipW.close();
+
+    return file;
+  }
+
+  /**
+   * Generates a new extension using |Extension.generateXPI|, and initializes a
+   * new |Extension| instance which will execute it.
+   *
+   * @param {string} id
+   * @param {object} data
+   * @returns {Extension}
+   */
+  static generate(id, data) {
+    let file = this.generateXPI(id, data);
+
+    flushJarCache(file);
+    Services.ppmm.broadcastAsyncMessage("Extension:FlushJarCache", {path: file.path});
+
+    let fileURI = Services.io.newFileURI(file);
+    let jarURI = Services.io.newURI("jar:" + fileURI.spec + "!/", null, null);
+
+    // This may be "temporary" or "permanent".
+    if (data.useAddonManager) {
+      return new MockExtension(id, file, jarURI, data.useAddonManager);
+    }
+
+    return new Extension({
+      id,
+      resourceURI: jarURI,
+      cleanupFile: file,
+    });
+  }
+
   on(hook, f) {
     return this.emitter.on(hook, f);
-  },
+  }
 
   off(hook, f) {
     return this.emitter.off(hook, f);
-  },
+  }
 
   emit(...args) {
     return this.emitter.emit(...args);
-  },
+  }
 
   testMessage(...args) {
     Management.emit("test-message", this, ...args);
-  },
+  }
 
   createPrincipal(uri = this.baseURI) {
     return Services.scriptSecurityManager.createCodebasePrincipal(
       uri, {addonId: this.id});
-  },
+  }
 
   // Checks that the given URL is a child of our baseURI.
   isExtensionURL(url) {
     let uri = Services.io.newURI(url, null, null);
 
     let common = this.baseURI.getCommonBaseSpec(uri);
     return common == this.baseURI.spec;
-  },
+  }
 
   // Representation of the extension to send to content
   // processes. This should include anything the content process might
   // need.
   serialize() {
     return {
       id: this.id,
       uuid: this.uuid,
@@ -1357,31 +1366,31 @@ Extension.prototype = extend(Object.crea
       resourceURL: this.addonData.resourceURI.spec,
       baseURL: this.baseURI.spec,
       content_scripts: this.manifest.content_scripts || [],  // eslint-disable-line camelcase
       webAccessibleResources: this.webAccessibleResources.serialize(),
       whiteListedHosts: this.whiteListedHosts.serialize(),
       localeData: this.localeData.serialize(),
       permissions: this.permissions,
     };
-  },
+  }
 
   broadcast(msg, data) {
     return new Promise(resolve => {
       let count = Services.ppmm.childCount;
       Services.ppmm.addMessageListener(msg + "Complete", function listener() {
         count--;
         if (count == 0) {
           Services.ppmm.removeMessageListener(msg + "Complete", listener);
           resolve();
         }
       });
       Services.ppmm.broadcastAsyncMessage(msg, data);
     });
-  },
+  }
 
   runManifest(manifest) {
     let permissions = manifest.permissions || [];
 
     let whitelist = [];
     for (let perm of permissions) {
       this.permissions.add(perm);
       if (!/^\w+(\.\w+)*$/.test(perm)) {
@@ -1410,49 +1419,54 @@ Extension.prototype = extend(Object.crea
       data["Extension:Extensions"] = [];
     }
     let serial = this.serialize();
     data["Extension:Extensions"].push(serial);
 
     return this.broadcast("Extension:Startup", serial).then(() => {
       return Promise.all(promises);
     });
-  },
+  }
 
   callOnClose(obj) {
     this.onShutdown.add(obj);
-  },
+  }
 
   forgetOnClose(obj) {
     this.onShutdown.delete(obj);
-  },
+  }
 
   get builtinMessages() {
     return new Map([
       ["@@extension_id", this.uuid],
     ]);
-  },
+  }
 
   // Reads the locale file for the given Gecko-compatible locale code, or if
   // no locale is given, the available locale closest to the UI locale.
   // Sets the currently selected locale on success.
-  initLocale: Task.async(function* (locale = undefined) {
-    if (locale === undefined) {
-      let locales = yield this.promiseLocales();
+  initLocale(locale = undefined) {
+    // Ugh.
+    let super_ = super.initLocale.bind(this);
+
+    return Task.spawn(function* () {
+      if (locale === undefined) {
+        let locales = yield this.promiseLocales();
 
-      let localeList = Array.from(locales.keys(), locale => {
-        return {name: locale, locales: [locale]};
-      });
+        let localeList = Array.from(locales.keys(), locale => {
+          return {name: locale, locales: [locale]};
+        });
 
-      let match = Locale.findClosestLocale(localeList);
-      locale = match ? match.name : this.defaultLocale;
-    }
+        let match = Locale.findClosestLocale(localeList);
+        locale = match ? match.name : this.defaultLocale;
+      }
 
-    return ExtensionData.prototype.initLocale.call(this, locale);
-  }),
+      return super_(locale);
+    }.bind(this));
+  }
 
   startup() {
     let started = false;
     return this.readManifest().then(() => {
       ExtensionManagement.startupExtension(this.uuid, this.addonData.resourceURI, this);
       started = true;
 
       if (!this.hasShutdown) {
@@ -1485,17 +1499,17 @@ Extension.prototype = extend(Object.crea
       if (started) {
         ExtensionManagement.shutdownExtension(this.uuid);
       }
 
       this.cleanupGeneratedFile();
 
       throw e;
     });
-  },
+  }
 
   cleanupGeneratedFile() {
     if (!this.cleanupFile) {
       return;
     }
 
     let file = this.cleanupFile;
     this.cleanupFile = null;
@@ -1504,17 +1518,17 @@ Extension.prototype = extend(Object.crea
 
     this.broadcast("Extension:FlushJarCache", {path: file.path}).then(() => {
       // We can't delete this file until everyone using it has
       // closed it (because Windows is dumb). So we wait for all the
       // child processes (including the parent) to flush their JAR
       // caches. These caches may keep the file open.
       file.remove(false);
     });
-  },
+  }
 
   shutdown() {
     this.hasShutdown = true;
     if (!this.manifest) {
       ExtensionManagement.shutdownExtension(this.uuid);
 
       this.cleanupGeneratedFile();
       return;
@@ -1534,24 +1548,24 @@ Extension.prototype = extend(Object.crea
 
     Services.ppmm.broadcastAsyncMessage("Extension:Shutdown", {id: this.id});
 
     MessageChannel.abortResponses({extensionId: this.id});
 
     ExtensionManagement.shutdownExtension(this.uuid);
 
     this.cleanupGeneratedFile();
-  },
+  }
 
   observe(subject, topic, data) {
     if (topic == "xpcom-shutdown") {
       this.cleanupGeneratedFile();
     }
-  },
+  }
 
   hasPermission(perm) {
     return this.permissions.has(perm);
-  },
+  }
 
   get name() {
     return this.localize(this.manifest.name);
-  },
-});
+  }
+};