diff options
author | Matt A. Tobin <email@mattatobin.com> | 2018-02-09 06:46:43 -0500 |
---|---|---|
committer | Matt A. Tobin <email@mattatobin.com> | 2018-02-09 06:46:43 -0500 |
commit | ac46df8daea09899ce30dc8fd70986e258c746bf (patch) | |
tree | 2750d3125fc253fd5b0671e4bd268eff1fd97296 /toolkit/jetpack/sdk/content | |
parent | 8cecf8d5208f3945b35f879bba3015bb1a11bec6 (diff) | |
download | UXP-ac46df8daea09899ce30dc8fd70986e258c746bf.tar UXP-ac46df8daea09899ce30dc8fd70986e258c746bf.tar.gz UXP-ac46df8daea09899ce30dc8fd70986e258c746bf.tar.lz UXP-ac46df8daea09899ce30dc8fd70986e258c746bf.tar.xz UXP-ac46df8daea09899ce30dc8fd70986e258c746bf.zip |
Move Add-on SDK source to toolkit/jetpack
Diffstat (limited to 'toolkit/jetpack/sdk/content')
-rw-r--r-- | toolkit/jetpack/sdk/content/content-worker.js | 305 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/content.js | 17 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/context-menu.js | 408 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/events.js | 57 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/l10n-html.js | 133 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/loader.js | 74 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/mod.js | 68 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/page-mod.js | 236 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/page-worker.js | 154 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/sandbox.js | 426 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/sandbox/events.js | 12 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/tab-events.js | 58 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/thumbnail.js | 51 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/utils.js | 105 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/worker-child.js | 158 | ||||
-rw-r--r-- | toolkit/jetpack/sdk/content/worker.js | 180 |
16 files changed, 2442 insertions, 0 deletions
diff --git a/toolkit/jetpack/sdk/content/content-worker.js b/toolkit/jetpack/sdk/content/content-worker.js new file mode 100644 index 000000000..0a8225733 --- /dev/null +++ b/toolkit/jetpack/sdk/content/content-worker.js @@ -0,0 +1,305 @@ +/* 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/. */ + +Object.freeze({ + // TODO: Bug 727854 Use same implementation than common JS modules, + // i.e. EventEmitter module + + /** + * Create an EventEmitter instance. + */ + createEventEmitter: function createEventEmitter(emit) { + let listeners = Object.create(null); + let eventEmitter = Object.freeze({ + emit: emit, + on: function on(name, callback) { + if (typeof callback !== "function") + return this; + if (!(name in listeners)) + listeners[name] = []; + listeners[name].push(callback); + return this; + }, + once: function once(name, callback) { + eventEmitter.on(name, function onceCallback() { + eventEmitter.removeListener(name, onceCallback); + callback.apply(callback, arguments); + }); + }, + removeListener: function removeListener(name, callback) { + if (!(name in listeners)) + return; + let index = listeners[name].indexOf(callback); + if (index == -1) + return; + listeners[name].splice(index, 1); + } + }); + function onEvent(name) { + if (!(name in listeners)) + return []; + let args = Array.slice(arguments, 1); + let results = []; + for (let callback of listeners[name]) { + results.push(callback.apply(null, args)); + } + return results; + } + return { + eventEmitter: eventEmitter, + emit: onEvent + }; + }, + + /** + * Create an EventEmitter instance to communicate with chrome module + * by passing only strings between compartments. + * This function expects `emitToChrome` function, that allows to send + * events to the chrome module. It returns the EventEmitter as `pipe` + * attribute, and, `onChromeEvent` a function that allows chrome module + * to send event into the EventEmitter. + * + * pipe.emit --> emitToChrome + * onChromeEvent --> callback registered through pipe.on + */ + createPipe: function createPipe(emitToChrome) { + let ContentWorker = this; + function onEvent(type, ...args) { + // JSON.stringify is buggy with cross-sandbox values, + // it may return "{}" on functions. Use a replacer to match them correctly. + let replacer = (k, v) => + typeof(v) === "function" + ? (type === "console" ? Function.toString.call(v) : void(0)) + : v; + + let str = JSON.stringify([type, ...args], replacer); + emitToChrome(str); + } + + let { eventEmitter, emit } = + ContentWorker.createEventEmitter(onEvent); + + return { + pipe: eventEmitter, + onChromeEvent: function onChromeEvent(array) { + // We either receive a stringified array, or a real array. + // We still allow to pass an array of objects, in WorkerSandbox.emitSync + // in order to allow sending DOM node reference between content script + // and modules (only used for context-menu API) + let args = typeof array == "string" ? JSON.parse(array) : array; + return emit.apply(null, args); + } + }; + }, + + injectConsole: function injectConsole(exports, pipe) { + exports.console = Object.freeze({ + log: pipe.emit.bind(null, "console", "log"), + info: pipe.emit.bind(null, "console", "info"), + warn: pipe.emit.bind(null, "console", "warn"), + error: pipe.emit.bind(null, "console", "error"), + debug: pipe.emit.bind(null, "console", "debug"), + exception: pipe.emit.bind(null, "console", "exception"), + trace: pipe.emit.bind(null, "console", "trace"), + time: pipe.emit.bind(null, "console", "time"), + timeEnd: pipe.emit.bind(null, "console", "timeEnd") + }); + }, + + injectTimers: function injectTimers(exports, chromeAPI, pipe, console) { + // wrapped functions from `'timer'` module. + // Wrapper adds `try catch` blocks to the callbacks in order to + // emit `error` event if exception is thrown in + // the Worker global scope. + // @see http://www.w3.org/TR/workers/#workerutils + + // List of all living timeouts/intervals + let _timers = Object.create(null); + + // Keep a reference to original timeout functions + let { + setTimeout: chromeSetTimeout, + setInterval: chromeSetInterval, + clearTimeout: chromeClearTimeout, + clearInterval: chromeClearInterval + } = chromeAPI.timers; + + function registerTimer(timer) { + let registerMethod = null; + if (timer.kind == "timeout") + registerMethod = chromeSetTimeout; + else if (timer.kind == "interval") + registerMethod = chromeSetInterval; + else + throw new Error("Unknown timer kind: " + timer.kind); + + if (typeof timer.fun == 'string') { + let code = timer.fun; + timer.fun = () => chromeAPI.sandbox.evaluate(exports, code); + } else if (typeof timer.fun != 'function') { + throw new Error('Unsupported callback type' + typeof timer.fun); + } + + let id = registerMethod(onFire, timer.delay); + function onFire() { + try { + if (timer.kind == "timeout") + delete _timers[id]; + timer.fun.apply(null, timer.args); + } catch(e) { + console.exception(e); + let wrapper = { + instanceOfError: instanceOf(e, Error), + value: e, + }; + if (wrapper.instanceOfError) { + wrapper.value = { + message: e.message, + fileName: e.fileName, + lineNumber: e.lineNumber, + stack: e.stack, + name: e.name, + }; + } + pipe.emit('error', wrapper); + } + } + _timers[id] = timer; + return id; + } + + // copied from sdk/lang/type.js since modules are not available here + function instanceOf(value, Type) { + var isConstructorNameSame; + var isConstructorSourceSame; + + // If `instanceof` returned `true` we know result right away. + var isInstanceOf = value instanceof Type; + + // If `instanceof` returned `false` we do ducktype check since `Type` may be + // from a different sandbox. If a constructor of the `value` or a constructor + // of the value's prototype has same name and source we assume that it's an + // instance of the Type. + if (!isInstanceOf && value) { + isConstructorNameSame = value.constructor.name === Type.name; + isConstructorSourceSame = String(value.constructor) == String(Type); + isInstanceOf = (isConstructorNameSame && isConstructorSourceSame) || + instanceOf(Object.getPrototypeOf(value), Type); + } + return isInstanceOf; + } + + function unregisterTimer(id) { + if (!(id in _timers)) + return; + let { kind } = _timers[id]; + delete _timers[id]; + if (kind == "timeout") + chromeClearTimeout(id); + else if (kind == "interval") + chromeClearInterval(id); + else + throw new Error("Unknown timer kind: " + kind); + } + + function disableAllTimers() { + Object.keys(_timers).forEach(unregisterTimer); + } + + exports.setTimeout = function ContentScriptSetTimeout(callback, delay) { + return registerTimer({ + kind: "timeout", + fun: callback, + delay: delay, + args: Array.slice(arguments, 2) + }); + }; + exports.clearTimeout = function ContentScriptClearTimeout(id) { + unregisterTimer(id); + }; + + exports.setInterval = function ContentScriptSetInterval(callback, delay) { + return registerTimer({ + kind: "interval", + fun: callback, + delay: delay, + args: Array.slice(arguments, 2) + }); + }; + exports.clearInterval = function ContentScriptClearInterval(id) { + unregisterTimer(id); + }; + + // On page-hide, save a list of all existing timers before disabling them, + // in order to be able to restore them on page-show. + // These events are fired when the page goes in/out of bfcache. + // https://developer.mozilla.org/En/Working_with_BFCache + let frozenTimers = []; + pipe.on("pageshow", function onPageShow() { + frozenTimers.forEach(registerTimer); + }); + pipe.on("pagehide", function onPageHide() { + frozenTimers = []; + for (let id in _timers) + frozenTimers.push(_timers[id]); + disableAllTimers(); + // Some other pagehide listeners may register some timers that won't be + // frozen as this particular pagehide listener is called first. + // So freeze these timers on next cycle. + chromeSetTimeout(function () { + for (let id in _timers) + frozenTimers.push(_timers[id]); + disableAllTimers(); + }, 0); + }); + + // Unregister all timers when the page is destroyed + // (i.e. when it is removed from bfcache) + pipe.on("detach", function clearTimeouts() { + disableAllTimers(); + _timers = {}; + frozenTimers = []; + }); + }, + + injectMessageAPI: function injectMessageAPI(exports, pipe, console) { + + let ContentWorker = this; + let { eventEmitter: port, emit : portEmit } = + ContentWorker.createEventEmitter(pipe.emit.bind(null, "event")); + pipe.on("event", portEmit); + + let self = { + port: port, + postMessage: pipe.emit.bind(null, "message"), + on: pipe.on.bind(null), + once: pipe.once.bind(null), + removeListener: pipe.removeListener.bind(null), + }; + Object.defineProperty(exports, "self", { + value: self + }); + }, + + injectOptions: function (exports, options) { + Object.defineProperty( exports.self, "options", { value: JSON.parse( options ) }); + }, + + inject: function (exports, chromeAPI, emitToChrome, options) { + let ContentWorker = this; + let { pipe, onChromeEvent } = + ContentWorker.createPipe(emitToChrome); + + ContentWorker.injectConsole(exports, pipe); + ContentWorker.injectTimers(exports, chromeAPI, pipe, exports.console); + ContentWorker.injectMessageAPI(exports, pipe, exports.console); + if ( options !== undefined ) { + ContentWorker.injectOptions(exports, options); + } + + Object.freeze( exports.self ); + + return onChromeEvent; + } +}); diff --git a/toolkit/jetpack/sdk/content/content.js b/toolkit/jetpack/sdk/content/content.js new file mode 100644 index 000000000..9655223a3 --- /dev/null +++ b/toolkit/jetpack/sdk/content/content.js @@ -0,0 +1,17 @@ +/* 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": "deprecated" +}; + +const { deprecateUsage } = require('../util/deprecate'); + +Object.defineProperty(exports, "Worker", { + get: function() { + deprecateUsage('`sdk/content/content` is deprecated. Please use `sdk/content/worker` directly.'); + return require('./worker').Worker; + } +}); diff --git a/toolkit/jetpack/sdk/content/context-menu.js b/toolkit/jetpack/sdk/content/context-menu.js new file mode 100644 index 000000000..2955e2f09 --- /dev/null +++ b/toolkit/jetpack/sdk/content/context-menu.js @@ -0,0 +1,408 @@ +/* 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"; + +const { Class } = require("../core/heritage"); +const self = require("../self"); +const { WorkerChild } = require("./worker-child"); +const { getInnerId } = require("../window/utils"); +const { Ci } = require("chrome"); +const { Services } = require("resource://gre/modules/Services.jsm"); +const system = require('../system/events'); +const { process } = require('../remote/child'); + +// These functions are roughly copied from sdk/selection which doesn't work +// in the content process +function getElementWithSelection(window) { + let element = Services.focus.getFocusedElementForWindow(window, false, {}); + if (!element) + return null; + + try { + // Accessing selectionStart and selectionEnd on e.g. a button + // results in an exception thrown as per the HTML5 spec. See + // http://www.whatwg.org/specs/web-apps/current-work/multipage/association-of-controls-and-forms.html#textFieldSelection + + let { value, selectionStart, selectionEnd } = element; + + let hasSelection = typeof value === "string" && + !isNaN(selectionStart) && + !isNaN(selectionEnd) && + selectionStart !== selectionEnd; + + return hasSelection ? element : null; + } + catch (err) { + console.exception(err); + return null; + } +} + +function safeGetRange(selection, rangeNumber) { + try { + let { rangeCount } = selection; + let range = null; + + for (let rangeNumber = 0; rangeNumber < rangeCount; rangeNumber++ ) { + range = selection.getRangeAt(rangeNumber); + + if (range && range.toString()) + break; + + range = null; + } + + return range; + } + catch (e) { + return null; + } +} + +function getSelection(window) { + let selection = window.getSelection(); + let range = safeGetRange(selection); + if (range) + return range.toString(); + + let node = getElementWithSelection(window); + if (!node) + return null; + + return node.value.substring(node.selectionStart, node.selectionEnd); +} + +//These are used by PageContext.isCurrent below. If the popupNode or any of +//its ancestors is one of these, Firefox uses a tailored context menu, and so +//the page context doesn't apply. +const NON_PAGE_CONTEXT_ELTS = [ + Ci.nsIDOMHTMLAnchorElement, + Ci.nsIDOMHTMLAppletElement, + Ci.nsIDOMHTMLAreaElement, + Ci.nsIDOMHTMLButtonElement, + Ci.nsIDOMHTMLCanvasElement, + Ci.nsIDOMHTMLEmbedElement, + Ci.nsIDOMHTMLImageElement, + Ci.nsIDOMHTMLInputElement, + Ci.nsIDOMHTMLMapElement, + Ci.nsIDOMHTMLMediaElement, + Ci.nsIDOMHTMLMenuElement, + Ci.nsIDOMHTMLObjectElement, + Ci.nsIDOMHTMLOptionElement, + Ci.nsIDOMHTMLSelectElement, + Ci.nsIDOMHTMLTextAreaElement, +]; + +// List all editable types of inputs. Or is it better to have a list +// of non-editable inputs? +var editableInputs = { + email: true, + number: true, + password: true, + search: true, + tel: true, + text: true, + textarea: true, + url: true +}; + +var CONTEXTS = {}; + +var Context = Class({ + initialize: function(id) { + this.id = id; + }, + + adjustPopupNode: function adjustPopupNode(popupNode) { + return popupNode; + }, + + // Gets state to pass through to the parent process for the node the user + // clicked on + getState: function(popupNode) { + return false; + } +}); + +// Matches when the context-clicked node doesn't have any of +// NON_PAGE_CONTEXT_ELTS in its ancestors +CONTEXTS.PageContext = Class({ + extends: Context, + + getState: function(popupNode) { + // If there is a selection in the window then this context does not match + if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed) + return false; + + // If the clicked node or any of its ancestors is one of the blocked + // NON_PAGE_CONTEXT_ELTS then this context does not match + while (!(popupNode instanceof Ci.nsIDOMDocument)) { + if (NON_PAGE_CONTEXT_ELTS.some(type => popupNode instanceof type)) + return false; + + popupNode = popupNode.parentNode; + } + + return true; + } +}); + +// Matches when there is an active selection in the window +CONTEXTS.SelectionContext = Class({ + extends: Context, + + getState: function(popupNode) { + if (!popupNode.ownerDocument.defaultView.getSelection().isCollapsed) + return true; + + try { + // The node may be a text box which has selectionStart and selectionEnd + // properties. If not this will throw. + let { selectionStart, selectionEnd } = popupNode; + return !isNaN(selectionStart) && !isNaN(selectionEnd) && + selectionStart !== selectionEnd; + } + catch (e) { + return false; + } + } +}); + +// Matches when the context-clicked node or any of its ancestors matches the +// selector given +CONTEXTS.SelectorContext = Class({ + extends: Context, + + initialize: function initialize(id, selector) { + Context.prototype.initialize.call(this, id); + this.selector = selector; + }, + + adjustPopupNode: function adjustPopupNode(popupNode) { + let selector = this.selector; + + while (!(popupNode instanceof Ci.nsIDOMDocument)) { + if (popupNode.matches(selector)) + return popupNode; + + popupNode = popupNode.parentNode; + } + + return null; + }, + + getState: function(popupNode) { + return !!this.adjustPopupNode(popupNode); + } +}); + +// Matches when the page url matches any of the patterns given +CONTEXTS.URLContext = Class({ + extends: Context, + + getState: function(popupNode) { + return popupNode.ownerDocument.URL; + } +}); + +// Matches when the user-supplied predicate returns true +CONTEXTS.PredicateContext = Class({ + extends: Context, + + getState: function(node) { + let window = node.ownerDocument.defaultView; + let data = {}; + + data.documentType = node.ownerDocument.contentType; + + data.documentURL = node.ownerDocument.location.href; + data.targetName = node.nodeName.toLowerCase(); + data.targetID = node.id || null ; + + if ((data.targetName === 'input' && editableInputs[node.type]) || + data.targetName === 'textarea') { + data.isEditable = !node.readOnly && !node.disabled; + } + else { + data.isEditable = node.isContentEditable; + } + + data.selectionText = getSelection(window, "TEXT"); + + data.srcURL = node.src || null; + data.value = node.value || null; + + while (!data.linkURL && node) { + data.linkURL = node.href || null; + node = node.parentNode; + } + + return data; + }, +}); + +function instantiateContext({ id, type, args }) { + if (!(type in CONTEXTS)) { + console.error("Attempt to use unknown context " + type); + return; + } + return new CONTEXTS[type](id, ...args); +} + +var ContextWorker = Class({ + implements: [ WorkerChild ], + + // Calls the context workers context listeners and returns the first result + // that is either a string or a value that evaluates to true. If all of the + // listeners returned false then returns false. If there are no listeners, + // returns true (show the menu item by default). + getMatchedContext: function getCurrentContexts(popupNode) { + let results = this.sandbox.emitSync("context", popupNode); + if (!results.length) + return true; + return results.reduce((val, result) => val || result); + }, + + // Emits a click event in the worker's port. popupNode is the node that was + // context-clicked, and clickedItemData is the data of the item that was + // clicked. + fireClick: function fireClick(popupNode, clickedItemData) { + this.sandbox.emitSync("click", popupNode, clickedItemData); + } +}); + +// Gets the item's content script worker for a window, creating one if necessary +// Once created it will be automatically destroyed when the window unloads. +// If there is not content scripts for the item then null will be returned. +function getItemWorkerForWindow(item, window) { + if (!item.contentScript && !item.contentScriptFile) + return null; + + let id = getInnerId(window); + let worker = item.workerMap.get(id); + + if (worker) + return worker; + + worker = ContextWorker({ + id: item.id, + window, + manager: item.manager, + contentScript: item.contentScript, + contentScriptFile: item.contentScriptFile, + onDetach: function() { + item.workerMap.delete(id); + } + }); + + item.workerMap.set(id, worker); + + return worker; +} + +// A very simple remote proxy for every item. It's job is to provide data for +// the main process to use to determine visibility state and to call into +// content scripts when clicked. +var RemoteItem = Class({ + initialize: function(options, manager) { + this.id = options.id; + this.contexts = options.contexts.map(instantiateContext); + this.contentScript = options.contentScript; + this.contentScriptFile = options.contentScriptFile; + + this.manager = manager; + + this.workerMap = new Map(); + keepAlive.set(this.id, this); + }, + + destroy: function() { + for (let worker of this.workerMap.values()) { + worker.destroy(); + } + keepAlive.delete(this.id); + }, + + activate: function(popupNode, data) { + let worker = getItemWorkerForWindow(this, popupNode.ownerDocument.defaultView); + if (!worker) + return; + + for (let context of this.contexts) + popupNode = context.adjustPopupNode(popupNode); + + worker.fireClick(popupNode, data); + }, + + // Fills addonInfo with state data to send through to the main process + getContextState: function(popupNode, addonInfo) { + if (!(self.id in addonInfo)) { + addonInfo[self.id] = { + processID: process.id, + items: {} + }; + } + + let worker = getItemWorkerForWindow(this, popupNode.ownerDocument.defaultView); + let contextStates = {}; + for (let context of this.contexts) + contextStates[context.id] = context.getState(popupNode); + + addonInfo[self.id].items[this.id] = { + // It isn't ideal to create a PageContext for every item but there isn't + // a good shared place to do it. + pageContext: (new CONTEXTS.PageContext()).getState(popupNode), + contextStates, + hasWorker: !!worker, + workerContext: worker ? worker.getMatchedContext(popupNode) : true + } + } +}); +exports.RemoteItem = RemoteItem; + +// Holds remote items for this frame. +var keepAlive = new Map(); + +// Called to create remote proxies for items. If they already exist we destroy +// and recreate. This can happen if the item changes in some way or in odd +// timing cases where the frame script is create around the same time as the +// item is created in the main process +process.port.on('sdk/contextmenu/createitems', (process, items) => { + for (let itemoptions of items) { + let oldItem = keepAlive.get(itemoptions.id); + if (oldItem) { + oldItem.destroy(); + } + + let item = new RemoteItem(itemoptions, this); + } +}); + +process.port.on('sdk/contextmenu/destroyitems', (process, items) => { + for (let id of items) { + let item = keepAlive.get(id); + item.destroy(); + } +}); + +var lastPopupNode = null; + +system.on('content-contextmenu', ({ subject }) => { + let { event: { target: popupNode }, addonInfo } = subject.wrappedJSObject; + lastPopupNode = popupNode; + + for (let item of keepAlive.values()) { + item.getContextState(popupNode, addonInfo); + } +}, true); + +process.port.on('sdk/contextmenu/activateitems', (process, items, data) => { + for (let id of items) { + let item = keepAlive.get(id); + if (!item) + continue; + + item.activate(lastPopupNode, data); + } +}); diff --git a/toolkit/jetpack/sdk/content/events.js b/toolkit/jetpack/sdk/content/events.js new file mode 100644 index 000000000..c085b6179 --- /dev/null +++ b/toolkit/jetpack/sdk/content/events.js @@ -0,0 +1,57 @@ +/* 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": "experimental" +}; + +const { Ci } = require("chrome"); +const { open } = require("../event/dom"); +const { observe } = require("../event/chrome"); +const { filter, merge, map, expand } = require("../event/utils"); +const { windows } = require("../window/utils"); +const { events: windowEvents } = require("sdk/window/events"); + +// Note: Please note that even though pagehide event is included +// it's not observable reliably since it's not always triggered +// when closing tabs. Implementation can be imrpoved once that +// event will be necessary. +var TYPES = ["DOMContentLoaded", "load", "pageshow", "pagehide"]; + +var insert = observe("document-element-inserted"); +var windowCreate = merge([ + observe("content-document-global-created"), + observe("chrome-document-global-created") +]); +var create = map(windowCreate, function({target, data, type}) { + return { target: target.document, type: type, data: data } +}); + +function streamEventsFrom({document}) { + // Map supported event types to a streams of those events on the given + // `window` for the inserted document and than merge these streams into + // single form stream off all window state change events. + let stateChanges = TYPES.map(function(type) { + return open(document, type, { capture: true }); + }); + + // Since load events on document occur for every loded resource + return filter(merge(stateChanges), function({target}) { + return target instanceof Ci.nsIDOMDocument + }) +} +exports.streamEventsFrom = streamEventsFrom; + +var opened = windows(null, { includePrivate: true }); +var state = merge(opened.map(streamEventsFrom)); + + +var futureReady = filter(windowEvents, ({type}) => + type === "DOMContentLoaded"); +var futureWindows = map(futureReady, ({target}) => target); +var futureState = expand(futureWindows, streamEventsFrom); + +exports.events = merge([insert, create, state, futureState]); diff --git a/toolkit/jetpack/sdk/content/l10n-html.js b/toolkit/jetpack/sdk/content/l10n-html.js new file mode 100644 index 000000000..f324623dc --- /dev/null +++ b/toolkit/jetpack/sdk/content/l10n-html.js @@ -0,0 +1,133 @@ +/* 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 { Ci, Cc, Cu } = require("chrome"); +const core = require("../l10n/core"); +const { loadSheet, removeSheet } = require("../stylesheet/utils"); +const { process, frames } = require("../remote/child"); +var observerService = Cc["@mozilla.org/observer-service;1"] + .getService(Ci.nsIObserverService); +const { ShimWaiver } = Cu.import("resource://gre/modules/ShimWaiver.jsm"); +const addObserver = ShimWaiver.getProperty(observerService, "addObserver"); +const removeObserver = ShimWaiver.getProperty(observerService, "removeObserver"); + +const assetsURI = require('../self').data.url(); + +const hideSheetUri = "data:text/css,:root {visibility: hidden !important;}"; + +function translateElementAttributes(element) { + // Translateable attributes + const attrList = ['title', 'accesskey', 'alt', 'label', 'placeholder']; + const ariaAttrMap = { + 'ariaLabel': 'aria-label', + 'ariaValueText': 'aria-valuetext', + 'ariaMozHint': 'aria-moz-hint' + }; + const attrSeparator = '.'; + + // Try to translate each of the attributes + for (let attribute of attrList) { + const data = core.get(element.dataset.l10nId + attrSeparator + attribute); + if (data) + element.setAttribute(attribute, data); + } + + // Look for the aria attribute translations that match fxOS's aliases + for (let attrAlias in ariaAttrMap) { + const data = core.get(element.dataset.l10nId + attrSeparator + attrAlias); + if (data) + element.setAttribute(ariaAttrMap[attrAlias], data); + } +} + +// Taken from Gaia: +// https://github.com/andreasgal/gaia/blob/04fde2640a7f40314643016a5a6c98bf3755f5fd/webapi.js#L1470 +function translateElement(element) { + element = element || document; + + // check all translatable children (= w/ a `data-l10n-id' attribute) + var children = element.querySelectorAll('*[data-l10n-id]'); + var elementCount = children.length; + for (var i = 0; i < elementCount; i++) { + var child = children[i]; + + // translate the child + var key = child.dataset.l10nId; + var data = core.get(key); + if (data) + child.textContent = data; + + translateElementAttributes(child); + } +} +exports.translateElement = translateElement; + +function onDocumentReady2Translate(event) { + let document = event.target; + document.removeEventListener("DOMContentLoaded", onDocumentReady2Translate, + false); + + translateElement(document); + + try { + // Finally display document when we finished replacing all text content + if (document.defaultView) + removeSheet(document.defaultView, hideSheetUri, 'user'); + } + catch(e) { + console.exception(e); + } +} + +function onContentWindow(document) { + // Accept only HTML documents + if (!(document instanceof Ci.nsIDOMHTMLDocument)) + return; + + // Bug 769483: data:URI documents instanciated with nsIDOMParser + // have a null `location` attribute at this time + if (!document.location) + return; + + // Accept only document from this addon + if (document.location.href.indexOf(assetsURI) !== 0) + return; + + try { + // First hide content of the document in order to have content blinking + // between untranslated and translated states + loadSheet(document.defaultView, hideSheetUri, 'user'); + } + catch(e) { + console.exception(e); + } + // Wait for DOM tree to be built before applying localization + document.addEventListener("DOMContentLoaded", onDocumentReady2Translate, + false); +} + +// Listen to creation of content documents in order to translate them as soon +// as possible in their loading process +const ON_CONTENT = "document-element-inserted"; +let enabled = false; +function enable() { + if (enabled) + return; + addObserver(onContentWindow, ON_CONTENT, false); + enabled = true; +} +process.port.on("sdk/l10n/html/enable", enable); + +function disable() { + if (!enabled) + return; + removeObserver(onContentWindow, ON_CONTENT); + enabled = false; +} +process.port.on("sdk/l10n/html/disable", disable); diff --git a/toolkit/jetpack/sdk/content/loader.js b/toolkit/jetpack/sdk/content/loader.js new file mode 100644 index 000000000..e4f0dd2aa --- /dev/null +++ b/toolkit/jetpack/sdk/content/loader.js @@ -0,0 +1,74 @@ +/* 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 { isValidURI, isLocalURL, URL } = require('../url'); +const { contract } = require('../util/contract'); +const { isString, isNil, instanceOf, isJSONable } = require('../lang/type'); +const { validateOptions, + string, array, object, either, required } = require('../deprecated/api-utils'); + +const isValidScriptFile = (value) => + (isString(value) || instanceOf(value, URL)) && isLocalURL(value); + +// map of property validations +const valid = { + contentURL: { + is: either(string, object), + ok: url => isNil(url) || isLocalURL(url) || isValidURI(url), + msg: 'The `contentURL` option must be a valid URL.' + }, + contentScriptFile: { + is: either(string, object, array), + ok: value => isNil(value) || [].concat(value).every(isValidScriptFile), + msg: 'The `contentScriptFile` option must be a local URL or an array of URLs.' + }, + contentScript: { + is: either(string, array), + ok: value => isNil(value) || [].concat(value).every(isString), + msg: 'The `contentScript` option must be a string or an array of strings.' + }, + contentScriptWhen: { + is: required(string), + map: value => value || 'end', + ok: value => ~['start', 'ready', 'end'].indexOf(value), + msg: 'The `contentScriptWhen` option must be either "start", "ready" or "end".' + }, + contentScriptOptions: { + ok: value => isNil(value) || isJSONable(value), + msg: 'The contentScriptOptions should be a jsonable value.' + } +}; +exports.validationAttributes = valid; + +/** + * Shortcut function to validate property with validation. + * @param {Object|Number|String} suspect + * value to validate + * @param {Object} validation + * validation rule passed to `api-utils` + */ +function validate(suspect, validation) { + return validateOptions( + { $: suspect }, + { $: validation } + ).$; +} + +function Allow(script) { + return { + get script() { + return script; + }, + set script(value) { + script = !!value; + } + }; +} + +exports.contract = contract(valid); diff --git a/toolkit/jetpack/sdk/content/mod.js b/toolkit/jetpack/sdk/content/mod.js new file mode 100644 index 000000000..81fe9ee42 --- /dev/null +++ b/toolkit/jetpack/sdk/content/mod.js @@ -0,0 +1,68 @@ +/* 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": "experimental" +}; + +const { Ci } = require("chrome"); +const { dispatcher } = require("../util/dispatcher"); +const { add, remove, iterator } = require("../lang/weak-set"); + +var getTargetWindow = dispatcher("getTargetWindow"); + +getTargetWindow.define(function (target) { + if (target instanceof Ci.nsIDOMWindow) + return target; + if (target instanceof Ci.nsIDOMDocument) + return target.defaultView || null; + + return null; +}); + +exports.getTargetWindow = getTargetWindow; + +var attachTo = dispatcher("attachTo"); +exports.attachTo = attachTo; + +var detachFrom = dispatcher("detatchFrom"); +exports.detachFrom = detachFrom; + +function attach(modification, target) { + if (!modification) + return; + + let window = getTargetWindow(target); + + attachTo(modification, window); + + // modification are stored per content; `window` reference can still be the + // same even if the content is changed, therefore `document` is used instead. + add(modification, window.document); +} +exports.attach = attach; + +function detach(modification, target) { + if (!modification) + return; + + if (target) { + let window = getTargetWindow(target); + detachFrom(modification, window); + remove(modification, window.document); + } + else { + let documents = iterator(modification); + for (let document of documents) { + let window = document.defaultView; + // The window might have already gone away + if (!window) + continue; + detachFrom(modification, document.defaultView); + remove(modification, document); + } + } +} +exports.detach = detach; diff --git a/toolkit/jetpack/sdk/content/page-mod.js b/toolkit/jetpack/sdk/content/page-mod.js new file mode 100644 index 000000000..8ff9b1e7b --- /dev/null +++ b/toolkit/jetpack/sdk/content/page-mod.js @@ -0,0 +1,236 @@ +/* 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": "stable" +}; + +const { getAttachEventType } = require('../content/utils'); +const { Class } = require('../core/heritage'); +const { Disposable } = require('../core/disposable'); +const { WeakReference } = require('../core/reference'); +const { WorkerChild } = require('./worker-child'); +const { EventTarget } = require('../event/target'); +const { on, emit, once, setListeners } = require('../event/core'); +const { on: domOn, removeListener: domOff } = require('../dom/events'); +const { isRegExp, isUndefined } = require('../lang/type'); +const { merge } = require('../util/object'); +const { isBrowser, getFrames } = require('../window/utils'); +const { getTabs, getURI: getTabURI } = require('../tabs/utils'); +const { ignoreWindow } = require('../private-browsing/utils'); +const { Style } = require("../stylesheet/style"); +const { attach, detach } = require("../content/mod"); +const { has, hasAny } = require("../util/array"); +const { Rules } = require("../util/rules"); +const { List, addListItem, removeListItem } = require('../util/list'); +const { when } = require("../system/unload"); +const { uuid } = require('../util/uuid'); +const { frames, process } = require('../remote/child'); + +const pagemods = new Map(); +const styles = new WeakMap(); +var styleFor = (mod) => styles.get(mod); + +// Helper functions +var modMatchesURI = (mod, uri) => mod.include.matchesAny(uri) && !mod.exclude.matchesAny(uri); + +/** + * PageMod constructor (exported below). + * @constructor + */ +const ChildPageMod = Class({ + implements: [ + EventTarget, + Disposable, + ], + setup: function PageMod(model) { + merge(this, model); + + // Set listeners on {PageMod} itself, not the underlying worker, + // like `onMessage`, as it'll get piped. + setListeners(this, model); + + function deserializeRules(rules) { + for (let rule of rules) { + yield rule.type == "string" ? rule.value + : new RegExp(rule.pattern, rule.flags); + } + } + + let include = [...deserializeRules(this.include)]; + this.include = Rules(); + this.include.add.apply(this.include, include); + + let exclude = [...deserializeRules(this.exclude)]; + this.exclude = Rules(); + this.exclude.add.apply(this.exclude, exclude); + + if (this.contentStyle || this.contentStyleFile) { + styles.set(this, Style({ + uri: this.contentStyleFile, + source: this.contentStyle + })); + } + + pagemods.set(this.id, this); + this.seenDocuments = new WeakMap(); + + // `applyOnExistingDocuments` has to be called after `pagemods.add()` + // otherwise its calls to `onContent` method won't do anything. + if (has(this.attachTo, 'existing')) + applyOnExistingDocuments(this); + }, + + dispose: function() { + let style = styleFor(this); + if (style) + detach(style); + + for (let i in this.include) + this.include.remove(this.include[i]); + + pagemods.delete(this.id); + } +}); + +function onContentWindow({ target: document }) { + // Return if we have no pagemods + if (pagemods.size === 0) + return; + + let window = document.defaultView; + // XML documents don't have windows, and we don't yet support them. + if (!window) + return; + + // Frame event listeners are bound to the frame the event came from by default + let frame = this; + // We apply only on documents in tabs of Firefox + if (!frame.isTab) + return; + + // When the tab is private, only addons with 'private-browsing' flag in + // their package.json can apply content script to private documents + if (ignoreWindow(window)) + return; + + for (let pagemod of pagemods.values()) { + if (modMatchesURI(pagemod, window.location.href)) + onContent(pagemod, window); + } +} +frames.addEventListener("DOMDocElementInserted", onContentWindow, true); + +function applyOnExistingDocuments (mod) { + for (let frame of frames) { + // Fake a newly created document + let window = frame.content; + // on startup with e10s, contentWindow might not exist yet, + // in which case we will get notified by "document-element-inserted". + if (!window || !window.frames) + return; + let uri = window.location.href; + if (has(mod.attachTo, "top") && modMatchesURI(mod, uri)) + onContent(mod, window); + if (has(mod.attachTo, "frame")) + getFrames(window). + filter(iframe => modMatchesURI(mod, iframe.location.href)). + forEach(frame => onContent(mod, frame)); + } +} + +function createWorker(mod, window) { + let workerId = String(uuid()); + + // Instruct the parent to connect to this worker. Do this first so the parent + // side is connected before the worker attempts to send any messages there + let frame = frames.getFrameForWindow(window.top); + frame.port.emit('sdk/page-mod/worker-create', mod.id, { + id: workerId, + url: window.location.href + }); + + // Create a child worker and notify the parent + let worker = WorkerChild({ + id: workerId, + window: window, + contentScript: mod.contentScript, + contentScriptFile: mod.contentScriptFile, + contentScriptOptions: mod.contentScriptOptions + }); + + once(worker, 'detach', () => worker.destroy()); +} + +function onContent (mod, window) { + let isTopDocument = window.top === window; + // Is a top level document and `top` is not set, ignore + if (isTopDocument && !has(mod.attachTo, "top")) + return; + // Is a frame document and `frame` is not set, ignore + if (!isTopDocument && !has(mod.attachTo, "frame")) + return; + + // ensure we attach only once per document + let seen = mod.seenDocuments; + if (seen.has(window.document)) + return; + seen.set(window.document, true); + + let style = styleFor(mod); + if (style) + attach(style, window); + + // Immediately evaluate content script if the document state is already + // matching contentScriptWhen expectations + if (isMatchingAttachState(mod, window)) { + createWorker(mod, window); + return; + } + + let eventName = getAttachEventType(mod) || 'load'; + domOn(window, eventName, function onReady (e) { + if (e.target.defaultView !== window) + return; + domOff(window, eventName, onReady, true); + createWorker(mod, window); + + // Attaching is asynchronous so if the document is already loaded we will + // miss the pageshow event so send a synthetic one. + if (window.document.readyState == "complete") { + mod.on('attach', worker => { + try { + worker.send('pageshow'); + emit(worker, 'pageshow'); + } + catch (e) { + // This can fail if an earlier attach listener destroyed the worker + } + }); + } + }, true); +} + +function isMatchingAttachState (mod, window) { + let state = window.document.readyState; + return 'start' === mod.contentScriptWhen || + // Is `load` event already dispatched? + 'complete' === state || + // Is DOMContentLoaded already dispatched and waiting for it? + ('ready' === mod.contentScriptWhen && state === 'interactive') +} + +process.port.on('sdk/page-mod/create', (process, model) => { + if (pagemods.has(model.id)) + return; + + new ChildPageMod(model); +}); + +process.port.on('sdk/page-mod/destroy', (process, id) => { + let mod = pagemods.get(id); + if (mod) + mod.destroy(); +}); diff --git a/toolkit/jetpack/sdk/content/page-worker.js b/toolkit/jetpack/sdk/content/page-worker.js new file mode 100644 index 000000000..e9e741120 --- /dev/null +++ b/toolkit/jetpack/sdk/content/page-worker.js @@ -0,0 +1,154 @@ +/* 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"; + +const { frames } = require("../remote/child"); +const { Class } = require("../core/heritage"); +const { Disposable } = require('../core/disposable'); +const { data } = require("../self"); +const { once } = require("../dom/events"); +const { getAttachEventType } = require("./utils"); +const { Rules } = require('../util/rules'); +const { uuid } = require('../util/uuid'); +const { WorkerChild } = require("./worker-child"); +const { Cc, Ci, Cu } = require("chrome"); +const { observe } = require("../event/chrome"); +const { on } = require("../event/core"); + +const appShell = Cc["@mozilla.org/appshell/appShellService;1"].getService(Ci.nsIAppShellService); + +const { XPCOMUtils } = require("resource://gre/modules/XPCOMUtils.jsm"); + +const pages = new Map(); + +const DOC_INSERTED = "document-element-inserted"; + +function isValidURL(page, url) { + return !page.rules || page.rules.matchesAny(url); +} + +const ChildPage = Class({ + implements: [ Disposable ], + setup: function(frame, id, options) { + this.id = id; + this.frame = frame; + this.options = options; + + this.webNav = appShell.createWindowlessBrowser(false); + this.docShell.allowJavascript = this.options.allow.script; + + // Accessing the browser's window forces the initial about:blank document to + // be created before we start listening for notifications + this.contentWindow; + + this.webProgress.addProgressListener(this, Ci.nsIWebProgress.NOTIFY_LOCATION); + + pages.set(this.id, this); + + this.contentURL = options.contentURL; + + if (options.include) { + this.rules = Rules(); + this.rules.add.apply(this.rules, [].concat(options.include)); + } + }, + + dispose: function() { + pages.delete(this.id); + this.webProgress.removeProgressListener(this); + this.webNav.close(); + this.webNav = null; + }, + + attachWorker: function() { + if (!isValidURL(this, this.contentWindow.location.href)) + return; + + this.options.id = uuid().toString(); + this.options.window = this.contentWindow; + this.frame.port.emit("sdk/frame/connect", this.id, { + id: this.options.id, + url: this.contentWindow.document.documentURIObject.spec + }); + new WorkerChild(this.options); + }, + + get docShell() { + return this.webNav.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDocShell); + }, + + get webProgress() { + return this.docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + }, + + get contentWindow() { + return this.docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIDOMWindow); + }, + + get contentURL() { + return this.options.contentURL; + }, + set contentURL(url) { + this.options.contentURL = url; + + url = this.options.contentURL ? data.url(this.options.contentURL) : "about:blank"; + this.webNav.loadURI(url, Ci.nsIWebNavigation.LOAD_FLAGS_NONE, null, null, null); + }, + + onLocationChange: function(progress, request, location, flags) { + // Ignore inner-frame events + if (progress != this.webProgress) + return; + // Ignore events that don't change the document + if (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) + return; + + let event = getAttachEventType(this.options); + // Attaching at the start of the load is handled by the + // document-element-inserted listener. + if (event == DOC_INSERTED) + return; + + once(this.contentWindow, event, () => { + this.attachWorker(); + }, false); + }, + + QueryInterface: XPCOMUtils.generateQI(["nsIWebProgressListener", "nsISupportsWeakReference"]) +}); + +on(observe(DOC_INSERTED), "data", ({ target }) => { + let page = Array.from(pages.values()).find(p => p.contentWindow.document === target); + if (!page) + return; + + if (getAttachEventType(page.options) == DOC_INSERTED) + page.attachWorker(); +}); + +frames.port.on("sdk/frame/create", (frame, id, options) => { + new ChildPage(frame, id, options); +}); + +frames.port.on("sdk/frame/set", (frame, id, params) => { + let page = pages.get(id); + if (!page) + return; + + if ("allowScript" in params) + page.docShell.allowJavascript = params.allowScript; + if ("contentURL" in params) + page.contentURL = params.contentURL; +}); + +frames.port.on("sdk/frame/destroy", (frame, id) => { + let page = pages.get(id); + if (!page) + return; + + page.destroy(); +}); diff --git a/toolkit/jetpack/sdk/content/sandbox.js b/toolkit/jetpack/sdk/content/sandbox.js new file mode 100644 index 000000000..096ba5c87 --- /dev/null +++ b/toolkit/jetpack/sdk/content/sandbox.js @@ -0,0 +1,426 @@ +/* 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 { Class } = require('../core/heritage'); +const { EventTarget } = require('../event/target'); +const { on, off, emit } = require('../event/core'); +const { events } = require('./sandbox/events'); +const { requiresAddonGlobal } = require('./utils'); +const { delay: async } = require('../lang/functional'); +const { Ci, Cu, Cc } = require('chrome'); +const timer = require('../timers'); +const { URL } = require('../url'); +const { sandbox, evaluate, load } = require('../loader/sandbox'); +const { merge } = require('../util/object'); +const { getTabForContentWindowNoShim } = require('../tabs/utils'); +const { getInnerId } = require('../window/utils'); +const { PlainTextConsole } = require('../console/plain-text'); +const { data } = require('../self');const { isChildLoader } = require('../remote/core'); +// WeakMap of sandboxes so we can access private values +const sandboxes = new WeakMap(); + +/* Trick the linker in order to ensure shipping these files in the XPI. + require('./content-worker.js'); + Then, retrieve URL of these files in the XPI: +*/ +var prefix = module.uri.split('sandbox.js')[0]; +const CONTENT_WORKER_URL = prefix + 'content-worker.js'; +const metadata = require('@loader/options').metadata; + +// 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 = (metadata && metadata['permissions']) || {}; +const EXPANDED_PRINCIPALS = permissions['cross-domain-content'] || []; + +const waiveSecurityMembrane = !!permissions['unsafe-content-script']; + +const nsIScriptSecurityManager = Ci.nsIScriptSecurityManager; +const secMan = Cc["@mozilla.org/scriptsecuritymanager;1"]. + getService(Ci.nsIScriptSecurityManager); + +const JS_VERSION = '1.8'; + +// Tests whether this window is loaded in a tab +function isWindowInTab(window) { + if (isChildLoader) { + let { frames } = require('../remote/child'); + let frame = frames.getFrameForWindow(window.top); + return frame && frame.isTab; + } + else { + // The deprecated sync worker API still does everything in the main process + return getTabForContentWindowNoShim(window); + } +} + +const WorkerSandbox = Class({ + implements: [ EventTarget ], + + /** + * Emit a message to the worker content sandbox + */ + emit: function emit(type, ...args) { + // JSON.stringify is buggy with cross-sandbox values, + // it may return "{}" on functions. Use a replacer to match them correctly. + let replacer = (k, v) => + typeof(v) === "function" + ? (type === "console" ? Function.toString.call(v) : void(0)) + : v; + + // Ensure having an asynchronous behavior + async(() => + emitToContent(this, JSON.stringify([type, ...args], replacer)) + ); + }, + + /** + * 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(...args) { + // because the arguments could be also non JSONable values, + // we need to ensure the array instance is created from + // the content's sandbox + return emitToContent(this, new modelFor(this).sandbox.Array(...args)); + }, + + /** + * Configures sandbox and loads content scripts into it. + * @param {Worker} worker + * content worker + */ + initialize: function WorkerSandbox(worker, window) { + let model = {}; + sandboxes.set(this, model); + model.worker = worker; + // We receive a wrapped window, that may be an xraywrapper if it's content + let proto = window; + + // TODO necessary? + // Ensure that `emit` has always the right `this` + this.emit = this.emit.bind(this); + this.emitSync = this.emitSync.bind(this); + + // Use expanded principal for content-script if the content is a + // regular web content for better isolation. + // (This behavior can be turned off for now with the unsafe-content-script + // flag to give addon developers time for making the necessary changes) + // But prevent it when the Worker isn't used for a content script but for + // injecting `addon` object into a Panel scope, for example. + // 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 = []; + let isSystemPrincipal = secMan.isSystemPrincipal( + window.document.nodePrincipal); + if (!isSystemPrincipal && !requiresAddonGlobal(worker)) { + if (EXPANDED_PRINCIPALS.length > 0) { + // 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'); + } + if (!waiveSecurityMembrane) + principals = EXPANDED_PRINCIPALS.concat(window); + } + + // Create the sandbox and bind it to window in order for content scripts to + // have access to all standard globals (window, document, ...) + let content = sandbox(principals, { + sandboxPrototype: proto, + wantXrays: !requiresAddonGlobal(worker), + wantGlobalProperties: wantGlobalProperties, + wantExportHelpers: true, + sameZoneAs: window, + metadata: { + SDKContentScript: true, + 'inner-window-id': getInnerId(window) + } + }); + model.sandbox = content; + + // 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() { + return content; + }, + get top() { + return top; + }, + get parent() { + return 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! + // + // Note that because waivers aren't propagated between origins, we + // need the unsafeWindow getter to live in the sandbox. + var unsafeWindowGetter = + new content.Function('return window.wrappedJSObject || window;'); + Object.defineProperty(content, 'unsafeWindow', {get: unsafeWindowGetter}); + + // Load trusted code that will inject content script API. + let ContentWorker = load(content, 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 + let onEvent = Cu.exportFunction(onContentEvent.bind(null, this), ContentWorker); + let chromeAPI = createChromeAPI(ContentWorker); + let result = Cu.waiveXrays(ContentWorker).inject(content, chromeAPI, onEvent, options); + + // Merge `emitToContent` into our private model of the + // WorkerSandbox so we can communicate with content script + model.emitToContent = result; + + let console = new PlainTextConsole(null, getInnerId(window)); + + // Handle messages send by this script: + setListeners(this, console); + + // Inject `addon` global into target document if document is trusted, + // `addon` in document is equivalent to `self` in content script. + if (requiresAddonGlobal(worker)) { + Object.defineProperty(getUnsafeWindow(window), 'addon', { + value: content.self, + configurable: true + } + ); + } + + // Inject our `console` into target document if worker doesn't have a tab + // (e.g Panel, PageWorker). + // `worker.tab` can't be used because bug 804935. + if (!isWindowInTab(window)) { + let win = getUnsafeWindow(window); + + // export our chrome console to content window, as 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'), + exception: genPropDesc('exception'), + assert: genPropDesc('assert'), + count: genPropDesc('count'), + table: genPropDesc('table'), + clear: genPropDesc('clear'), + dirxml: genPropDesc('dirxml'), + markTimeline: genPropDesc('markTimeline'), + timeline: genPropDesc('timeline'), + timelineEnd: genPropDesc('timelineEnd'), + timeStamp: genPropDesc('timeStamp'), + }; + + Object.defineProperties(con, properties); + Cu.makeObjectPropsNormal(con); + + win.console = con; + }; + + emit(events, "content-script-before-inserted", { + window: window, + worker: worker + }); + + // 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) + importScripts.apply(null, [this].concat(contentScriptFile)); + + if (contentScript) { + evaluateIn( + this, + Array.isArray(contentScript) ? contentScript.join(';\n') : contentScript + ); + } + }, + destroy: function destroy(reason) { + if (typeof reason != 'string') + reason = ''; + this.emitSync('event', 'detach', reason); + let model = modelFor(this); + model.sandbox = null + model.worker = null; + }, + +}); + +exports.WorkerSandbox = WorkerSandbox; + +/** + * 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. + */ +function importScripts (workerSandbox, ...urls) { + let { worker, sandbox } = modelFor(workerSandbox); + for (let i in urls) { + let contentScriptFile = data.url(urls[i]); + + try { + let uri = URL(contentScriptFile); + if (uri.scheme === 'resource') + load(sandbox, String(uri)); + else + throw Error('Unsupported `contentScriptFile` url: ' + String(uri)); + } + catch(e) { + emit(worker, 'error', e); + } + } +} + +function setListeners (workerSandbox, console) { + let { worker } = modelFor(workerSandbox); + // console.xxx calls + workerSandbox.on('console', function consoleListener (kind, ...args) { + console[kind].apply(console, args); + }); + + // self.postMessage calls + workerSandbox.on('message', function postMessage(data) { + // destroyed? + if (worker) + emit(worker, 'message', data); + }); + + // self.port.emit calls + workerSandbox.on('event', function portEmit (...eventArgs) { + // If not destroyed, emit event information to worker + // `eventArgs` has the event name as first element, + // and remaining elements are additional arguments to pass + if (worker) + emit.apply(null, [worker.port].concat(eventArgs)); + }); + + // unwrap, recreate and propagate async Errors thrown from content-script + workerSandbox.on('error', function onError({instanceOfError, value}) { + if (worker) { + let error = value; + if (instanceOfError) { + error = new Error(value.message, value.fileName, value.lineNumber); + error.stack = value.stack; + error.name = value.name; + } + emit(worker, 'error', error); + } + }); +} + +/** + * Evaluates code in the sandbox. + * @param {String} code + * JavaScript source to evaluate. + * @param {String} [filename='javascript:' + code] + * Name of the file + */ +function evaluateIn (workerSandbox, code, filename) { + let { worker, sandbox } = modelFor(workerSandbox); + try { + evaluate(sandbox, code, filename || 'javascript:' + code); + } + catch(e) { + emit(worker, 'error', e); + } +} + +/** + * Method called by the worker sandbox when it needs to send a message + */ +function onContentEvent (workerSandbox, args) { + // As `emit`, we ensure having an asynchronous behavior + async(function () { + // We emit event to chrome/addon listeners + emit.apply(null, [workerSandbox].concat(JSON.parse(args))); + }); +} + + +function modelFor (workerSandbox) { + return sandboxes.get(workerSandbox); +} + +function getUnsafeWindow (win) { + return win.wrappedJSObject || win; +} + +function emitToContent (workerSandbox, args) { + return modelFor(workerSandbox).emitToContent(args); +} + +function createChromeAPI (scope) { + return Cu.cloneInto({ + timers: { + setTimeout: timer.setTimeout.bind(timer), + setInterval: timer.setInterval.bind(timer), + clearTimeout: timer.clearTimeout.bind(timer), + clearInterval: timer.clearInterval.bind(timer), + }, + sandbox: { + evaluate: evaluate, + }, + }, scope, {cloneFunctions: true}); +} diff --git a/toolkit/jetpack/sdk/content/sandbox/events.js b/toolkit/jetpack/sdk/content/sandbox/events.js new file mode 100644 index 000000000..d6f7eb004 --- /dev/null +++ b/toolkit/jetpack/sdk/content/sandbox/events.js @@ -0,0 +1,12 @@ +/* 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": "experimental" +}; + +const events = {}; +exports.events = events; diff --git a/toolkit/jetpack/sdk/content/tab-events.js b/toolkit/jetpack/sdk/content/tab-events.js new file mode 100644 index 000000000..9e244a853 --- /dev/null +++ b/toolkit/jetpack/sdk/content/tab-events.js @@ -0,0 +1,58 @@ +/* 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"; + +const { Ci } = require('chrome'); +const system = require('sdk/system/events'); +const { frames } = require('sdk/remote/child'); +const { WorkerChild } = require('sdk/content/worker-child'); + +// map observer topics to tab event names +const EVENTS = { + 'content-document-global-created': 'create', + 'chrome-document-global-created': 'create', + 'content-document-interactive': 'ready', + 'chrome-document-interactive': 'ready', + 'content-document-loaded': 'load', + 'chrome-document-loaded': 'load', +// 'content-page-shown': 'pageshow', // bug 1024105 +} + +function topicListener({ subject, type }) { + // NOTE detect the window from the subject: + // - on *-global-created the subject is the window + // - in the other cases it is the document object + let window = subject instanceof Ci.nsIDOMWindow ? subject : subject.defaultView; + if (!window){ + return; + } + let frame = frames.getFrameForWindow(window); + if (frame) { + let readyState = frame.content.document.readyState; + frame.port.emit('sdk/tab/event', EVENTS[type], { readyState }); + } +} + +for (let topic in EVENTS) + system.on(topic, topicListener, true); + +// bug 1024105 - content-page-shown notification doesn't pass persisted param +function eventListener({target, type, persisted}) { + let frame = this; + if (target === frame.content.document) { + frame.port.emit('sdk/tab/event', type, persisted); + } +} +frames.addEventListener('pageshow', eventListener, true); + +frames.port.on('sdk/tab/attach', (frame, options) => { + options.window = frame.content; + new WorkerChild(options); +}); + +// Forward the existent frames's readyState. +for (let frame of frames) { + let readyState = frame.content.document.readyState; + frame.port.emit('sdk/tab/event', 'init', { readyState }); +} diff --git a/toolkit/jetpack/sdk/content/thumbnail.js b/toolkit/jetpack/sdk/content/thumbnail.js new file mode 100644 index 000000000..783615fc6 --- /dev/null +++ b/toolkit/jetpack/sdk/content/thumbnail.js @@ -0,0 +1,51 @@ +/* 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 { Cc, Ci, Cu } = require('chrome'); +const AppShellService = Cc['@mozilla.org/appshell/appShellService;1']. + getService(Ci.nsIAppShellService); + +const NS = 'http://www.w3.org/1999/xhtml'; +const COLOR = 'rgb(255,255,255)'; + +/** + * Creates canvas element with a thumbnail of the passed window. + * @param {Window} window + * @returns {Element} + */ +function getThumbnailCanvasForWindow(window) { + let aspectRatio = 0.5625; // 16:9 + let thumbnail = AppShellService.hiddenDOMWindow.document + .createElementNS(NS, 'canvas'); + thumbnail.mozOpaque = true; + thumbnail.width = Math.ceil(window.screen.availWidth / 5.75); + thumbnail.height = Math.round(thumbnail.width * aspectRatio); + let ctx = thumbnail.getContext('2d'); + let snippetWidth = window.innerWidth * .6; + let scale = thumbnail.width / snippetWidth; + ctx.scale(scale, scale); + ctx.drawWindow(window, window.scrollX, window.scrollY, snippetWidth, + snippetWidth * aspectRatio, COLOR); + return thumbnail; +} +exports.getThumbnailCanvasForWindow = getThumbnailCanvasForWindow; + +/** + * Creates Base64 encoded data URI of the thumbnail for the passed window. + * @param {Window} window + * @returns {String} + */ +exports.getThumbnailURIForWindow = function getThumbnailURIForWindow(window) { + return getThumbnailCanvasForWindow(window).toDataURL() +}; + +// default 80x45 blank when not available +exports.BLANK = 'data:image/png;base64,' + + 'iVBORw0KGgoAAAANSUhEUgAAAFAAAAAtCAYAAAA5reyyAAAAJElEQVRoge3BAQ'+ + 'EAAACCIP+vbkhAAQAAAAAAAAAAAAAAAADXBjhtAAGQ0AF/AAAAAElFTkSuQmCC'; diff --git a/toolkit/jetpack/sdk/content/utils.js b/toolkit/jetpack/sdk/content/utils.js new file mode 100644 index 000000000..90995a614 --- /dev/null +++ b/toolkit/jetpack/sdk/content/utils.js @@ -0,0 +1,105 @@ +/* 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' +}; + +var { merge } = require('../util/object'); +var { data } = require('../self'); +var assetsURI = data.url(); +var isArray = Array.isArray; +var method = require('../../method/core'); +var { uuid } = require('../util/uuid'); + +const isAddonContent = ({ contentURL }) => + contentURL && data.url(contentURL).startsWith(assetsURI); + +exports.isAddonContent = isAddonContent; + +function hasContentScript({ contentScript, contentScriptFile }) { + return (isArray(contentScript) ? contentScript.length > 0 : + !!contentScript) || + (isArray(contentScriptFile) ? contentScriptFile.length > 0 : + !!contentScriptFile); +} +exports.hasContentScript = hasContentScript; + +function requiresAddonGlobal(model) { + return model.injectInDocument || (isAddonContent(model) && !hasContentScript(model)); +} +exports.requiresAddonGlobal = requiresAddonGlobal; + +function getAttachEventType(model) { + if (!model) return null; + let when = model.contentScriptWhen; + return requiresAddonGlobal(model) ? 'document-element-inserted' : + when === 'start' ? 'document-element-inserted' : + when === 'ready' ? 'DOMContentLoaded' : + when === 'end' ? 'load' : + null; +} +exports.getAttachEventType = getAttachEventType; + +var attach = method('worker-attach'); +exports.attach = attach; + +var connect = method('worker-connect'); +exports.connect = connect; + +var detach = method('worker-detach'); +exports.detach = detach; + +var destroy = method('worker-destroy'); +exports.destroy = destroy; + +function WorkerHost (workerFor) { + // Define worker properties that just proxy to underlying worker + return ['postMessage', 'port', 'url', 'tab'].reduce(function(proto, name) { + // Use descriptor properties instead so we can call + // the worker function in the context of the worker so we + // don't have to create new functions with `fn.bind(worker)` + let descriptorProp = { + value: function (...args) { + let worker = workerFor(this); + return worker[name].apply(worker, args); + } + }; + + let accessorProp = { + get: function () { return workerFor(this)[name]; }, + set: function (value) { workerFor(this)[name] = value; } + }; + + Object.defineProperty(proto, name, merge({ + enumerable: true, + configurable: false, + }, isDescriptor(name) ? descriptorProp : accessorProp)); + return proto; + }, {}); + + function isDescriptor (prop) { + return ~['postMessage'].indexOf(prop); + } +} +exports.WorkerHost = WorkerHost; + +function makeChildOptions(options) { + function makeStringArray(arrayOrValue) { + if (!arrayOrValue) + return []; + return [].concat(arrayOrValue).map(String); + } + + return { + id: String(uuid()), + contentScript: makeStringArray(options.contentScript), + contentScriptFile: makeStringArray(options.contentScriptFile), + contentScriptOptions: options.contentScriptOptions ? + JSON.stringify(options.contentScriptOptions) : + null, + } +} +exports.makeChildOptions = makeChildOptions; diff --git a/toolkit/jetpack/sdk/content/worker-child.js b/toolkit/jetpack/sdk/content/worker-child.js new file mode 100644 index 000000000..dbf65a933 --- /dev/null +++ b/toolkit/jetpack/sdk/content/worker-child.js @@ -0,0 +1,158 @@ +/* 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'; + +const { merge } = require('../util/object'); +const { Class } = require('../core/heritage'); +const { emit } = require('../event/core'); +const { EventTarget } = require('../event/target'); +const { getInnerId, getByInnerId } = require('../window/utils'); +const { instanceOf, isObject } = require('../lang/type'); +const system = require('../system/events'); +const { when } = require('../system/unload'); +const { WorkerSandbox } = require('./sandbox'); +const { Ci } = require('chrome'); +const { process, frames } = require('../remote/child'); + +const EVENTS = { + 'chrome-page-shown': 'pageshow', + 'content-page-shown': 'pageshow', + 'chrome-page-hidden': 'pagehide', + 'content-page-hidden': 'pagehide', + 'inner-window-destroyed': 'detach', +} + +// The parent Worker must have been created (or an async message sent to spawn +// its creation) before creating the WorkerChild or messages from the content +// script to the parent will get lost. +const WorkerChild = Class({ + implements: [EventTarget], + + initialize(options) { + merge(this, options); + keepAlive.set(this.id, this); + + this.windowId = getInnerId(this.window); + if (this.contentScriptOptions) + this.contentScriptOptions = JSON.parse(this.contentScriptOptions); + + this.port = EventTarget(); + this.port.on('*', this.send.bind(this, 'event')); + this.on('*', this.send.bind(this)); + + this.observe = this.observe.bind(this); + + for (let topic in EVENTS) + system.on(topic, this.observe); + + this.receive = this.receive.bind(this); + process.port.on('sdk/worker/message', this.receive); + + this.sandbox = WorkerSandbox(this, this.window); + + // If the document has an unexpected readyState, its worker-child instance is initialized + // as frozen until one of the known readyState is reached. + let initialDocumentReadyState = this.window.document.readyState; + this.frozen = [ + "loading", "interactive", "complete" + ].includes(initialDocumentReadyState) ? false : true; + + if (this.frozen) { + console.warn("SDK worker-child started as frozen on unexpected initial document.readyState", { + initialDocumentReadyState, windowLocation: this.window.location.href, + }); + } + + this.frozenMessages = []; + this.on('pageshow', () => { + this.frozen = false; + this.frozenMessages.forEach(args => this.sandbox.emit(...args)); + this.frozenMessages = []; + }); + this.on('pagehide', () => { + this.frozen = true; + }); + }, + + // messages + receive(process, id, args) { + if (id !== this.id) + return; + args = JSON.parse(args); + + if (this.frozen) + this.frozenMessages.push(args); + else + this.sandbox.emit(...args); + + if (args[0] === 'detach') + this.destroy(args[1]); + }, + + send(...args) { + process.port.emit('sdk/worker/event', this.id, JSON.stringify(args, exceptions)); + }, + + // notifications + observe({ type, subject }) { + if (!this.sandbox) + return; + + if (subject.defaultView && getInnerId(subject.defaultView) === this.windowId) { + this.sandbox.emitSync(EVENTS[type]); + emit(this, EVENTS[type]); + } + + if (type === 'inner-window-destroyed' && + subject.QueryInterface(Ci.nsISupportsPRUint64).data === this.windowId) { + this.destroy(); + } + }, + + get frame() { + return frames.getFrameForWindow(this.window.top); + }, + + // detach/destroy: unload and release the sandbox + destroy(reason) { + if (!this.sandbox) + return; + + for (let topic in EVENTS) + system.off(topic, this.observe); + process.port.off('sdk/worker/message', this.receive); + + this.sandbox.destroy(reason); + this.sandbox = null; + keepAlive.delete(this.id); + + this.send('detach'); + } +}) +exports.WorkerChild = WorkerChild; + +// Error instances JSON poorly +function exceptions(key, value) { + if (!isObject(value) || !instanceOf(value, Error)) + return value; + let _errorType = value.constructor.name; + let { message, fileName, lineNumber, stack, name } = value; + return { _errorType, message, fileName, lineNumber, stack, name }; +} + +// workers for windows in this tab +var keepAlive = new Map(); + +process.port.on('sdk/worker/create', (process, options, cpows) => { + options.window = cpows.window; + let worker = new WorkerChild(options); + + let frame = frames.getFrameForWindow(options.window.top); + frame.port.emit('sdk/worker/connect', options.id, options.window.location.href); +}); + +when(reason => { + for (let worker of keepAlive.values()) + worker.destroy(reason); +}); diff --git a/toolkit/jetpack/sdk/content/worker.js b/toolkit/jetpack/sdk/content/worker.js new file mode 100644 index 000000000..39b940a88 --- /dev/null +++ b/toolkit/jetpack/sdk/content/worker.js @@ -0,0 +1,180 @@ +/* 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 { emit } = require('../event/core'); +const { omit, merge } = require('../util/object'); +const { Class } = require('../core/heritage'); +const { method } = require('../lang/functional'); +const { getInnerId } = require('../window/utils'); +const { EventTarget } = require('../event/target'); +const { isPrivate } = require('../private-browsing/utils'); +const { getTabForBrowser, getTabForContentWindowNoShim, getBrowserForTab } = require('../tabs/utils'); +const { attach, connect, detach, destroy, makeChildOptions } = require('./utils'); +const { ensure } = require('../system/unload'); +const { on: observe } = require('../system/events'); +const { Ci, Cu } = require('chrome'); +const { modelFor: tabFor } = require('sdk/model/core'); +const { remoteRequire, processes, frames } = require('../remote/parent'); +remoteRequire('sdk/content/worker-child'); + +const workers = new WeakMap(); +var modelFor = (worker) => workers.get(worker); + +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."; + +// a handle for communication between content script and addon code +const Worker = Class({ + implements: [EventTarget], + + initialize(options = {}) { + ensure(this, 'detach'); + + let model = { + attached: false, + destroyed: false, + earlyEvents: [], // fired before worker was attached + frozen: true, // document is not yet active + options, + }; + workers.set(this, model); + + this.on('detach', this.detach); + EventTarget.prototype.initialize.call(this, options); + + this.receive = this.receive.bind(this); + + this.port = EventTarget(); + this.port.emit = this.send.bind(this, 'event'); + this.postMessage = this.send.bind(this, 'message'); + + if ('window' in options) { + let window = options.window; + delete options.window; + attach(this, window); + } + }, + + // messages + receive(process, id, args) { + let model = modelFor(this); + if (id !== model.id || !model.attached) + return; + args = JSON.parse(args); + if (model.destroyed && args[0] != 'detach') + return; + + if (args[0] === 'event') + emit(this.port, ...args.slice(1)) + else + emit(this, ...args); + }, + + send(...args) { + let model = modelFor(this); + if (model.destroyed && args[0] !== 'detach') + throw new Error(ERR_DESTROYED); + + if (!model.attached) { + model.earlyEvents.push(args); + return; + } + + processes.port.emit('sdk/worker/message', model.id, JSON.stringify(args)); + }, + + // properties + get url() { + let { url } = modelFor(this); + return url; + }, + + get contentURL() { + return this.url; + }, + + get tab() { + require('sdk/tabs'); + let { frame } = modelFor(this); + if (!frame) + return null; + let rawTab = getTabForBrowser(frame.frameElement); + return rawTab && tabFor(rawTab); + }, + + toString: () => '[object Worker]', + + detach: method(detach), + destroy: method(destroy), +}) +exports.Worker = Worker; + +attach.define(Worker, function(worker, window) { + let model = modelFor(worker); + if (model.attached) + detach(worker); + + let childOptions = makeChildOptions(model.options); + processes.port.emitCPOW('sdk/worker/create', [childOptions], { window }); + + let listener = (frame, id, url) => { + if (id != childOptions.id) + return; + frames.port.off('sdk/worker/connect', listener); + connect(worker, frame, { id, url }); + }; + frames.port.on('sdk/worker/connect', listener); +}); + +connect.define(Worker, function(worker, frame, { id, url }) { + let model = modelFor(worker); + if (model.attached) + detach(worker); + + model.id = id; + model.frame = frame; + model.url = url; + + // Messages from content -> chrome come through the process message manager + // since that lives longer than the frame message manager + processes.port.on('sdk/worker/event', worker.receive); + + model.attached = true; + model.destroyed = false; + model.frozen = false; + + model.earlyEvents.forEach(args => worker.send(...args)); + model.earlyEvents = []; + emit(worker, 'attach'); +}); + +// unload and release the child worker, release window reference +detach.define(Worker, function(worker) { + let model = modelFor(worker); + if (!model.attached) + return; + + processes.port.off('sdk/worker/event', worker.receive); + model.attached = false; + model.destroyed = true; + emit(worker, 'detach'); +}); + +isPrivate.define(Worker, ({ tab }) => isPrivate(tab)); + +// Something in the parent side has destroyed the worker, tell the child to +// detach, the child will respond when it has detached +destroy.define(Worker, function(worker, reason) { + let model = modelFor(worker); + model.destroyed = true; + if (!model.attached) + return; + + worker.send('detach', reason); +}); |