Skip to content

Instantly share code, notes, and snippets.

@rpl
Created August 4, 2016 17:05
Show Gist options
  • Select an option

  • Save rpl/e654ddd5a19bb1876182b67622f07cb5 to your computer and use it in GitHub Desktop.

Select an option

Save rpl/e654ddd5a19bb1876182b67622f07cb5 to your computer and use it in GitHub Desktop.
Hybrid Addon Helpers - Proposal B
/* 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,
};
<!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