From 7738ed46ae7d96a29b724b11aec660e73a887952 Mon Sep 17 00:00:00 2001 From: Les Orchard Date: Tue, 21 Jun 2016 16:27:04 -0400 Subject: [PATCH] Use BroadcastChannel to allow WebExtensions to communicate with Test Pilot - New lib/webextension-channels module to manage channels for every enabled experiment - Each "channel" creates a background page in the WebExtension's origin and listens for BroadcastChannel messages from the WebExtension - Example WebExtension that uses the BroadcastChannel Fixes #433 --- addon/index.js | 11 +- addon/lib/webextension-channels.js | 171 +++++++++++++++++++++++ docs/examples/webextension/background.js | 12 ++ docs/examples/webextension/manifest.json | 19 +++ 4 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 addon/lib/webextension-channels.js create mode 100644 docs/examples/webextension/background.js create mode 100644 docs/examples/webextension/manifest.json diff --git a/addon/index.js b/addon/index.js index 774584832b..016d26ae9f 100644 --- a/addon/index.js +++ b/addon/index.js @@ -38,6 +38,7 @@ Mustache.parse(templates.experimentList); const Metrics = require('./lib/metrics'); const survey = require('./lib/survey'); +const WebExtensionChannels = require('./lib/webextension-channels'); const PANEL_WIDTH = 300; const FOOTER_HEIGHT = 50; @@ -233,8 +234,8 @@ panel.on('show', showExperimentList); panel.port.on('back', showExperimentList); function showExperimentList() { - panel.port.emit('show', getExperimentList(store.availableExperiments, - store.installedAddons)); + panel.port.emit('show', getExperimentList(store.availableExperiments || {}, + store.installedAddons || {})); } panel.port.on('link', url => { @@ -475,6 +476,7 @@ const addonListener = { version: addon.version }); Metrics.experimentEnabled(addon.id); + WebExtensionChannels.updateExperimentChannels(); } }, onDisabled: function(addon) { @@ -486,6 +488,7 @@ const addonListener = { version: addon.version }); Metrics.experimentDisabled(addon.id); + WebExtensionChannels.updateExperimentChannels(); } }, onUninstalling: function(addon) { @@ -511,6 +514,7 @@ const addonListener = { } Metrics.experimentDisabled(addon.id); + WebExtensionChannels.updateExperimentChannels(); } } }; @@ -525,6 +529,7 @@ const installListener = { formatInstallData(install, addon), addon); }); Metrics.experimentEnabled(addon.id); + WebExtensionChannels.updateExperimentChannels(); }, onInstallFailed: function(install) { app.send('addon-install:install-failed', formatInstallData(install)); @@ -571,6 +576,7 @@ exports.main = function(options) { initServerEnvironmentPreference(); Metrics.init(); + WebExtensionChannels.init(); }; exports.onUnload = function(reason) { @@ -579,6 +585,7 @@ exports.onUnload = function(reason) { panel.destroy(); button.destroy(); Metrics.destroy(); + WebExtensionChannels.destroy(); if (reason === 'uninstall' || reason === 'disable') { Metrics.onDisable(); diff --git a/addon/lib/webextension-channels.js b/addon/lib/webextension-channels.js new file mode 100644 index 0000000000..24fc0c3be6 --- /dev/null +++ b/addon/lib/webextension-channels.js @@ -0,0 +1,171 @@ +/* + * This Source Code is subject to the terms of the Mozilla Public License + * version 2.0 (the 'License'). You can obtain a copy of the License at + * http://mozilla.org/MPL/2.0/. + */ + +/* global XPCOMUtils, Services */ + +const { Ci, Cu } = require('chrome'); +const { Class } = require('sdk/core/heritage'); +const { Disposable } = require('sdk/core/disposable'); + +const store = require('sdk/simple-storage').storage; + +const Metrics = require('./metrics'); + +const TESTPILOT_TELEMETRY_CHANNEL = 'testpilot-telemetry'; + +Cu.import('resource://gre/modules/XPCOMUtils.jsm'); + +XPCOMUtils.defineLazyModuleGetter(this, 'Services', + 'resource://gre/modules/Services.jsm'); + +const {getExtensionUUID} = Cu.import('resource://gre/modules/Extension.jsm', {}); + +function createChannelForAddonId(name, addonId) { + // The BroadcastChannel API allows messaging between different windows that + // share the same origin. Bug 1186732 extended this to WebExtensions (which + // may not have an origin) by adding a special URL that loads an about:blank + // page at the (generalized) "origin" of the extension. + // + // Load that about:blank page, and use its `window` to get a BroadcastChannel + // that allows two-way communication between the main Test Pilot extension and + // individual experiment extensions. + + // Note: the `targetExtensionUUID` is different for each copy of Firefox, + // so that malicious websites can't guess the special URL associated with + // an extension. + const targetExtensionUUID = getExtensionUUID(addonId); + + // Create the special about:blank URL for the extension. + const baseURI = Services.io + .newURI(`moz-extension://${targetExtensionUUID}/_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(baseURI, { addonId }); + + // Create a hidden window and open the special about:blank page for the + // extension. + const addonChromeWebNav = Services.appShell.createWindowlessBrowser(true); + const docShell = addonChromeWebNav.QueryInterface(Ci.nsIInterfaceRequestor) // eslint-disable-line new-cap + .getInterface(Ci.nsIDocShell); + docShell.createAboutBlankContentViewer(principal); + const window = docShell.contentViewer.DOMDocument.defaultView; + + // Finally, get the BroadcastChannel associated with the extension. + const addonBroadcastChannel = new window.BroadcastChannel(name); + + // Callers need to keep the pointer to the window, otherwise the window's + // BroadcastChannel will get garbage collected. + return { + addonChromeWebNav, + addonBroadcastChannel + }; +} + +const WebExtensionChannel = Class({ // eslint-disable-line new-cap + implements: [Disposable], + + initialize(targetAddonId) { + this.pingListeners = new Set(); + + this.targetAddonId = targetAddonId; + + const { + addonChromeWebNav, + addonBroadcastChannel + } = createChannelForAddonId(TESTPILOT_TELEMETRY_CHANNEL, targetAddonId); + + // NOTE: Keep a ref to prevent it from going away during garbage collection + // (or the BroadcastChannel will stop working). + this.addonChromeWebNav = addonChromeWebNav; + this.addonBroadcastChannel = addonBroadcastChannel; + + this.handleEventBound = ev => this.handleEvent(ev); + }, + + dispose() { + this.addonBroadcastChannel.removeEventListener('message', this.handleEventBound); + this.addonBroadcastChannel.close(); + this.addonChromeWebNav.close(); + this.pingListeners.clear(); + + this.addonBroadcastChannel = null; + this.addonChromeWebNav = null; + }, + + registerPingListener(callback) { + this.pingListeners.add(callback); + + if (this.pingListeners.size >= 0) { + this.addonBroadcastChannel.addEventListener('message', this.handleEventBound); + } + }, + + unregisterPingListener(callback) { + this.pingListeners.delete(callback); + + if (this.pingListeners.size === 0) { + this.addonBroadcastChannel.removeEventListener('message', this.handleEventBound); + } + }, + + handleEvent(event) { + if (event.data) { + this.notifyPing(event.data, {addonId: this.targetAddonId}); + } + }, + + notifyPing(data, sender) { + for (let pingListener of this.pingListeners) { // eslint-disable-line prefer-const + try { + pingListener({ + senderAddonId: sender.addonId, + testpilotPingData: data + }); + } catch (err) { + console.error('Error executing pingListener', err); // eslint-disable-line no-console + } + } + } +}); + +let channels = {}; + +module.exports = { + WebExtensionChannel, + + // Update all the channels on init. + init() { + this.updateExperimentChannels(); + }, + + // Drop refs to channels for garbage collection + destroy() { + channels = {}; + }, + + // Rebuild channels for all known experiments + updateExperimentChannels() { + channels = {}; + if (store.installedAddons) { + Object.keys(store.installedAddons).forEach(id => { + const channel = new WebExtensionChannel(id); + channels[id] = channel; + channel.registerPingListener(data => + this.handleWebExtensionPing(id, data)); + }); + } + }, + + // Pass a ping message along to Telemetry via Metrics + handleWebExtensionPing(id, data) { + Metrics.onExperimentPing({ + subject: id, + data: JSON.stringify(data) + }); + } +}; diff --git a/docs/examples/webextension/background.js b/docs/examples/webextension/background.js new file mode 100644 index 0000000000..955258d70e --- /dev/null +++ b/docs/examples/webextension/background.js @@ -0,0 +1,12 @@ +var TESTPILOT_TELEMETRY_CHANNEL = 'testpilot-telemetry'; +var testpilotPingChannel = new BroadcastChannel(TESTPILOT_TELEMETRY_CHANNEL); +setInterval(function () { + testpilotPingChannel.postMessage({ + boolData: true, + arrayOfData: ["one", "two", "three"], + nestedData: { + intData: 10, + }, + }); + console.log("TEST PILOT PING SENT", Date.now()); +}, 5000); diff --git a/docs/examples/webextension/manifest.json b/docs/examples/webextension/manifest.json new file mode 100644 index 0000000000..f435491613 --- /dev/null +++ b/docs/examples/webextension/manifest.json @@ -0,0 +1,19 @@ +{ + "manifest_version": 2, + "name": "Test Pilot WebExtension Example", + "version": "1.0", + "description": "This is a WebExtension built as an example Test Pilot experiment", + "icons": { + "32": "icons/icon-32.png" + }, + "permissions": ["background"], + "applications": { + "gecko": { + "id": "testpilotexample1@mozilla.org", + "strict_min_version": "45.0" + } + }, + "background": { + "scripts": ["background.js"] + } +}