diff --git a/addon/data/postmessageProxyFrame.html b/addon/data/postmessageProxyFrame.html new file mode 100644 index 0000000000..dcca21fb9e --- /dev/null +++ b/addon/data/postmessageProxyFrame.html @@ -0,0 +1,9 @@ + + +
+ + + + + + diff --git a/addon/data/postmessageProxyFrame.js b/addon/data/postmessageProxyFrame.js new file mode 100644 index 0000000000..bee7bbd506 --- /dev/null +++ b/addon/data/postmessageProxyFrame.js @@ -0,0 +1,25 @@ +const TELEMETRY_QUEUE_POLL_INTERVAL = 5000; +const proxyFrame = document.getElementById('tp-proxy'); + +// Periodically poll for queued telemetry pings +setInterval(function() { + proxyFrame.contentWindow.postMessage({ + op: 'fetchTelemetryPings' + }, '*'); +}, TELEMETRY_QUEUE_POLL_INTERVAL); + +// Listen for postMessage +window.addEventListener('message', function(ev) { + if (ev.data.op === 'telemetryPings') { + // Relay telemetryPings on to the add-on + self.port.emit('telemetryPings', ev.data.data); + return; + } +}); + +// Listen for message from add-on to update iframe src +self.port.on('updateIFrameSrc', src => { + proxyFrame.src = src; +}); + +self.port.emit('ready'); diff --git a/addon/index.js b/addon/index.js index 765e961268..6f13809b2d 100644 --- a/addon/index.js +++ b/addon/index.js @@ -37,6 +37,7 @@ Mustache.parse(templates.installed); Mustache.parse(templates.experimentList); const Metrics = require('./lib/metrics'); +const PostmessageProxy = require('./lib/postmessage-proxy'); const survey = require('./lib/survey'); const PANEL_WIDTH = 300; @@ -571,6 +572,7 @@ exports.main = function(options) { initServerEnvironmentPreference(); Metrics.init(); + PostmessageProxy.init(); }; exports.onUnload = function(reason) { @@ -579,6 +581,7 @@ exports.onUnload = function(reason) { panel.destroy(); button.destroy(); Metrics.destroy(); + PostmessageProxy.destroy(); survey.destroy(); if (reason === 'uninstall' || reason === 'disable') { diff --git a/addon/lib/metrics.js b/addon/lib/metrics.js index 8971c01634..206477e3e4 100644 --- a/addon/lib/metrics.js +++ b/addon/lib/metrics.js @@ -167,6 +167,15 @@ module.exports = { }); }, + onPostmessageProxyFramePings: function(ev) { + const pings = JSON.parse(ev); + pings.forEach(ping => this.onExperimentPing({ + subject: ping.subject, + // HACK: Re-encode data because onExperimentPing expects a string + data: JSON.stringify(ping.data) + })); + }, + onExperimentPing: function(ev) { const { subject, data } = ev; const dataParsed = JSON.parse(data); diff --git a/addon/lib/postmessage-proxy.js b/addon/lib/postmessage-proxy.js new file mode 100644 index 0000000000..9ef43923c3 --- /dev/null +++ b/addon/lib/postmessage-proxy.js @@ -0,0 +1,41 @@ +const {Page} = require('sdk/page-worker'); +const Metrics = require('./metrics'); +const simplePrefs = require('sdk/simple-prefs'); + +let postmessageProxyFrameWorker; + +const iframeURLs = { + local: 'http://testpilot.dev:8000/postmessage-proxy', + dev: 'http://testpilot.dev.mozaws.net/postmessage-proxy', + stage: 'https://testpilot.stage.mozaws.net/postmessage-proxy', + production: 'https://testpilot.firefox.com/postmessage-proxy' +}; + +module.exports = { + + init: function() { + postmessageProxyFrameWorker = Page({ // eslint-disable-line new-cap + contentURL: './postmessageProxyFrame.html', + contentScriptFile: './postmessageProxyFrame.js' + }); + + postmessageProxyFrameWorker.port.on('telemetryPings', + ev => Metrics.onPostmessageProxyFramePings(ev)); + + postmessageProxyFrameWorker.port.on('ready', () => this.updatePrefs()); + + simplePrefs.on('SERVER_ENVIRONMENT', () => this.updatePrefs()); + }, + + updatePrefs: function() { + const envName = simplePrefs.prefs.SERVER_ENVIRONMENT; + const src = (envName in iframeURLs) ? + iframeURLs[envName] : iframeURLs.production; + postmessageProxyFrameWorker.port.emit('updateIFrameSrc', src); + }, + + destroy: function() { + postmessageProxyFrameWorker.destroy(); + } + +}; diff --git a/docs/examples/webextension/background.html b/docs/examples/webextension/background.html new file mode 100644 index 0000000000..0c798b8429 --- /dev/null +++ b/docs/examples/webextension/background.html @@ -0,0 +1,6 @@ + + + + + + diff --git a/docs/examples/webextension/background.js b/docs/examples/webextension/background.js new file mode 100644 index 0000000000..09af8ce0bb --- /dev/null +++ b/docs/examples/webextension/background.js @@ -0,0 +1,18 @@ +var EXPERIMENT_ID = 'webextension-example-1'; + +function sendTelemetryPing(data) { + document.getElementById('tp-proxy').contentWindow.postMessage({ + op: 'queueTelemetryPing', + data: { + subject: EXPERIMENT_ID, + data: data + } + }, '*'); +} + +// Start sending a ping with fake data every second. +setInterval(function () { + sendTelemetryPing({ + timesThingClicked: parseInt(Math.random() * 100) + }); +}, 1000); diff --git a/docs/examples/webextension/manifest.json b/docs/examples/webextension/manifest.json new file mode 100644 index 0000000000..50bfcc1a5e --- /dev/null +++ b/docs/examples/webextension/manifest.json @@ -0,0 +1,20 @@ +{ + "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": { + "page": "background.html", + "scripts": ["background.js"] + } +} diff --git a/gulpfile.js b/gulpfile.js index 5ee6e08d0f..ad0c81f129 100644 --- a/gulpfile.js +++ b/gulpfile.js @@ -118,6 +118,12 @@ gulp.task('scripts', shouldLint('js-lint', 'lint'), function extraScriptsTask() .pipe(gulp.dest(DEST_PATH + 'scripts')); }); +gulp.task('vendor', shouldLint('js-lint', 'lint'), function extraVendorTask() { + return gulp.src(SRC_PATH + 'vendor/**/*') + .pipe(gulpif(!IS_DEBUG, uglify())) + .pipe(gulp.dest(DEST_PATH + 'vendor')); +}); + gulp.task('styles', shouldLint('sass-lint', 'sass-lint'), function stylesTask() { return gulp.src(SRC_PATH + 'styles/**/*.scss') .pipe(sourcemaps.init()) @@ -171,6 +177,7 @@ gulp.task('build', function buildTask(done) { 'app-vendor', 'app-main', 'scripts', + 'vendor', 'styles', 'images', 'locales', @@ -186,6 +193,7 @@ gulp.task('watch', ['build'], function watchTask() { gulp.watch(SRC_PATH + 'app/**/*.js', ['app-main']); gulp.watch('./package.json', ['app-vendor']); gulp.watch(SRC_PATH + 'scripts/**/*.js', ['scripts']); + gulp.watch(SRC_PATH + 'vendor/**/*.js', ['vendor']); gulp.watch(SRC_PATH + 'addon/**/*', ['addon']); gulp.watch(['./legal-copy/*.md', './legal-copy/*.js'], ['legal']); gulp.watch('./locales/**/*', ['locales']); diff --git a/testpilot/frontend/static-src/scripts/proxy.js b/testpilot/frontend/static-src/scripts/proxy.js new file mode 100644 index 0000000000..318406d0a7 --- /dev/null +++ b/testpilot/frontend/static-src/scripts/proxy.js @@ -0,0 +1,41 @@ +var TELEMETRY_PINGS_KEY = 'telemetryPings'; +var TELEMETRY_PINGS_MAX_COUNT = 10; + +// Parse queued telemetry pings from a cookie +function parseTelemetryPings() { + var pings; + try { pings = JSON.parse(Cookies.get(TELEMETRY_PINGS_KEY)); } + catch (e) { pings = []; } + return pings; +} + +window.addEventListener('message', function(ev) { + // Only listen for messages from add-ons and webextensions + if (ev.origin.indexOf('moz-extension://') !== 0 && + ev.origin.indexOf('resource://testpilot-addon') !== 0) { + return; + } + + if (ev.data.op === 'queueTelemetryPing') { + // Parse the current list of pings, add this new one. + var pings = parseTelemetryPings(); + pings.push(ev.data.data); + // Drop some older pings, if we have too many. + while (pings.length > TELEMETRY_PINGS_MAX_COUNT) { pings.shift(); } + // Serialize the queue back into a cookie. + Cookies.set(TELEMETRY_PINGS_KEY, JSON.stringify(pings)); + return; + } + + if (ev.data.op === 'fetchTelemetryPings') { + // Parse the current list of pings, clear the queue. + var pings = parseTelemetryPings(); + Cookies.set(TELEMETRY_PINGS_KEY, JSON.stringify([])); + // Send the list of pings back to the requester + ev.source.postMessage({ + op: 'telemetryPings', + data: JSON.stringify(pings) + }, '*'); + return; + } +}); diff --git a/testpilot/frontend/static-src/vendor/js.cookie.js b/testpilot/frontend/static-src/vendor/js.cookie.js new file mode 100644 index 0000000000..24210af42f --- /dev/null +++ b/testpilot/frontend/static-src/vendor/js.cookie.js @@ -0,0 +1,151 @@ +/*! + * JavaScript Cookie v2.1.2 + * https://github.com/js-cookie/js-cookie + * + * Copyright 2006, 2015 Klaus Hartl & Fagner Brack + * Released under the MIT license + */ +;(function (factory) { + if (typeof define === 'function' && define.amd) { + define(factory); + } else if (typeof exports === 'object') { + module.exports = factory(); + } else { + var OldCookies = window.Cookies; + var api = window.Cookies = factory(); + api.noConflict = function () { + window.Cookies = OldCookies; + return api; + }; + } +}(function () { + function extend () { + var i = 0; + var result = {}; + for (; i < arguments.length; i++) { + var attributes = arguments[ i ]; + for (var key in attributes) { + result[key] = attributes[key]; + } + } + return result; + } + + function init (converter) { + function api (key, value, attributes) { + var result; + if (typeof document === 'undefined') { + return; + } + + // Write + + if (arguments.length > 1) { + attributes = extend({ + path: '/' + }, api.defaults, attributes); + + if (typeof attributes.expires === 'number') { + var expires = new Date(); + expires.setMilliseconds(expires.getMilliseconds() + attributes.expires * 864e+5); + attributes.expires = expires; + } + + try { + result = JSON.stringify(value); + if (/^[\{\[]/.test(result)) { + value = result; + } + } catch (e) {} + + if (!converter.write) { + value = encodeURIComponent(String(value)) + .replace(/%(23|24|26|2B|3A|3C|3E|3D|2F|3F|40|5B|5D|5E|60|7B|7D|7C)/g, decodeURIComponent); + } else { + value = converter.write(value, key); + } + + key = encodeURIComponent(String(key)); + key = key.replace(/%(23|24|26|2B|5E|60|7C)/g, decodeURIComponent); + key = key.replace(/[\(\)]/g, escape); + + return (document.cookie = [ + key, '=', value, + attributes.expires ? '; expires=' + attributes.expires.toUTCString() : '', // use expires attribute, max-age is not supported by IE + attributes.path ? '; path=' + attributes.path : '', + attributes.domain ? '; domain=' + attributes.domain : '', + attributes.secure ? '; secure' : '' + ].join('')); + } + + // Read + + if (!key) { + result = {}; + } + + // To prevent the for loop in the first place assign an empty array + // in case there are no cookies at all. Also prevents odd result when + // calling "get()" + var cookies = document.cookie ? document.cookie.split('; ') : []; + var rdecode = /(%[0-9A-Z]{2})+/g; + var i = 0; + + for (; i < cookies.length; i++) { + var parts = cookies[i].split('='); + var cookie = parts.slice(1).join('='); + + if (cookie.charAt(0) === '"') { + cookie = cookie.slice(1, -1); + } + + try { + var name = parts[0].replace(rdecode, decodeURIComponent); + cookie = converter.read ? + converter.read(cookie, name) : converter(cookie, name) || + cookie.replace(rdecode, decodeURIComponent); + + if (this.json) { + try { + cookie = JSON.parse(cookie); + } catch (e) {} + } + + if (key === name) { + result = cookie; + break; + } + + if (!key) { + result[name] = cookie; + } + } catch (e) {} + } + + return result; + } + + api.set = api; + api.get = function (key) { + return api(key); + }; + api.getJSON = function () { + return api.apply({ + json: true + }, [].slice.call(arguments)); + }; + api.defaults = {}; + + api.remove = function (key, attributes) { + api(key, '', extend(attributes, { + expires: -1 + })); + }; + + api.withConverter = init; + + return api; + } + + return init(function () {}); +})); diff --git a/testpilot/frontend/templates/testpilot/frontend/proxy.html b/testpilot/frontend/templates/testpilot/frontend/proxy.html new file mode 100644 index 0000000000..7c76ba2860 --- /dev/null +++ b/testpilot/frontend/templates/testpilot/frontend/proxy.html @@ -0,0 +1,7 @@ + + + + + + + diff --git a/testpilot/frontend/urls.py b/testpilot/frontend/urls.py index 87327236f6..46dd56fd96 100644 --- a/testpilot/frontend/urls.py +++ b/testpilot/frontend/urls.py @@ -5,5 +5,6 @@ urlpatterns = patterns( '', + url(r'postmessage-proxy', views.postmessage_proxy, name='postmessage_proxy'), url(r'(?P