/* 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/. */ /** * * `deprecated/traits-worker` was previously `content/worker` and kept * only due to `deprecated/symbiont` using it, which is necessary for * `widget`, until that reaches deprecation EOL. * */ "use strict"; module.metadata = { "stability": "deprecated" }; const { Trait } = require('./traits'); const { EventEmitter, EventEmitterTrait } = require('./events'); const { Ci, Cu, Cc } = require('chrome'); const timer = require('../timers'); const { URL } = require('../url'); const unload = require('../system/unload'); const observers = require('../system/events'); const { Cortex } = require('./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/content-worker.js'); Then, retrieve URL of these files in the XPI: */ let prefix = module.uri.split('deprecated/traits-worker.js')[0]; const CONTENT_WORKER_URL = prefix + 'content/content-worker.js'; // Fetch additional list of domains to authorize access to for each content // script. It is stored in manifest `metadata` field which contains // package.json data. This list is originaly defined by authors in // `permissions` attribute of their package.json addon file. const permissions = require('@loader/options').metadata['permissions'] || {}; const EXPANDED_PRINCIPALS = permissions['cross-domain-content'] || []; 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."; 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); 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; // Eventually use expanded principal sandbox feature, if some are given. // // But prevent it when the Worker isn't used for a content script but for // injecting `addon` object into a Panel, Widget, ... scope. // That's because: // 1/ It is useless to use multiple domains as the worker is only used // to communicate with the addon, // 2/ By using it it would prevent the document to have access to any JS // value of the worker. As JS values coming from multiple domain principals // can't be accessed by "mono-principals" (principal with only one domain). // Even if this principal is for a domain that is specified in the multiple // domain principal. let principals = window; let wantGlobalProperties = [] if (EXPANDED_PRINCIPALS.length > 0 && !worker._injectInDocument) { principals = EXPANDED_PRINCIPALS.concat(window); // We have to replace XHR constructor of the content document // with a custom cross origin one, automagically added by platform code: delete proto.XMLHttpRequest; wantGlobalProperties.push("XMLHttpRequest"); } // 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(principals, { wantXrays: true, sameZoneAs: window }); apiSandbox.console = console; // 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(principals, { sandboxPrototype: proto, wantXrays: true, wantGlobalProperties: wantGlobalProperties, sameZoneAs: window, metadata: { SDKContentScript: true } }); // 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' } }, sandbox: { evaluate: evaluate, __exposedProps__: { evaluate: 'r', } }, __exposedProps__: { timers: 'r', sandbox: '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); }); // unwrap, recreate and propagate async Errors thrown from content-script this.on("error", function onError({instanceOfError, value}) { if (self._addonWorker) { let error = value; if (instanceOfError) { error = new Error(value.message, value.fileName, value.lineNumber); error.stack = value.stack; error.name = value.name; } self._addonWorker._emit('error', error); } }); // 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; }, /** * 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://addons.mozilla.org/en-US/developers/docs/sdk/latest/modules/sdk/content/worker.html */ 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); 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.on("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, 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._inited = true; 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.off("inner-window-destroyed", this._documentUnload); this._earlyEvents.length = 0; this._emit("detach"); } this._inited = false; }, /** * 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;