diff --git a/browser/components/extensions/test/browser/browser.ini b/browser/components/extensions/test/browser/browser.ini index 404a4207f037..44c23f33929e 100644 --- a/browser/components/extensions/test/browser/browser.ini +++ b/browser/components/extensions/test/browser/browser.ini @@ -40,6 +40,7 @@ tags = webextensions [browser_ext_getViews.js] [browser_ext_incognito_popup.js] [browser_ext_lastError.js] +[browser_ext_legacy_extension_context_contentscript.js] [browser_ext_optionsPage_privileges.js] [browser_ext_pageAction_context.js] [browser_ext_pageAction_popup.js] diff --git a/browser/components/extensions/test/browser/browser_ext_legacy_extension_context_contentscript.js b/browser/components/extensions/test/browser/browser_ext_legacy_extension_context_contentscript.js new file mode 100644 index 000000000000..70a8cf3ba5f1 --- /dev/null +++ b/browser/components/extensions/test/browser/browser_ext_legacy_extension_context_contentscript.js @@ -0,0 +1,171 @@ +"use strict"; + +const { + LegacyExtensionContext, +} = Cu.import("resource://gre/modules/LegacyExtensionsUtils.jsm", {}); + +function promiseAddonStartup(extension) { + const {Management} = Cu.import("resource://gre/modules/Extension.jsm", {}); + + return new Promise((resolve) => { + let listener = (evt, extensionInstance) => { + Management.off("startup", listener); + resolve(extensionInstance); + }; + Management.on("startup", listener); + }); +} + +/** + * 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 = window.location.hostname; + + 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.sendMessage("current-tab-closed", true)) + .catch(() => browser.test.sendMessage("current-tab-closed", false)); + } + }); + + browser.test.sendMessage("ready"); + } + + function contentScript() { + browser.runtime.sendMessage("webextension -> legacy_extension message", (reply) => { + browser.test.assertEq("legacy_extension -> webextension reply", reply, + "Got the expected reply from the LegacyExtensionContext"); + browser.test.sendMessage("got-reply-message"); + }); + + let port = browser.runtime.connect(); + + port.onMessage.addListener(msg => { + browser.test.assertEq("legacy_extension -> webextension port message", msg, + "Got the expected message from the LegacyExtensionContext"); + port.postMessage("webextension -> legacy_extension port message"); + }); + } + + let extensionData = { + background: `new ${backgroundScript}`, + manifest: { + content_scripts: [ + { + matches: ["http://example.com/*"], + js: ["content-script.js"], + }, + ], + }, + files: { + "content-script.js": `new ${contentScript}`, + }, + }; + + let extension = ExtensionTestUtils.loadExtension(extensionData); + + let waitForExtensionReady = extension.awaitMessage("ready"); + + let waitForExtensionInstance = promiseAddonStartup(extension); + + 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); + + let waitConnectPort = new Promise(resolve => { + let {browser} = legacyContext.api; + browser.runtime.onConnect.addListener(port => { + resolve(port); + }); + }); + + let waitMessage = new Promise(resolve => { + let {browser} = legacyContext.api; + browser.runtime.onMessage.addListener((singleMsg, msgSender, sendReply) => { + sendReply("legacy_extension -> webextension reply"); + resolve({singleMsg, msgSender}); + }); + }); + + is(legacyContext.type, "legacy_extension", + "LegacyExtensionContext instance has the expected type"); + + ok(legacyContext.api, "Got the API object"); + + yield waitForExtensionReady; + + extension.sendMessage("open-test-tab"); + + let {uuid, tab} = yield extension.awaitMessage("get-expected-sender-info"); + + let {singleMsg, msgSender} = yield waitMessage; + is(singleMsg, "webextension -> legacy_extension message", + "Got the expected message"); + ok(msgSender, "Got a message sender object"); + + is(msgSender.id, uuid, "The sender has the expected id property"); + is(msgSender.url, "http://example.com/", "The sender has the expected url property"); + ok(msgSender.tab, "The sender has a tab property"); + is(msgSender.tab.id, tab.id, "The port sender has the expected tab.id"); + + // Wait confirmation that the reply has been received. + yield extension.awaitMessage("got-reply-message"); + + let port = yield waitConnectPort; + + ok(port, "Got the Port API object"); + ok(port.sender, "The port has a sender property"); + + 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"); + 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 port message"); + + let msg = yield waitPortMessage; + + is(msg, "webextension -> legacy_extension port message", + "LegacyExtensionContext received the expected message from the webextension"); + + let waitForDisconnect = new Promise(resolve => { + port.onDisconnect.addListener(resolve); + }); + + let waitForTestDone = extension.awaitMessage("current-tab-closed"); + + extension.sendMessage("close-current-tab"); + + yield waitForDisconnect; + + info("Got the disconnect event on tab closed"); + + let success = yield waitForTestDone; + + ok(success, "Test completed successfully"); + + yield extension.unload(); +}); diff --git a/toolkit/components/extensions/Extension.jsm b/toolkit/components/extensions/Extension.jsm index 29f837756cc8..72a79c477de5 100644 --- a/toolkit/components/extensions/Extension.jsm +++ b/toolkit/components/extensions/Extension.jsm @@ -4,7 +4,7 @@ "use strict"; -this.EXPORTED_SYMBOLS = ["Extension", "ExtensionData"]; +this.EXPORTED_SYMBOLS = ["Extension", "ExtensionData", "ExtensionContext"]; /* globals Extension ExtensionData */ diff --git a/toolkit/components/extensions/LegacyExtensionsUtils.jsm b/toolkit/components/extensions/LegacyExtensionsUtils.jsm new file mode 100644 index 000000000000..c04f5ca3893a --- /dev/null +++ b/toolkit/components/extensions/LegacyExtensionsUtils.jsm @@ -0,0 +1,104 @@ +/* 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, LegacyExtensionContext */ + +/** + * 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, "Extension", + "resource://gre/modules/Extension.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "ExtensionContext", + "resource://gre/modules/Extension.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", + "resource://gre/modules/NetUtil.jsm"); +XPCOMUtils.defineLazyModuleGetter(this, "Services", + "resource://gre/modules/Services.jsm"); + +/** + * Instances created from this class provide to a legacy extension + * a simple API to exchange messages with a webextension. + */ +var LegacyExtensionContext = class extends ExtensionContext { + /** + * 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.url] + * 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 {url} = optionalParams; + + super(targetExtension, { + contentWindow: null, + uri: NetUtil.newURI(url || "about:blank"), + type: "legacy_extension", + }); + + // Legacy Extensions (xul overlays, bootstrap restartless and Addon SDK) + // runs with a systemPrincipal. + let addonPrincipal = Services.scriptSecurityManager.getSystemPrincipal(); + Object.defineProperty( + this, "principal", + {value: addonPrincipal, enumerable: true, configurable: true} + ); + + let cloneScope = Cu.Sandbox(this.principal, {}); + Cu.setSandboxMetadata(cloneScope, {addonId: targetExtension.id}); + Object.defineProperty( + this, "cloneScope", + {value: cloneScope, enumerable: true, configurable: true, writable: true} + ); + + this.api = { + browser: { + runtime: { + onConnect: this.messenger.onConnect("runtime.onConnect"), + onMessage: this.messenger.onMessage("runtime.onMessage"), + }, + }, + }; + } + + /** + * This method is called when the extension shuts down or is unloaded, + * and it nukes the cloneScope sandbox, if any. + */ + unload() { + if (this.unloaded) { + throw new Error("Error trying to unload LegacyExtensionContext twice."); + } + super.unload(); + Cu.nukeSandbox(this.cloneScope); + this.cloneScope = null; + } + + /** + * The LegacyExtensionContext is not a visible context. + */ + get externallyVisible() { + return false; + } +}; + +this.LegacyExtensionsUtils = {}; diff --git a/toolkit/components/extensions/moz.build b/toolkit/components/extensions/moz.build index 70b490ff9462..2d9d72170108 100644 --- a/toolkit/components/extensions/moz.build +++ b/toolkit/components/extensions/moz.build @@ -11,6 +11,7 @@ EXTRA_JS_MODULES += [ 'ExtensionManagement.jsm', 'ExtensionStorage.jsm', 'ExtensionUtils.jsm', + 'LegacyExtensionsUtils.jsm', 'MessageChannel.jsm', 'NativeMessaging.jsm', 'Schemas.jsm', diff --git a/toolkit/components/extensions/test/xpcshell/test_ext_legacy_extension_context.js b/toolkit/components/extensions/test/xpcshell/test_ext_legacy_extension_context.js new file mode 100644 index 000000000000..941c168d15f0 --- /dev/null +++ b/toolkit/components/extensions/test/xpcshell/test_ext_legacy_extension_context.js @@ -0,0 +1,156 @@ +"use strict"; + +/* globals browser */ + +Cu.import("resource://gre/modules/Extension.jsm"); +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); + +const {LegacyExtensionContext} = 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: window.location.hostname, + }; + + browser.test.sendMessage("webextension-ready", extensionInfo); + + browser.test.onMessage.addListener(msg => { + if (msg == "do-send-message") { + browser.runtime.sendMessage("webextension -> legacy_extension message").then(reply => { + browser.test.assertEq("legacy_extension -> webextension reply", reply, + "Got the expected message from the LegacyExtensionContext"); + browser.test.sendMessage("got-reply-message"); + }); + } else if (msg == "do-connect") { + let port = browser.runtime.connect(); + + port.onMessage.addListener(msg => { + browser.test.assertEq("legacy_extension -> webextension port message", msg, + "Got the expected message from the LegacyExtensionContext"); + port.postMessage("webextension -> legacy_extension port message"); + }); + } + }); + } + + let extensionData = { + background: "new " + backgroundScript, + }; + + let extension = Extension.generate(extensionData); + + let waitForExtensionInfo = new Promise((resolve, reject) => { + extension.on("test-message", function testMessageListener(kind, msg, ...args) { + if (msg != "webextension-ready") { + reject(new Error(`Got an unexpected test-message: ${msg}`)); + } else { + extension.off("test-message", testMessageListener); + resolve(args[0]); + } + }); + }); + + yield extension.startup(); + + let extensionInfo = yield waitForExtensionInfo; + + // Connect to the target extension.id as an external context + // using the given custom sender info. + let legacyContext = new LegacyExtensionContext(extension); + + equal(legacyContext.type, "legacy_extension", + "LegacyExtensionContext instance has the expected type"); + + ok(legacyContext.api, "Got the expected API object"); + ok(legacyContext.api.browser, "Got the expected browser property"); + + let waitMessage = new Promise(resolve => { + const {browser} = legacyContext.api; + browser.runtime.onMessage.addListener((singleMsg, msgSender) => { + resolve({singleMsg, msgSender}); + + // Send a reply to the sender. + return Promise.resolve("legacy_extension -> webextension reply"); + }); + }); + + extension.testMessage("do-send-message"); + + let {singleMsg, msgSender} = yield waitMessage; + equal(singleMsg, "webextension -> legacy_extension message", + "Got the expected message"); + ok(msgSender, "Got a message sender object"); + + equal(msgSender.id, extensionInfo.uuid, "The sender has the expected id property"); + equal(msgSender.url, extensionInfo.bgURL, "The sender has the expected url property"); + + // Wait confirmation that the reply has been received. + yield new Promise((resolve, reject) => { + extension.on("test-message", function testMessageListener(kind, msg, ...args) { + if (msg != "got-reply-message") { + reject(new Error(`Got an unexpected test-message: ${msg}`)); + } else { + extension.off("test-message", testMessageListener); + resolve(); + } + }); + }); + + let waitConnectPort = new Promise(resolve => { + let {browser} = legacyContext.api; + browser.runtime.onConnect.addListener(port => { + resolve(port); + }); + }); + + extension.testMessage("do-connect"); + + let port = yield waitConnectPort; + + ok(port, "Got the Port API object"); + ok(port.sender, "The port has a sender property"); + equal(port.sender.id, extensionInfo.uuid, + "The port sender has the expected id property"); + equal(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 port message"); + + let msg = yield waitPortMessage; + + equal(msg, "webextension -> legacy_extension port message", + "LegacyExtensionContext received the expected message from the webextension"); + + let waitForDisconnect = new Promise(resolve => { + port.onDisconnect.addListener(resolve); + }); + + extension.shutdown(); + + yield waitForDisconnect; + + do_print("Got the disconnect event on unload"); + + legacyContext.shutdown(); +}); diff --git a/toolkit/components/extensions/test/xpcshell/xpcshell.ini b/toolkit/components/extensions/test/xpcshell/xpcshell.ini index 06a48dd6f6de..dcbe164db8a3 100644 --- a/toolkit/components/extensions/test/xpcshell/xpcshell.ini +++ b/toolkit/components/extensions/test/xpcshell/xpcshell.ini @@ -54,6 +54,7 @@ skip-if = release_build [test_ext_simple.js] [test_ext_storage.js] [test_getAPILevelForWindow.js] +[test_ext_legacy_extension_context.js] [test_locale_converter.js] [test_locale_data.js] [test_native_messaging.js]