Bug 1306037: Support options_ui in embedded WebExtensions. r=aswan
authorKris Maglione <maglione.k@gmail.com>
Wed, 28 Sep 2016 23:11:35 +0100
changeset 315926 bc3137d7d869992394745738791cadfc23a927b2
parent 315925 282b5fdbd98b40b96bc8568294fc500aa2f06838
child 315927 5fd6dd2bdbfaafbd7c48d3a804c15ec7fc769782
push id20634
push usercbook@mozilla.com
push dateFri, 30 Sep 2016 10:10:13 +0000
treeherderfx-team@afe79b010d13 [default view] [failures only]
perfherder[talos] [build metrics] [platform microbench] (compared to previous push)
reviewersaswan
bugs1306037
milestone52.0a1
Bug 1306037: Support options_ui in embedded WebExtensions. r=aswan MozReview-Commit-ID: KZVPz52qrTS
browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage.js
testing/specialpowers/content/SpecialPowersObserverAPI.js
toolkit/components/extensions/Extension.jsm
toolkit/mozapps/extensions/internal/XPIProvider.jsm
--- a/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage.js
+++ b/browser/components/extensions/test/browser/browser_ext_runtime_openOptionsPage.js
@@ -1,16 +1,26 @@
 /* -*- Mode: indent-tabs-mode: nil; js-indent-level: 2 -*- */
 /* vim: set sts=2 sw=2 et tw=80: */
 "use strict";
 
+requestLongerTimeout(2);
+
+function add_tasks(task) {
+  add_task(task.bind(null, {embedded: false}));
+
+  add_task(task.bind(null, {embedded: true}));
+}
+
 function* loadExtension(options) {
   let extension = ExtensionTestUtils.loadExtension({
     useAddonManager: "temporary",
 
+    embedded: options.embedded,
+
     manifest: Object.assign({
       "permissions": ["tabs"],
     }, options.manifest),
 
     files: {
       "options.html": `<!DOCTYPE html>
         <html>
           <head>
@@ -32,20 +42,22 @@ function* loadExtension(options) {
     background: options.background,
   });
 
   yield extension.startup();
 
   return extension;
 }
 
-add_task(function* test_inline_options() {
+add_tasks(function* test_inline_options(extraOptions) {
+  info(`Test options opened inline (${JSON.stringify(extraOptions)})`);
+
   let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com/");
 
-  let extension = yield loadExtension({
+  let extension = yield loadExtension(Object.assign({}, extraOptions, {
     manifest: {
       applications: {gecko: {id: "inline_options@tests.mozilla.org"}},
       "options_ui": {
         "page": "options.html",
       },
     },
 
     background: function() {
@@ -118,28 +130,30 @@ add_task(function* test_inline_options()
         return browser.tabs.remove(tab.id);
       }).then(() => {
         browser.test.notifyPass("options-ui");
       }).catch(error => {
         browser.test.log(`Error: ${error} :: ${error.stack}`);
         browser.test.notifyFail("options-ui");
       });
     },
-  });
+  }));
 
   yield extension.awaitFinish("options-ui");
   yield extension.unload();
 
   yield BrowserTestUtils.removeTab(tab);
 });
 
-add_task(function* test_tab_options() {
+add_tasks(function* test_tab_options(extraOptions) {
+  info(`Test options opened in a tab (${JSON.stringify(extraOptions)})`);
+
   let tab = yield BrowserTestUtils.openNewForegroundTab(gBrowser, "http://example.com/");
 
-  let extension = yield loadExtension({
+  let extension = yield loadExtension(Object.assign({}, extraOptions, {
     manifest: {
       applications: {gecko: {id: "tab_options@tests.mozilla.org"}},
       "options_ui": {
         "page": "options.html",
         "open_in_tab": true,
       },
     },
 
@@ -216,26 +230,28 @@ add_task(function* test_tab_options() {
         return browser.tabs.remove(tab.id);
       }).then(() => {
         browser.test.notifyPass("options-ui-tab");
       }).catch(error => {
         browser.test.log(`Error: ${error} :: ${error.stack}`);
         browser.test.notifyFail("options-ui-tab");
       });
     },
-  });
+  }));
 
   yield extension.awaitFinish("options-ui-tab");
   yield extension.unload();
 
   yield BrowserTestUtils.removeTab(tab);
 });
 
-add_task(function* test_options_no_manifest() {
-  let extension = yield loadExtension({
+add_tasks(function* test_options_no_manifest(extraOptions) {
+  info(`Test with no manifest key (${JSON.stringify(extraOptions)})`);
+
+  let extension = yield loadExtension(Object.assign({}, extraOptions, {
     manifest: {
       applications: {gecko: {id: "no_options@tests.mozilla.org"}},
     },
 
     background: function() {
       browser.test.log("Try to open options page when not specified in the manifest.");
 
       browser.runtime.openOptionsPage().then(
@@ -251,13 +267,13 @@ add_task(function* test_options_no_manif
         }
       ).then(() => {
         browser.test.notifyPass("options-no-manifest");
       }).catch(error => {
         browser.test.log(`Error: ${error} :: ${error.stack}`);
         browser.test.notifyFail("options-no-manifest");
       });
     },
-  });
+  }));
 
   yield extension.awaitFinish("options-no-manifest");
   yield extension.unload();
 });
--- a/testing/specialpowers/content/SpecialPowersObserverAPI.js
+++ b/testing/specialpowers/content/SpecialPowersObserverAPI.js
@@ -606,22 +606,30 @@ SpecialPowersObserverAPI.prototype = {
           }
         };
         Management.on("startup", startupListener);
 
         // Make sure the extension passes the packaging checks when
         // they're run on a bare archive rather than a running instance,
         // as the add-on manager runs them.
         let extensionData = new ExtensionData(extension.rootURI);
-        extensionData.readManifest().then(() => {
-          return extensionData.initAllLocales();
-        }).then(() => {
-          if (extensionData.errors.length) {
-            return Promise.reject("Extension contains packaging errors");
+        extensionData.readManifest().then(
+          () => {
+            return extensionData.initAllLocales().then(() => {
+              if (extensionData.errors.length) {
+                return Promise.reject("Extension contains packaging errors");
+              }
+            });
+          },
+          () => {
+            // readManifest() will throw if we're loading an embedded
+            // extension, so don't worry about locale errors in that
+            // case.
           }
+        ).then(() => {
           return extension.startup();
         }).then(() => {
           this._sendReply(aMessage, "SPExtensionMessage", {id, type: "extensionStarted", args: []});
         }).catch(e => {
           dump(`Extension startup failed: ${e}\n${e.stack}`);
           Management.off("startup", startupListener);
           this._sendReply(aMessage, "SPExtensionMessage", {id, type: "extensionFailed", args: []});
         });
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -1355,16 +1355,62 @@ this.Extension = class extends Extension
       let bgScript = uuidGen.generateUUID().number + ".js";
 
       provide(manifest, ["background", "scripts"], [bgScript], true);
       files[bgScript] = data.background;
     }
 
     provide(files, ["manifest.json"], manifest);
 
+    if (data.embedded) {
+      // Package this as a webextension embedded inside a legacy
+      // extension.
+
+      let xpiFiles = {
+        "install.rdf": `<?xml version="1.0" encoding="UTF-8"?>
+          <RDF xmlns="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
+               xmlns:em="http://www.mozilla.org/2004/em-rdf#">
+              <Description about="urn:mozilla:install-manifest"
+                  em:id="${manifest.applications.gecko.id}"
+                  em:name="${manifest.name}"
+                  em:type="2"
+                  em:version="${manifest.version}"
+                  em:description=""
+                  em:hasEmbeddedWebExtension="true"
+                  em:bootstrap="true">
+
+                  <!-- Firefox -->
+                  <em:targetApplication>
+                      <Description
+                          em:id="{ec8030f7-c20a-464f-9b0e-13a3a9e97384}"
+                          em:minVersion="51.0a1"
+                          em:maxVersion="*"/>
+                  </em:targetApplication>
+              </Description>
+          </RDF>
+        `,
+
+        "bootstrap.js": `
+          function install() {}
+          function uninstall() {}
+          function shutdown() {}
+
+          function startup(data) {
+            data.webExtension.startup();
+          }
+        `,
+      };
+
+      for (let [path, data] of Object.entries(files)) {
+        xpiFiles[`webextension/${path}`] = data;
+      }
+
+      files = xpiFiles;
+    }
+
     return this.generateZipFile(files);
   }
 
   static generateZipFile(files, baseName = "generated-extension.xpi") {
     let ZipWriter = Components.Constructor("@mozilla.org/zipwriter;1", "nsIZipWriter");
     let zipW = new ZipWriter();
 
     let file = FileUtils.getFile("TmpD", [baseName]);
--- a/toolkit/mozapps/extensions/internal/XPIProvider.jsm
+++ b/toolkit/mozapps/extensions/internal/XPIProvider.jsm
@@ -1014,17 +1014,17 @@ var loadManifestFromWebManifest = Task.a
  * @param  aUri
  *         The URI that the manifest is being read from
  * @param  aStream
  *         An open stream to read the RDF from
  * @return an AddonInternal object
  * @throws if the install manifest in the RDF stream is corrupt or could not
  *         be read
  */
-function loadManifestFromRDF(aUri, aStream) {
+let loadManifestFromRDF = Task.async(function*(aUri, aStream) {
   function getPropertyArray(aDs, aSource, aProperty) {
     let values = [];
     let targets = aDs.GetTargets(aSource, EM_R(aProperty), true);
     while (targets.hasMoreElements())
       values.push(getRDFValue(targets.getNext()));
 
     return values;
   }
@@ -1151,23 +1151,37 @@ function loadManifestFromRDF(aUri, aStre
   addon.strictCompatibility = !(addon.type in COMPATIBLE_BY_DEFAULT_TYPES) ||
                               getRDFProperty(ds, root, "strictCompatibility") == "true";
 
   // Only read these properties for extensions.
   if (addon.type == "extension") {
     addon.bootstrap = getRDFProperty(ds, root, "bootstrap") == "true";
     addon.multiprocessCompatible = getRDFProperty(ds, root, "multiprocessCompatible") == "true";
     addon.hasEmbeddedWebExtension = getRDFProperty(ds, root, "hasEmbeddedWebExtension") == "true";
+
     if (addon.optionsType &&
         addon.optionsType != AddonManager.OPTIONS_TYPE_DIALOG &&
         addon.optionsType != AddonManager.OPTIONS_TYPE_INLINE &&
         addon.optionsType != AddonManager.OPTIONS_TYPE_TAB &&
         addon.optionsType != AddonManager.OPTIONS_TYPE_INLINE_INFO) {
       throw new Error("Install manifest specifies unknown type: " + addon.optionsType);
     }
+
+    if (addon.hasEmbeddedWebExtension) {
+      let uri = NetUtil.newURI("webextension/manifest.json", null, aUri);
+      let embeddedAddon = yield loadManifestFromWebManifest(uri);
+      if (embeddedAddon.optionsURL) {
+        if (addon.optionsType || addon.optionsURL)
+          logger.warn(`Addon ${addon.id} specifies optionsType or optionsURL ` +
+                      `in both install.rdf and manifest.json`);
+
+        addon.optionsURL = embeddedAddon.optionsURL;
+        addon.optionsType = embeddedAddon.optionsType;
+      }
+    }
   }
   else {
     // Some add-on types are always restartless.
     if (RESTARTLESS_TYPES.has(addon.type)) {
       addon.bootstrap = true;
     }
 
     // Only extensions are allowed to provide an optionsURL, optionsType or aboutURL. For
@@ -1275,17 +1289,17 @@ function loadManifestFromRDF(aUri, aStre
     addon.updateURL = null;
     addon.updateKey = null;
   }
 
   // icons will be filled by the calling function
   addon.icons = {};
 
   return addon;
-}
+});
 
 function defineSyncGUID(aAddon) {
   // Define .syncGUID as a lazy property which is also settable
   Object.defineProperty(aAddon, "syncGUID", {
     get: () => {
       // Generate random GUID used for Sync.
       let guid = Cc["@mozilla.org/uuid-generator;1"]
           .getService(Ci.nsIUUIDGenerator)
@@ -1339,25 +1353,25 @@ var loadManifestFromDir = Task.async(fun
     let entries = aFile.directoryEntries.QueryInterface(Ci.nsIDirectoryEnumerator);
     let entry;
     while ((entry = entries.nextFile))
       size += getFileSize(entry);
     entries.close();
     return size;
   }
 
-  function loadFromRDF(aUri) {
+  function* loadFromRDF(aUri) {
     let fis = Cc["@mozilla.org/network/file-input-stream;1"].
               createInstance(Ci.nsIFileInputStream);
     fis.init(aUri.file, -1, -1, false);
     let bis = Cc["@mozilla.org/network/buffered-input-stream;1"].
               createInstance(Ci.nsIBufferedInputStream);
     bis.init(fis, 4096);
     try {
-      var addon = loadManifestFromRDF(aUri, bis);
+      var addon = yield loadManifestFromRDF(aUri, bis);
     } finally {
       bis.close();
       fis.close();
     }
 
     let iconFile = aDir.clone();
     iconFile.append("icon.png");
 
@@ -1395,17 +1409,17 @@ var loadManifestFromDir = Task.async(fun
     if (!addon.id) {
       if (aInstallLocation == TemporaryInstallLocation) {
         addon.id = generateTemporaryInstallID(aDir);
       } else {
         addon.id = aDir.leafName;
       }
     }
   } else {
-    addon = loadFromRDF(uri);
+    addon = yield loadFromRDF(uri);
   }
 
   addon._sourceBundle = aDir.clone();
   addon._installLocation = aInstallLocation;
   addon.size = getFileSize(aDir);
   addon.signedState = yield verifyDirSignedState(aDir, addon)
     .then(({signedState}) => signedState);
   addon.appDisabled = !isUsableAddon(addon);
@@ -1419,23 +1433,23 @@ var loadManifestFromDir = Task.async(fun
  * Loads an AddonInternal object from an nsIZipReader for an add-on.
  *
  * @param  aZipReader
  *         An open nsIZipReader for the add-on's files
  * @return an AddonInternal object
  * @throws if the XPI file does not contain a valid install manifest
  */
 var loadManifestFromZipReader = Task.async(function*(aZipReader, aInstallLocation) {
-  function loadFromRDF(aUri) {
+  function* loadFromRDF(aUri) {
     let zis = aZipReader.getInputStream(entry);
     let bis = Cc["@mozilla.org/network/buffered-input-stream;1"].
               createInstance(Ci.nsIBufferedInputStream);
     bis.init(zis, 4096);
     try {
-      var addon = loadManifestFromRDF(aUri, bis);
+      var addon = yield loadManifestFromRDF(aUri, bis);
     } finally {
       bis.close();
       zis.close();
     }
 
     if (aZipReader.hasEntry("icon.png")) {
       addon.icons[32] = "icon.png";
       addon.icons[48] = "icon.png";
@@ -1465,17 +1479,17 @@ var loadManifestFromZipReader = Task.asy
   }
 
   let uri = buildJarURI(aZipReader.file, entry);
 
   let isWebExtension = (entry == FILE_WEB_MANIFEST);
 
   let addon = isWebExtension ?
               yield loadManifestFromWebManifest(uri) :
-              loadFromRDF(uri);
+              yield loadFromRDF(uri);
 
   addon._sourceBundle = aZipReader.file;
   addon._installLocation = aInstallLocation;
 
   addon.size = 0;
   let entries = aZipReader.findEntries(null);
   while (entries.hasMore())
     addon.size += aZipReader.getEntry(entries.getNext()).realSize;
@@ -7319,17 +7333,17 @@ AddonWrapper.prototype = {
 
   get optionsURL() {
     if (!this.isActive) {
       return null;
     }
 
     let addon = addonFor(this);
     if (addon.optionsURL) {
-      if (this.isWebExtension) {
+      if (this.isWebExtension || this.hasEmbeddedWebExtension) {
         // The internal object's optionsURL property comes from the addons
         // DB and should be a relative URL.  However, extensions with
         // options pages installed before bug 1293721 was fixed got absolute
         // URLs in the addons db.  This code handles both cases.
         let base = ExtensionManagement.getURLForExtension(addon.id);
         if (!base) {
           return null;
         }