/* 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); } });