Bug 1252215 - [webext] LegacyExtensionsUtils JSM module and LegacyExtensionContext helper. r?kmag,aswan draft
authorLuca Greco <lgreco@mozilla.com>
Fri, 20 May 2016 17:15:43 +0200
changeset 388651 620728ef68b25d75b9c23e2849796c8e129f2827
parent 388650 98e3e1a81859a64dfa625d1d40d3d0043a46941c
child 388652 5f6fa87939c821e1f11ff658d89d39f227cf4ef4
push id23215
push userluca.greco@alcacoop.it
push dateSat, 16 Jul 2016 20:37:12 +0000
reviewerskmag, aswan
bugs1252215
milestone50.0a1
Bug 1252215 - [webext] LegacyExtensionsUtils JSM module and LegacyExtensionContext helper. r?kmag,aswan - this new module contains helpers to be able to receive connections originated from a webextension context from a legacy extension context (implemented by the `LegacyExtensionContext` class exported from this new jsm module) - two new mochitest-chrome test files ensures that the LegacyExtensionContext can receive a Port object and exchange messages with a background page and a content script (the content script test is in a different test file because it doesn't currently work on android, because it needs the browser.tabs API and the TabManager internal helper) MozReview-Commit-ID: DS1NTXk0fB6
toolkit/components/extensions/Extension.jsm
toolkit/components/extensions/LegacyExtensionsUtils.jsm
toolkit/components/extensions/moz.build
toolkit/components/extensions/test/mochitest/chrome.ini
toolkit/components/extensions/test/mochitest/test_chrome_ext_legacy_extension_context.html
toolkit/components/extensions/test/mochitest/test_chrome_ext_legacy_extension_context_contentscript.html
--- a/toolkit/components/extensions/Extension.jsm
+++ b/toolkit/components/extensions/Extension.jsm
@@ -1,15 +1,15 @@
 /* This Source Code Form is subject to the terms of the Mozilla Public
  * License, v. 2.0. If a copy of the MPL was not distributed with this
  * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
 
 "use strict";
 
-this.EXPORTED_SYMBOLS = ["Extension", "ExtensionData"];
+this.EXPORTED_SYMBOLS = ["Extension", "ExtensionData", "Management"];
 
 /* globals Extension ExtensionData */
 
 /*
  * This file is the main entry point for extensions. When an extension
  * loads, its bootstrap.js file creates a Extension instance
  * and calls .startup() on it. It calls .shutdown() when the extension
  * unloads. Extension manages any extension-specific state in
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/LegacyExtensionsUtils.jsm
@@ -0,0 +1,149 @@
+/* This Source Code Form is subject to the terms of the Mozilla Public
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
+ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
+
+"use strict";
+
+this.EXPORTED_SYMBOLS = ["LegacyExtensionsUtils"];
+
+/* exported LegacyExtensionsUtils */
+
+/**
+ * This file exports helpers for Legacy Extensions that want to embed a webextensions
+ * and exchange messages with the embedded WebExtension.
+ */
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+// Lazy imports.
+XPCOMUtils.defineLazyModuleGetter(this, "Services",
+                                  "resource://gre/modules/Services.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Extension",
+                                  "resource://gre/modules/Extension.jsm");
+XPCOMUtils.defineLazyModuleGetter(this, "Management",
+                                  "resource://gre/modules/Extension.jsm");
+
+// Import Messenger and BaseContext from ExtensionUtils.
+Cu.import("resource://gre/modules/ExtensionUtils.jsm");
+
+const {Messenger, BaseContext} = ExtensionUtils;
+
+/**
+ * Instances created from this class provide to a legacy extension
+ * a simple API to exchange messages with a webextension.
+ */
+class LegacyExtensionContext extends BaseContext {
+  /**
+   * Create a new LegacyExtensionContext given a target Extension instance and an optional
+   * url (which can be used to recognize the messages of container context).
+   *
+   * @param {Extension} targetExtension
+   *   The webextension instance associated with this context. This will be the
+   *   instance of the newly created embedded webextension when this class is
+   *   used through the EmbeddedWebExtensionsUtils.
+   * @param {Object} [optionalParams]
+   *   An object with the following properties:
+   * @param {string}  [optionalParams.senderURL]
+   *   An URL to mark the messages sent from this context
+   *   (e.g. EmbeddedWebExtension sets it to the base url of the container addon).
+   */
+  constructor(targetExtension, optionalParams = {}) {
+    let {senderURL} = optionalParams;
+
+    if (targetExtension) {
+      if (!(targetExtension instanceof Extension)) {
+        throw new Error("targetExtension is not an Extension class instance");
+      }
+    } else {
+      throw new Error("targetExtension parameter is mandatory");
+    }
+
+    super(targetExtension.id);
+
+    // Associate this context with the targetExtension object.
+    this.extension = targetExtension;
+
+    let sender = {
+      id: this.extension.uuid,
+      url: senderURL,
+    };
+
+    // The filter extensionId is always set to targetAddonId to be able to receive
+    // connections from the target webextension addon.
+    let filter = {extensionId: targetExtension.id};
+
+    // The empty getSender is needed so that the messaging works even on
+    // platforms where TabManager is not currently supported (e.g. Android).
+    let delegate = {
+      getSender() {},
+    };
+
+    // This page-load event is handled synchronously by ext-tabs.js
+    // and will put a getSender method in the delegate object, that will
+    // be able to resolve the tab object when the connection is originated
+    // from a tab (e.g. a content script).
+    Management.emit("page-load", this, {type: this.type}, sender, delegate);
+
+    // Legacy Extensions (xul overlays, bootstrap restartless and Addon SDK)
+    // runs with a systemPrincipal.
+    this.addonPrincipal = Services.scriptSecurityManager.getSystemPrincipal();
+
+    this.messenger = new Messenger(this, [Services.mm, Services.ppmm],
+                                       sender, filter, delegate);
+
+    this._cloneScope = Cu.Sandbox(this.addonPrincipal, {});
+    Cu.setSandboxMetadata(this._cloneScope, {addonId: targetExtension.id});
+
+    this.api = {
+      onConnect: this.messenger.onConnect("runtime.onConnect"),
+    };
+  }
+
+  /**
+   * Signal that the context is shutting down and call the unload method.
+   * Called when the extension shuts down.
+   */
+  shutdown() {
+    this.unload();
+  }
+
+  /**
+   * This method is called when the extension shuts down or is unloaded.
+   */
+  unload() {
+    if (this.unloaded) {
+      return;
+    }
+
+    super.unload();
+    Cu.nukeSandbox(this._cloneScope);
+  }
+
+  /**
+   * Return the context cloneScope.
+   */
+  get cloneScope() {
+    return this._cloneScope;
+  }
+
+  /**
+   * The custom type of this context (that will always be "legacy_extension").
+   */
+  get type() {
+    return "legacy_extension";
+  }
+
+  /**
+   * The principal associated to the context (which is a system principal as the other
+   * code running in a legacy extension).
+   */
+  get principal() {
+    return this.addonPrincipal;
+  }
+}
+
+this.LegacyExtensionsUtils = {
+  LegacyExtensionContext,
+};
--- a/toolkit/components/extensions/moz.build
+++ b/toolkit/components/extensions/moz.build
@@ -5,16 +5,17 @@
 # file, You can obtain one at http://mozilla.org/MPL/2.0/.
 
 EXTRA_JS_MODULES += [
     'Extension.jsm',
     'ExtensionContent.jsm',
     'ExtensionManagement.jsm',
     'ExtensionStorage.jsm',
     'ExtensionUtils.jsm',
+    'LegacyExtensionsUtils.jsm',
     'MessageChannel.jsm',
     'NativeMessaging.jsm',
     'Schemas.jsm',
 ]
 
 DIRS += ['schemas']
 
 JAR_MANIFESTS += ['jar.mn']
--- a/toolkit/components/extensions/test/mochitest/chrome.ini
+++ b/toolkit/components/extensions/test/mochitest/chrome.ini
@@ -9,16 +9,19 @@ support-files =
 [test_chrome_ext_background_debug_global.html]
 skip-if = (os == 'android') # android doesn't have devtools
 [test_chrome_ext_background_page.html]
 skip-if = (toolkit == 'android') # android doesn't have devtools
 [test_chrome_ext_downloads_download.html]
 [test_chrome_ext_downloads_misc.html]
 [test_chrome_ext_downloads_search.html]
 [test_chrome_ext_eventpage_warning.html]
+[test_chrome_ext_legacy_extension_context.html]
+[test_chrome_ext_legacy_extension_context_contentscript.html]
+skip-if = (os == 'android') # User browser.tabs and TabManager. Bug 1258975 on android.
 [test_chrome_ext_native_messaging.html]
 skip-if = os == "android"  # native messaging is not supported on android
 [test_chrome_ext_contentscript_unrecognizedprop_warning.html]
 skip-if = (os == 'android') # browser.tabs is undefined. Bug 1258975 on android.
 [test_chrome_ext_webnavigation_resolved_urls.html]
 skip-if = (os == 'android') # browser.tabs is undefined. Bug 1258975 on android.
 [test_chrome_native_messaging_paths.html]
 skip-if = os != "mac" && os != "linux"
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_legacy_extension_context.html
@@ -0,0 +1,137 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>Test for simple WebExtension</title>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {Management} = SpecialPowers.Cu.import("resource://gre/modules/Extension.jsm");
+const {
+  LegacyExtensionsUtils: {
+    LegacyExtensionContext,
+  },
+} = SpecialPowers.Cu.import("resource://gre/modules/LegacyExtensionsUtils.jsm");
+
+/**
+ * This test case ensures that LegacyExtensionContext instances:
+ *  - expose the expected API object and can join the messaging
+ *    of a webextension given its addon id
+ *  - the exposed API object can receive a port related to a `runtime.connect`
+ *    Port created in the webextension's background page
+ *  - the received Port instance can exchange messages with the background page
+ *  - the received Port receive a disconnect event when the webextension is
+ *    shutting down
+ */
+add_task(function* test_legacy_extension_context() {
+  function backgroundScript() {
+    let bgURL = window.location.href;
+
+    let extensionInfo = {
+      bgURL,
+      // Extract the assigned uuid from the background page url.
+      uuid: bgURL.match("://(.+?)/")[1],
+    };
+
+    browser.test.sendMessage("webextension-ready", extensionInfo);
+
+    browser.test.onMessage.addListener(msg => {
+      if (msg == "do-connect") {
+        let port = browser.runtime.connect();
+
+        port.onMessage.addListener(msg => {
+          browser.test.assertEq("legacy_extension -> webextension", msg,
+                                "Got the expected message from the LegacyExtensionContext");
+          port.postMessage("webextension -> legacy_extension");
+        });
+      }
+    });
+  }
+
+  let extensionData = {
+    background: "new " + backgroundScript,
+  };
+
+  let waitForExtensionInstance = new Promise((resolve, reject) => {
+    let startupListener = (event, extension) => {
+      Management.off("startup", startupListener);
+      resolve(extension);
+    };
+    Management.on("startup", startupListener);
+  });
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+  yield extension.startup();
+  let extensionInfo = yield extension.awaitMessage("webextension-ready");
+  let extensionInstance = yield waitForExtensionInstance;
+
+  // Connect to the target extension.id as an external context
+  // using the given custom sender info.
+  let legacyContext = new LegacyExtensionContext(extensionInstance, {
+    sourceContextURL: "about:blank",
+  });
+
+  ok(legacyContext, "Got a LegacyExtensionContext instance");
+
+  is(legacyContext.type, "legacy_extension",
+     "LegacyExtensionContext instance has the expected type");
+
+  ok(legacyContext.api, "Got the API object");
+
+  let waitConnectPort = new Promise(resolve => {
+    legacyContext.api.onConnect.addListener(port => {
+      resolve(port);
+    });
+  });
+
+  extension.sendMessage("do-connect");
+
+  let port = yield waitConnectPort;
+
+  ok(port, "Got the Port API object");
+  ok(port.sender, "The port has a sender property");
+
+  if (port.sender) {
+    is(port.sender.id, extensionInfo.uuid,
+       "The port sender has the expected id property");
+    is(port.sender.url, extensionInfo.bgURL,
+       "The port sender has the expected url property");
+  }
+
+  let waitPortMessage = new Promise(resolve => {
+    port.onMessage.addListener((msg) => {
+      resolve(msg);
+    });
+  });
+
+  port.postMessage("legacy_extension -> webextension");
+
+  let msg = yield waitPortMessage;
+
+  is(msg, "webextension -> legacy_extension",
+     "LegacyExtensionContext received the expected message from the webextension");
+
+  let waitForDisconnect = new Promise(resolve => {
+    port.onDisconnect.addListener(resolve);
+  });
+
+  yield extension.unload();
+
+  yield waitForDisconnect;
+
+  info("Got the disconnect event on unload");
+
+  legacyContext.shutdown();
+});
+
+</script>
+
+</body>
+</html>
new file mode 100644
--- /dev/null
+++ b/toolkit/components/extensions/test/mochitest/test_chrome_ext_legacy_extension_context_contentscript.html
@@ -0,0 +1,165 @@
+<!DOCTYPE HTML>
+<html>
+<head>
+  <title>Test for simple WebExtension</title>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/SpawnTask.js"></script>
+  <script src="chrome://mochikit/content/tests/SimpleTest/ExtensionTestUtils.js"></script>
+  <script type="text/javascript" src="head.js"></script>
+  <link rel="stylesheet" href="chrome://mochikit/contents/tests/SimpleTest/test.css"/>
+</head>
+<body>
+
+<script type="text/javascript">
+"use strict";
+
+const {Management} = SpecialPowers.Cu.import("resource://gre/modules/Extension.jsm");
+const {
+  LegacyExtensionsUtils: {
+    LegacyExtensionContext,
+  },
+} = SpecialPowers.Cu.import("resource://gre/modules/LegacyExtensionsUtils.jsm");
+
+
+/**
+ * This test case ensures that the LegacyExtensionContext can receive a connection
+ * from a content script and that the received port contains the expected sender
+ * tab info.
+ */
+add_task(function* test_legacy_extension_context_contentscript_connection() {
+  function backgroundScript() {
+    // Extract the assigned uuid from the background page url and send it
+    // in a test message.
+    let uuid = String(window.location).match("://(.*)/")[1];
+
+    browser.test.onMessage.addListener(msg => {
+      if (msg == "open-test-tab") {
+        browser.tabs.create({url: "http://example.com/"})
+          .then(tab => browser.test.sendMessage("get-expected-sender-info", {
+            uuid, tab,
+          }));
+      } else if (msg == "close-current-tab") {
+        browser.tabs.query({active: true})
+          .then(tabs => browser.tabs.remove(tabs[0].id))
+          .then(() => browser.test.notifyPass("current-tab-closed"))
+          .catch(() => browser.test.notifyFail("current-tab-closed"));
+      }
+    });
+
+    browser.test.sendMessage("ready");
+  }
+
+  function contentScript() {
+    let port = browser.runtime.connect();
+
+    port.onMessage.addListener(msg => {
+      browser.test.assertEq("legacy_extension -> webextension", msg,
+                            "Got the expected message from the LegacyExtensionContext");
+      port.postMessage("webextension -> legacy_extension");
+    });
+  }
+
+  let extensionData = {
+    background: `new ${backgroundScript}`,
+    manifest: {
+      "content_scripts": [
+        {
+          "matches": ["http://example.com/*"],
+          "js": ["content-script.js"],
+          "run_at": "document_idle",
+        },
+      ],
+    },
+    files: {
+      "content-script.js": `new ${contentScript}`,
+    },
+  };
+
+  let waitForExtensionInstance = new Promise((resolve, reject) => {
+    let startupListener = (event, extension) => {
+      Management.off("startup", startupListener);
+      resolve(extension);
+    };
+    Management.on("startup", startupListener);
+  });
+
+  let extension = ExtensionTestUtils.loadExtension(extensionData);
+
+  yield extension.startup();
+
+  let extensionInstance = yield waitForExtensionInstance;
+
+  // Connect to the target extension.id as an external context
+  // using the given custom sender info.
+  let legacyContext = new LegacyExtensionContext(extensionInstance, {
+    sourceContextURL: "about:blank",
+  });
+
+  ok(legacyContext, "Got a LegacyExtensionContext instance");
+
+  is(legacyContext.type, "legacy_extension",
+     "LegacyExtensionContext instance has the expected type");
+
+  ok(legacyContext.api, "Got the API object");
+
+  let waitConnectPort = new Promise(resolve => {
+    legacyContext.api.onConnect.addListener(port => {
+      resolve(port);
+    });
+  });
+
+  yield extension.awaitMessage("ready");
+
+  extension.sendMessage("open-test-tab");
+
+  let {uuid, tab} = yield extension.awaitMessage("get-expected-sender-info");
+
+  let port = yield waitConnectPort;
+
+  ok(port, "Got the Port API object");
+  ok(port.sender, "The port has a sender property");
+
+  if (port.sender) {
+    is(port.sender.id, uuid, "The port sender has an id property");
+    is(port.sender.url, "http://example.com/", "The port sender has the expected url property");
+    ok(port.sender.tab, "The port sender has a tab property");
+
+    if (port.sender.tab) {
+      is(port.sender.tab.id, tab.id, "The port sender has the expected tab.id");
+    }
+  }
+
+  let waitPortMessage = new Promise(resolve => {
+    port.onMessage.addListener((msg) => {
+      resolve(msg);
+    });
+  });
+
+  port.postMessage("legacy_extension -> webextension");
+
+  let msg = yield waitPortMessage;
+
+  is(msg, "webextension -> legacy_extension",
+     "LegacyExtensionContext received the expected message from the webextension");
+
+  let waitForDisconnect = new Promise(resolve => {
+    port.onDisconnect.addListener(resolve);
+  });
+
+  extension.sendMessage("close-current-tab");
+
+  yield waitForDisconnect;
+
+  info("Got the disconnect event on tab closed");
+
+  yield extension.awaitFinish("current-tab-closed");
+
+  yield extension.unload();
+
+  legacyContext.shutdown();
+});
+
+</script>
+
+</body>
+</html>