cfx
Version:
programmatically use cfx with node.js
640 lines (561 loc) • 21.9 kB
JavaScript
/* -*- Mode: Java; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */
/* vim:set ts=2 sw=2 sts=2 et: */
/* 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";
module.metadata = {
"stability": "unstable"
};
const { Trait } = require('../deprecated/traits');
const { EventEmitter, EventEmitterTrait } = require('../deprecated/events');
const { Ci, Cu, Cc } = require('chrome');
const timer = require('../timers');
const { URL } = require('../url');
const unload = require('../system/unload');
const observers = require('../deprecated/observer-service');
const { Cortex } = require('../deprecated/cortex');
const { sandbox, evaluate, load } = require("../loader/sandbox");
const { merge } = require('../util/object');
const xulApp = require("../system/xul-app");
const { getInnerId } = require("../window/utils")
const USE_JS_PROXIES = !xulApp.versionInRange(xulApp.platformVersion,
"17.0a2", "*");
const { getTabForWindow } = require('../tabs/helpers');
const { getTabForContentWindow } = require('../tabs/utils');
/* Trick the linker in order to ensure shipping these files in the XPI.
require('./content-proxy.js');
require('./content-worker.js');
Then, retrieve URL of these files in the XPI:
*/
let prefix = module.uri.split('worker.js')[0];
const CONTENT_PROXY_URL = prefix + 'content-proxy.js';
const CONTENT_WORKER_URL = prefix + 'content-worker.js';
const JS_VERSION = '1.8';
const ERR_DESTROYED =
"Couldn't find the worker to receive this message. " +
"The script may not be initialized yet, or may already have been unloaded.";
const ERR_FROZEN = "The page is currently hidden and can no longer be used " +
"until it is visible again.";
/**
* This key is not exported and should only be used for proxy tests.
* The following `PRIVATE_KEY` is used in addon module scope in order to tell
* Worker API to expose `UNWRAP_ACCESS_KEY` in content script.
* This key allows test-content-proxy.js to unwrap proxy with valueOf:
* let xpcWrapper = proxyWrapper.valueOf(UNWRAP_ACCESS_KEY);
*/
const PRIVATE_KEY = {};
const WorkerSandbox = EventEmitter.compose({
/**
* Emit a message to the worker content sandbox
*/
emit: function emit() {
// First ensure having a regular array
// (otherwise, `arguments` would be mapped to an object by `stringify`)
let array = Array.slice(arguments);
// JSON.stringify is buggy with cross-sandbox values,
// it may return "{}" on functions. Use a replacer to match them correctly.
function replacer(k, v) {
return typeof v === "function" ? undefined : v;
}
// Ensure having an asynchronous behavior
let self = this;
timer.setTimeout(function () {
self._emitToContent(JSON.stringify(array, replacer));
}, 0);
},
/**
* Synchronous version of `emit`.
* /!\ Should only be used when it is strictly mandatory /!\
* Doesn't ensure passing only JSON values.
* Mainly used by context-menu in order to avoid breaking it.
*/
emitSync: function emitSync() {
let args = Array.slice(arguments);
// Bug 732716: Ensure wrapping xrays sent to the content script
// otherwise it will have access to raw xraywrappers and content script
// will assume it is an user object coming from the content script sandbox
if ("_wrap" in this)
args = args.map(this._wrap);
return this._emitToContent(args);
},
/**
* Tells if content script has at least one listener registered for one event,
* through `self.on('xxx', ...)`.
* /!\ Shouldn't be used. Implemented to avoid breaking context-menu API.
*/
hasListenerFor: function hasListenerFor(name) {
return this._hasListenerFor(name);
},
/**
* Method called by the worker sandbox when it needs to send a message
*/
_onContentEvent: function onContentEvent(args) {
// As `emit`, we ensure having an asynchronous behavior
let self = this;
timer.setTimeout(function () {
// We emit event to chrome/addon listeners
self._emit.apply(self, JSON.parse(args));
}, 0);
},
/**
* Configures sandbox and loads content scripts into it.
* @param {Worker} worker
* content worker
*/
constructor: function WorkerSandbox(worker) {
this._addonWorker = worker;
// Ensure that `emit` has always the right `this`
this.emit = this.emit.bind(this);
this.emitSync = this.emitSync.bind(this);
// We receive a wrapped window, that may be an xraywrapper if it's content
let window = worker._window;
let proto = window;
// Instantiate trusted code in another Sandbox in order to prevent content
// script from messing with standard classes used by proxy and API code.
let apiSandbox = sandbox(window, { wantXrays: true, sameZoneAs: window });
apiSandbox.console = console;
// Build content proxies only if the document has a non-system principal
// And only on old firefox versions that doesn't ship bug 738244
if (USE_JS_PROXIES && XPCNativeWrapper.unwrap(window) !== window) {
// Execute the proxy code
load(apiSandbox, CONTENT_PROXY_URL);
// Get a reference of the window's proxy
proto = apiSandbox.create(window);
// Keep a reference to `wrap` function for `emitSync` usage
this._wrap = apiSandbox.wrap;
}
// Create the sandbox and bind it to window in order for content scripts to
// have access to all standard globals (window, document, ...)
let content = this._sandbox = sandbox(window, {
sandboxPrototype: proto,
wantXrays: true,
sameZoneAs: window
});
// We have to ensure that window.top and window.parent are the exact same
// object than window object, i.e. the sandbox global object. But not
// always, in case of iframes, top and parent are another window object.
let top = window.top === window ? content : content.top;
let parent = window.parent === window ? content : content.parent;
merge(content, {
// We need "this === window === top" to be true in toplevel scope:
get window() content,
get top() top,
get parent() parent,
// Use the Greasemonkey naming convention to provide access to the
// unwrapped window object so the content script can access document
// JavaScript values.
// NOTE: this functionality is experimental and may change or go away
// at any time!
get unsafeWindow() window.wrappedJSObject
});
// Load trusted code that will inject content script API.
// We need to expose JS objects defined in same principal in order to
// avoid having any kind of wrapper.
load(apiSandbox, CONTENT_WORKER_URL);
// prepare a clean `self.options`
let options = 'contentScriptOptions' in worker ?
JSON.stringify( worker.contentScriptOptions ) :
undefined;
// Then call `inject` method and communicate with this script
// by trading two methods that allow to send events to the other side:
// - `onEvent` called by content script
// - `result.emitToContent` called by addon script
// Bug 758203: We have to explicitely define `__exposedProps__` in order
// to allow access to these chrome object attributes from this sandbox with
// content priviledges
// https://developer.mozilla.org/en/XPConnect_wrappers#Other_security_wrappers
let chromeAPI = {
timers: {
setTimeout: timer.setTimeout,
setInterval: timer.setInterval,
clearTimeout: timer.clearTimeout,
clearInterval: timer.clearInterval,
__exposedProps__: {
setTimeout: 'r',
setInterval: 'r',
clearTimeout: 'r',
clearInterval: 'r'
}
},
__exposedProps__: {
timers: 'r'
}
};
let onEvent = this._onContentEvent.bind(this);
// `ContentWorker` is defined in CONTENT_WORKER_URL file
let result = apiSandbox.ContentWorker.inject(content, chromeAPI, onEvent, options);
this._emitToContent = result.emitToContent;
this._hasListenerFor = result.hasListenerFor;
// Handle messages send by this script:
let self = this;
// console.xxx calls
this.on("console", function consoleListener(kind) {
console[kind].apply(console, Array.slice(arguments, 1));
});
// self.postMessage calls
this.on("message", function postMessage(data) {
// destroyed?
if (self._addonWorker)
self._addonWorker._emit('message', data);
});
// self.port.emit calls
this.on("event", function portEmit(name, args) {
// destroyed?
if (self._addonWorker)
self._addonWorker._onContentScriptEvent.apply(self._addonWorker, arguments);
});
// Internal feature that is only used by SDK tests:
// Expose unlock key to content script context.
// See `PRIVATE_KEY` definition for more information.
if (apiSandbox && worker._expose_key)
content.UNWRAP_ACCESS_KEY = apiSandbox.UNWRAP_ACCESS_KEY;
// Inject `addon` global into target document if document is trusted,
// `addon` in document is equivalent to `self` in content script.
if (worker._injectInDocument) {
let win = window.wrappedJSObject ? window.wrappedJSObject : window;
Object.defineProperty(win, "addon", {
value: content.self
}
);
}
// Inject our `console` into target document if worker doesn't have a tab
// (e.g Panel, PageWorker, Widget).
// `worker.tab` can't be used because bug 804935.
if (!getTabForContentWindow(window)) {
let win = window.wrappedJSObject ? window.wrappedJSObject : window;
// export our chrome console to content window, using the same approach
// of `ConsoleAPI`:
// http://mxr.mozilla.org/mozilla-central/source/dom/base/ConsoleAPI.js#150
//
// and described here:
// https://developer.mozilla.org/en-US/docs/Components.utils.createObjectIn
let con = Cu.createObjectIn(win);
let genPropDesc = function genPropDesc(fun) {
return { enumerable: true, configurable: true, writable: true,
value: console[fun] };
}
const properties = {
log: genPropDesc('log'),
info: genPropDesc('info'),
warn: genPropDesc('warn'),
error: genPropDesc('error'),
debug: genPropDesc('debug'),
trace: genPropDesc('trace'),
dir: genPropDesc('dir'),
group: genPropDesc('group'),
groupCollapsed: genPropDesc('groupCollapsed'),
groupEnd: genPropDesc('groupEnd'),
time: genPropDesc('time'),
timeEnd: genPropDesc('timeEnd'),
profile: genPropDesc('profile'),
profileEnd: genPropDesc('profileEnd'),
__noSuchMethod__: { enumerable: true, configurable: true, writable: true,
value: function() {} }
};
Object.defineProperties(con, properties);
Cu.makeObjectPropsNormal(con);
win.console = con;
};
// The order of `contentScriptFile` and `contentScript` evaluation is
// intentional, so programs can load libraries like jQuery from script URLs
// and use them in scripts.
let contentScriptFile = ('contentScriptFile' in worker) ? worker.contentScriptFile
: null,
contentScript = ('contentScript' in worker) ? worker.contentScript : null;
if (contentScriptFile) {
if (Array.isArray(contentScriptFile))
this._importScripts.apply(this, contentScriptFile);
else
this._importScripts(contentScriptFile);
}
if (contentScript) {
this._evaluate(
Array.isArray(contentScript) ? contentScript.join(';\n') : contentScript
);
}
},
destroy: function destroy() {
this.emitSync("detach");
this._sandbox = null;
this._addonWorker = null;
this._wrap = null;
},
/**
* JavaScript sandbox where all the content scripts are evaluated.
* {Sandbox}
*/
_sandbox: null,
/**
* Reference to the addon side of the worker.
* @type {Worker}
*/
_addonWorker: null,
/**
* Evaluates code in the sandbox.
* @param {String} code
* JavaScript source to evaluate.
* @param {String} [filename='javascript:' + code]
* Name of the file
*/
_evaluate: function(code, filename) {
try {
evaluate(this._sandbox, code, filename || 'javascript:' + code);
}
catch(e) {
this._addonWorker._emit('error', e);
}
},
/**
* Imports scripts to the sandbox by reading files under urls and
* evaluating its source. If exception occurs during evaluation
* `"error"` event is emitted on the worker.
* This is actually an analog to the `importScript` method in web
* workers but in our case it's not exposed even though content
* scripts may be able to do it synchronously since IO operation
* takes place in the UI process.
*/
_importScripts: function _importScripts(url) {
let urls = Array.slice(arguments, 0);
for each (let contentScriptFile in urls) {
try {
let uri = URL(contentScriptFile);
if (uri.scheme === 'resource')
load(this._sandbox, String(uri));
else
throw Error("Unsupported `contentScriptFile` url: " + String(uri));
}
catch(e) {
this._addonWorker._emit('error', e);
}
}
}
});
/**
* Message-passing facility for communication between code running
* in the content and add-on process.
* @see https://jetpack.mozillalabs.com/sdk/latest/docs/#module/api-utils/content/worker
*/
const Worker = EventEmitter.compose({
on: Trait.required,
_removeAllListeners: Trait.required,
// List of messages fired before worker is initialized
get _earlyEvents() {
delete this._earlyEvents;
this._earlyEvents = [];
return this._earlyEvents;
},
/**
* Sends a message to the worker's global scope. Method takes single
* argument, which represents data to be sent to the worker. The data may
* be any primitive type value or `JSON`. Call of this method asynchronously
* emits `message` event with data value in the global scope of this
* symbiont.
*
* `message` event listeners can be set either by calling
* `self.on` with a first argument string `"message"` or by
* implementing `onMessage` function in the global scope of this worker.
* @param {Number|String|JSON} data
*/
postMessage: function (data) {
let args = ['message'].concat(Array.slice(arguments));
if (!this._inited) {
this._earlyEvents.push(args);
return;
}
processMessage.apply(this, args);
},
/**
* EventEmitter, that behaves (calls listeners) asynchronously.
* A way to send customized messages to / from the worker.
* Events from in the worker can be observed / emitted via
* worker.on / worker.emit.
*/
get port() {
// We generate dynamically this attribute as it needs to be accessible
// before Worker.constructor gets called. (For ex: Panel)
// create an event emitter that receive and send events from/to the worker
this._port = EventEmitterTrait.create({
emit: this._emitEventToContent.bind(this)
});
// expose wrapped port, that exposes only public properties:
// We need to destroy this getter in order to be able to set the
// final value. We need to update only public port attribute as we never
// try to access port attribute from private API.
delete this._public.port;
this._public.port = Cortex(this._port);
// Replicate public port to the private object
delete this.port;
this.port = this._public.port;
return this._port;
},
/**
* Same object than this.port but private API.
* Allow access to _emit, in order to send event to port.
*/
_port: null,
/**
* Emit a custom event to the content script,
* i.e. emit this event on `self.port`
*/
_emitEventToContent: function () {
let args = ['event'].concat(Array.slice(arguments));
if (!this._inited) {
this._earlyEvents.push(args);
return;
}
processMessage.apply(this, args);
},
// Is worker connected to the content worker sandbox ?
_inited: false,
// Is worker being frozen? i.e related document is frozen in bfcache.
// Content script should not be reachable if frozen.
_frozen: true,
constructor: function Worker(options) {
options = options || {};
if ('contentScriptFile' in options)
this.contentScriptFile = options.contentScriptFile;
if ('contentScriptOptions' in options)
this.contentScriptOptions = options.contentScriptOptions;
if ('contentScript' in options)
this.contentScript = options.contentScript;
this._setListeners(options);
// Internal feature that is only used by SDK unit tests.
// See `PRIVATE_KEY` definition for more information.
if ('exposeUnlockKey' in options && options.exposeUnlockKey === PRIVATE_KEY)
this._expose_key = true;
unload.ensure(this._public, "destroy");
// Ensure that worker._port is initialized for contentWorker to be able
// to send events during worker initialization.
this.port;
this._documentUnload = this._documentUnload.bind(this);
this._pageShow = this._pageShow.bind(this);
this._pageHide = this._pageHide.bind(this);
if ("window" in options) this._attach(options.window);
},
_setListeners: function(options) {
if ('onError' in options)
this.on('error', options.onError);
if ('onMessage' in options)
this.on('message', options.onMessage);
if ('onDetach' in options)
this.on('detach', options.onDetach);
},
_attach: function(window) {
this._window = window;
// Track document unload to destroy this worker.
// We can't watch for unload event on page's window object as it
// prevents bfcache from working:
// https://developer.mozilla.org/En/Working_with_BFCache
this._windowID = getInnerId(this._window);
observers.add("inner-window-destroyed", this._documentUnload);
// Listen to pagehide event in order to freeze the content script
// while the document is frozen in bfcache:
this._window.addEventListener("pageshow", this._pageShow, true);
this._window.addEventListener("pagehide", this._pageHide, true);
// will set this._contentWorker pointing to the private API:
this._contentWorker = WorkerSandbox(this);
// Mainly enable worker.port.emit to send event to the content worker
this._inited = true;
this._frozen = false;
// Process all events and messages that were fired before the
// worker was initialized.
this._earlyEvents.forEach((function (args) {
processMessage.apply(this, args);
}).bind(this));
},
_documentUnload: function _documentUnload(subject, topic, data) {
let innerWinID = subject.QueryInterface(Ci.nsISupportsPRUint64).data;
if (innerWinID != this._windowID) return false;
this._workerCleanup();
return true;
},
_pageShow: function _pageShow() {
this._contentWorker.emitSync("pageshow");
this._emit("pageshow");
this._frozen = false;
},
_pageHide: function _pageHide() {
this._contentWorker.emitSync("pagehide");
this._emit("pagehide");
this._frozen = true;
},
get url() {
// this._window will be null after detach
return this._window ? this._window.document.location.href : null;
},
get tab() {
// this._window will be null after detach
if (this._window)
return getTabForWindow(this._window);
return null;
},
/**
* Tells content worker to unload itself and
* removes all the references from itself.
*/
destroy: function destroy() {
this._workerCleanup();
this._removeAllListeners();
},
/**
* Remove all internal references to the attached document
* Tells _port to unload itself and removes all the references from itself.
*/
_workerCleanup: function _workerCleanup() {
// maybe unloaded before content side is created
// As Symbiont call worker.constructor on document load
if (this._contentWorker)
this._contentWorker.destroy();
this._contentWorker = null;
if (this._window) {
this._window.removeEventListener("pageshow", this._pageShow, true);
this._window.removeEventListener("pagehide", this._pageHide, true);
}
this._window = null;
// This method may be called multiple times,
// avoid dispatching `detach` event more than once
if (this._windowID) {
this._windowID = null;
observers.remove("inner-window-destroyed", this._documentUnload);
this._earlyEvents.length = 0;
this._emit("detach");
}
},
/**
* Receive an event from the content script that need to be sent to
* worker.port. Provide a way for composed object to catch all events.
*/
_onContentScriptEvent: function _onContentScriptEvent() {
this._port._emit.apply(this._port, arguments);
},
/**
* Reference to the content side of the worker.
* @type {WorkerGlobalScope}
*/
_contentWorker: null,
/**
* Reference to the window that is accessible from
* the content scripts.
* @type {Object}
*/
_window: null,
/**
* Flag to enable `addon` object injection in document. (bug 612726)
* @type {Boolean}
*/
_injectInDocument: false
});
/**
* Fired from postMessage and _emitEventToContent, or from the _earlyMessage
* queue when fired before the content is loaded. Sends arguments to
* contentWorker if able
*/
function processMessage () {
if (!this._contentWorker)
throw new Error(ERR_DESTROYED);
if (this._frozen)
throw new Error(ERR_FROZEN);
this._contentWorker.emit.apply(null, Array.slice(arguments));
}
exports.Worker = Worker;