Created
August 4, 2016 17:05
-
-
Save rpl/e654ddd5a19bb1876182b67622f07cb5 to your computer and use it in GitHub Desktop.
Hybrid Addon Helpers - Proposal B
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /* 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, "ExtensionContext", | |
| "resource://gre/modules/Extension.jsm"); | |
| XPCOMUtils.defineLazyModuleGetter(this, "NetUtil", | |
| "resource://gre/modules/NetUtil.jsm"); | |
| XPCOMUtils.defineLazyModuleGetter(this, "getExtensionUUID", | |
| "resource://gre/modules/Extension.jsm"); | |
| // Map of the existent EmbeddedExtensions instances by addon id. | |
| let EmbeddedExtensionsMap = new Map(); | |
| function createAddonBroadcastWindow(addonId) { | |
| const extensionUUID = getExtensionUUID(addonId); | |
| const blankURI = Services.io | |
| .newURI(`moz-extension://${extensionUUID}/_blank.html`, null, null); | |
| // Create a principal (security context) for the generalized origin given | |
| // by the extension's special URL and its `addonId`. | |
| const principal = Services.scriptSecurityManager | |
| .createCodebasePrincipal(blankURI, {addonId}); | |
| // Create a hidden window. | |
| const chromeWebNav = Services.appShell.createWindowlessBrowser(true); | |
| const docShell = chromeWebNav | |
| .QueryInterface(Ci.nsIInterfaceRequestor) | |
| .getInterface(Ci.nsIDocShell); | |
| docShell.createAboutBlankContentViewer(principal); | |
| const BroadcastChannel = docShell.contentViewer.DOMDocument.defaultView.BroadcastChannel; | |
| // Callers need to keep the pointer to the chromeWebNav, otherwise the window's | |
| // BroadcastChannel will get garbage collected. | |
| return { | |
| extensionUUID, | |
| chromeWebNav, | |
| BroadcastChannel, | |
| }; | |
| } | |
| /** | |
| * Create a new EmbeddedExtension given the add-on id and the base resource URI of the | |
| * container add-on (the webextension resources will be loaded from the "webextension/" | |
| * subdir of the base resource URI for the legacy extension add-on). | |
| * | |
| * @param {Object} containerAddonParams | |
| * An object with the following properties: | |
| * @param {string} containerAddonParams.addonId | |
| * The Add-on id of the Legacy Extension which will contain the embedded webextension. | |
| * @param {nsIURI} containerAddonParams.resourceURI | |
| * The nsIURI of the Legacy Extension container add-on. | |
| */ | |
| function EmbeddedExtension({addonId, resourceURI}) { | |
| let { | |
| extensionUUID, | |
| chromeWebNav, | |
| BroadcastChannel, | |
| } = createAddonBroadcastWindow(addonId); | |
| let embeddedExtensionURI = Services.io.newURI("webextension/", null, resourceURI); | |
| let broadcastChannelsByName = new Map(); | |
| // Setup status flag. | |
| let started = false; | |
| let destroyed = false; | |
| // Pending startup promise. | |
| let pendingStartup; | |
| // Reference to the embedded webextension. | |
| let extension; | |
| // Keep track of the embedded extension instance. | |
| EmbeddedExtensionsMap.set(addonId, this); | |
| const createExtensionBroadcastChannel = (name) => { | |
| if (!name) { | |
| throw Error("ExtensionBroadcastChannel name is mandatory"); | |
| } | |
| let broadcastChannel = broadcastChannelsByName.get(name); | |
| if (!broadcastChannel) { | |
| broadcastChannel = new BroadcastChannel(name); | |
| broadcastChannelsByName.set(name, broadcastChannel); | |
| } | |
| return broadcastChannel; | |
| }; | |
| const closeExtensionBroadcastChannels = () => { | |
| for (let broadcastChannel of broadcastChannelsByName.values()) { | |
| broadcastChannel.close(); | |
| } | |
| broadcastChannelsByName.clear(); | |
| }; | |
| const startup = () => { | |
| if (started) { | |
| return Promise.resolve(); | |
| } | |
| let resolveStartup, rejectStartup; | |
| pendingStartup = new Promise((resolve, reject) => { | |
| resolveStartup = resolve; | |
| rejectStartup = reject; | |
| }); | |
| // This is the instance of the WebExtension embedded in the hybrid add-on. | |
| extension = new Extension({ | |
| id: addonId, | |
| resourceURI: embeddedExtensionURI, | |
| }); | |
| if (!extension) { | |
| const error = new Error("Failed to create the embedded WebExtension"); | |
| pendingStartup = null; | |
| rejectStartup(error); | |
| return Promise.reject(error); | |
| } | |
| // Destroy the LegacyExtensionContext cloneScope when | |
| // the embedded webextensions is unloaded. | |
| extension.callOnClose({ | |
| close: () => { | |
| // TODO: should we alert the container addon here? | |
| }, | |
| }); | |
| return extension.startup() | |
| .then(() => { | |
| // Resolve the startup promise and reset the startupError. | |
| started = true; | |
| pendingStartup = null; | |
| resolveStartup(); | |
| }) | |
| .catch((err) => { | |
| started = false; | |
| pendingStartup = null; | |
| // Report an error if the embedded webextension fails during | |
| // its startup and reject the startup promise | |
| // (with the error object as parameter). | |
| // Adjust the error message to nicely handle both the exception and | |
| // the validation errors scenarios. | |
| let msg; | |
| if (err.errors) { | |
| msg = JSON.stringify(err.errors, null, 2); | |
| } else { | |
| msg = err.message; | |
| } | |
| let startupError = `Embedded WebExtension startup failed for "${addonId}": ${msg}`; | |
| Cu.reportError(startupError); | |
| rejectStartup(err); | |
| // Raise the error to the container addon | |
| throw err; | |
| }); | |
| }; | |
| const shutdown = () => { | |
| return new Promise((resolve, reject) => { | |
| if (!started) { | |
| return resolve(); | |
| } | |
| if (pendingStartup) { | |
| // Run the embedded extension shutdown once the startup is completed. | |
| return pendingStartup.then(() => { | |
| extension.shutdown(); | |
| resolve(); | |
| }, reject); | |
| } else { | |
| // Run shutdown now if the embedded webextension has been started | |
| extension.shutdown(); | |
| return resolve(); | |
| } | |
| }); | |
| }; | |
| const destroy = () => { | |
| if (destroyed) { | |
| return; | |
| } | |
| shutdown().then(() => { | |
| closeExtensionBroadcastChannels(); | |
| chromeWebNav.loadURI("about:blank", 0, null, null, null); | |
| chromeWebNav.close(); | |
| chromeWebNav = null; | |
| // Remove this instance from the tracked embedded extensions, even if it has never | |
| // been fully started. | |
| EmbeddedExtensionsMap.delete(addonId); | |
| destroyed = true; | |
| }); | |
| }; | |
| const basePropConfig = { | |
| configurable: false, | |
| writable: false, | |
| enumerable: true, | |
| }; | |
| Object.defineProperties(this, { | |
| addonId: Object.assign({value: addonId}, basePropConfig), | |
| extensionUUID: Object.assign({value: extensionUUID}, basePropConfig), | |
| /** | |
| * Shutdown the embeddedExtension instance and destroy all the created BroadcastChannel instances. | |
| */ | |
| destroy: Object.assign({value: destroy}, basePropConfig), | |
| /** | |
| * Create and start the embedded webextension (and reject on loading errors. | |
| */ | |
| startup: Object.assign({value: startup}, basePropConfig), | |
| /** | |
| * Shuts down the embedded webextension. | |
| */ | |
| shutdown: Object.assign({value: shutdown}, basePropConfig), | |
| /** | |
| * Create a BroadcastChannel instance connected to the embedded extension origin. | |
| */ | |
| createExtensionBroadcastChannel: Object.assign({ | |
| value: createExtensionBroadcastChannel, | |
| }, basePropConfig), | |
| }); | |
| } | |
| function getEmbeddedExtensionFor({id, resourceURI}) { | |
| let embeddedExtension = EmbeddedExtensionsMap.get(id); | |
| if (!embeddedExtension) { | |
| embeddedExtension = new EmbeddedExtension({addonId: id, resourceURI}); | |
| } | |
| return embeddedExtension; | |
| } | |
| this.LegacyExtensionsUtils = { | |
| getEmbeddedExtensionFor, | |
| }; |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!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"; | |
| /* global BroadcastChannel */ | |
| const { | |
| utils: Cu, | |
| } = Components; | |
| Cu.import("resource://gre/modules/Services.jsm"); | |
| const { | |
| LegacyExtensionsUtils, | |
| } = Cu.import("resource://gre/modules/LegacyExtensionsUtils.jsm"); | |
| const { | |
| Extension, | |
| } = Cu.import("resource://gre/modules/Extension.jsm"); | |
| /** | |
| * This test case ensures that the LegacyExtensionsUtils.EmbeddedExtension: | |
| * - load the embedded webextension resources from a "/webextension/" dir | |
| * inside the XPI. | |
| * - EmbeddedExtension.prototype.api returns an API object which exposes | |
| * a working `runtime.onConnect` event object (e.g. the API can receive a port | |
| * when the embedded webextension is started and it can exchange messages | |
| * with the background page). | |
| * - EmbeddedExtension.prototype.startup/shutdown methods manage the embedded | |
| * webextension lifecycle as expected. | |
| */ | |
| add_task(function* test_embedded_webextension_utils() { | |
| function backgroundScript() { | |
| let sendChannel = new BroadcastChannel("webext->legacy"); | |
| let recvChannel = new BroadcastChannel("legacy->webext"); | |
| recvChannel.onmessage = (evt) => { | |
| browser.test.assertEq("legacy_extension -> webextension"); | |
| sendChannel.postMessage("webextension -> legacy_extension"); | |
| }; | |
| sendChannel.postMessage("webextension -> legacy_extension"); | |
| } | |
| const id = "@test.embedded.web.extension"; | |
| // Extensions.generateXPI is used here (and in the other hybrid addons tests in this same | |
| // test dir) to be able to generate an xpi with the directory layout that we expect from | |
| // an hybrid legacy+webextension addon (where all the embedded webextension resources are | |
| // loaded from a 'webextension/' directory). | |
| let xpi = Extension.generateXPI(id, { | |
| files: { | |
| "webextension/manifest.json": { | |
| applications: {gecko: {id}}, | |
| name: "embedded webextension name", | |
| manifest_version: 2, | |
| version: "1.0", | |
| background: { | |
| scripts: ["bg.js"], | |
| }, | |
| }, | |
| "webextension/bg.js": `new ${backgroundScript}`, | |
| }, | |
| }); | |
| // Remove the generated xpi file and flush the its jar cache | |
| // on cleanup. | |
| SimpleTest.registerCleanupFunction(() => { | |
| Services.obs.notifyObservers(xpi, "flush-cache-entry", null); | |
| xpi.remove(false); | |
| }); | |
| let fileURI = Services.io.newFileURI(xpi); | |
| let resourceURI = Services.io.newURI(`jar:${fileURI.spec}!/`, null, null); | |
| let embeddedExtension = LegacyExtensionsUtils.getEmbeddedExtensionFor({ | |
| id, resourceURI, | |
| }); | |
| ok(embeddedExtension, "Got the embeddedExtension object"); | |
| // Check the embedded extension properties existence. | |
| isDeeply( | |
| Object.keys(embeddedExtension).sort(), | |
| [ | |
| "addonId", "extensionUUID", | |
| "createExtensionBroadcastChannel", | |
| "destroy", "shutdown", "startup", | |
| ].sort(), | |
| "The EmbeddedExtension instance has the expected propertied" | |
| ); | |
| // Check the embedded extension properties types. | |
| is(typeof embeddedExtension.addonId, "string", | |
| "The embeddedExtension has the expected addonId attribute"); | |
| is(typeof embeddedExtension.startup, "function", | |
| "The embeddedExtension has the expected startup method"); | |
| is(typeof embeddedExtension.shutdown, "function", | |
| "The embeddedExtension has the expected shutdown method"); | |
| is(typeof embeddedExtension.createExtensionBroadcastChannel, "function", | |
| "The embeddedExtension has the expected createExtensionBroadcastChannel method"); | |
| // Check the embedded extension addonId property value | |
| is(embeddedExtension.addonId, id, "The embeddedExtension has the expected addonId"); | |
| // Check the embedded extension API methods | |
| SimpleTest.doesThrow(() => { | |
| embeddedExtension.createExtensionBroadcastChannel(); | |
| }, /ExtensionBroadcastChannel name is mandatory/); | |
| let sendChannel = embeddedExtension.createExtensionBroadcastChannel( | |
| "legacy->webext" | |
| ); | |
| let recvChannel = embeddedExtension.createExtensionBroadcastChannel( | |
| "webext->legacy" | |
| ); | |
| info("waiting embeddedExtension.startup is resolved"); | |
| yield embeddedExtension.startup(); | |
| info("embeddedExtension.startup resolved as expected"); | |
| let waitChannelMessage = new Promise(resolve => { | |
| let messageListener = evt => { | |
| recvChannel.removeEventListener("message", messageListener); | |
| resolve(evt.data); | |
| }; | |
| recvChannel.addEventListener("message", messageListener); | |
| }); | |
| sendChannel.postMessage("legacy_extension -> webextension"); | |
| let data = yield waitChannelMessage; | |
| is(data, "webextension -> legacy_extension", | |
| "Got a message from the Extension BroadcastChannel"); | |
| info("waiting embeddedExtension.shutdown is resolved"); | |
| yield embeddedExtension.shutdown(); | |
| info("embeddedExtension.shutdown resolved as expected"); | |
| embeddedExtension.destroy(); | |
| }); | |
| function* createManifestErrorTestCase(id, xpi, expected) { | |
| // Remove the generated xpi file and flush the its jar cache | |
| // on cleanup. | |
| SimpleTest.registerCleanupFunction(() => { | |
| Services.obs.notifyObservers(xpi, "flush-cache-entry", null); | |
| xpi.remove(false); | |
| }); | |
| let fileURI = Services.io.newFileURI(xpi); | |
| let resourceURI = Services.io.newURI(`jar:${fileURI.spec}!/`, null, null); | |
| let embeddedExtension = LegacyExtensionsUtils.getEmbeddedExtensionFor({ | |
| id, resourceURI, | |
| }); | |
| let startupError; | |
| try { | |
| yield embeddedExtension.startup(); | |
| } catch (e) { | |
| startupError = e; | |
| } | |
| info(`Got the startupError: ${startupError}`); | |
| ok(startupError, "waitForStartup has been rejected and we got a startupError"); | |
| if (expected.isExceptionError) { | |
| ok(startupError.message, "Got an exception error message"); | |
| ok(startupError.message.includes(expected.errorMessageIncludes), | |
| `The error message includes the expected error message "${expected.errorMessageIncludes}"`); | |
| info(`Got the Exception: ${startupError} - ${startupError.stack}`); | |
| } | |
| if (expected.isValidationErrors) { | |
| ok(startupError.errors, "Got validation errors as expected"); | |
| ok(startupError.errors.some((msg) => msg.includes(expected.validationErrorsIncludes)), | |
| `The validation errors include the expected error message "${expected.validationErrorsIncludes}"`); | |
| info(`Got Validation Errors: ${JSON.stringify(startupError.errors, null, 2)}`); | |
| } | |
| // Shutdown a "never-started" addon with an embedded webextension should not | |
| // raise any exception, and if it does this test will fail. | |
| yield embeddedExtension.shutdown(); | |
| embeddedExtension.destroy(); | |
| } | |
| add_task(function* test_embedded_webextension_utils_manifest_errors() { | |
| const isExceptionError = true; | |
| const isValidationErrors = true; | |
| let testCases = [ | |
| { | |
| id: "empty-manifest@test.embedded.web.extension", | |
| files: { | |
| "webextension/manifest.json": ``, | |
| }, | |
| expected: {isExceptionError, errorMessageIncludes: "(NS_BASE_STREAM_CLOSED)"}, | |
| }, | |
| { | |
| id: "invalid-json-manifest@test.embedded.web.extension", | |
| files: { | |
| "webextension/manifest.json": `{ "name": }`, | |
| }, | |
| expected: {isExceptionError, errorMessageIncludes: "JSON.parse:"}, | |
| }, | |
| { | |
| id: "blocking-manifest-validation-error@test.embedded.web.extension", | |
| files: { | |
| "webextension/manifest.json": { | |
| name: "embedded webextension name", | |
| manifest_version: 2, | |
| version: "1.0", | |
| background: { | |
| scripts: {}, | |
| }, | |
| }, | |
| }, | |
| expected: { | |
| isValidationErrors, | |
| validationErrorsIncludes: "Error processing background:", | |
| }, | |
| }, | |
| ]; | |
| for (let {id, files, expected} of testCases) { | |
| let xpi = Extension.generateXPI(id, {files}); | |
| yield createManifestErrorTestCase(id, xpi, expected); | |
| } | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment