From ac46df8daea09899ce30dc8fd70986e258c746bf Mon Sep 17 00:00:00 2001 From: "Matt A. Tobin" Date: Fri, 9 Feb 2018 06:46:43 -0500 Subject: Move Add-on SDK source to toolkit/jetpack --- toolkit/jetpack/framescript/FrameScriptManager.jsm | 27 +++ toolkit/jetpack/framescript/content.jsm | 94 +++++++++ toolkit/jetpack/framescript/context-menu.js | 215 +++++++++++++++++++++ toolkit/jetpack/framescript/manager.js | 26 +++ toolkit/jetpack/framescript/util.js | 25 +++ 5 files changed, 387 insertions(+) create mode 100644 toolkit/jetpack/framescript/FrameScriptManager.jsm create mode 100644 toolkit/jetpack/framescript/content.jsm create mode 100644 toolkit/jetpack/framescript/context-menu.js create mode 100644 toolkit/jetpack/framescript/manager.js create mode 100644 toolkit/jetpack/framescript/util.js (limited to 'toolkit/jetpack/framescript') diff --git a/toolkit/jetpack/framescript/FrameScriptManager.jsm b/toolkit/jetpack/framescript/FrameScriptManager.jsm new file mode 100644 index 000000000..1ce6ceb07 --- /dev/null +++ b/toolkit/jetpack/framescript/FrameScriptManager.jsm @@ -0,0 +1,27 @@ +/* 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 globalMM = Components.classes["@mozilla.org/globalmessagemanager;1"]. + getService(Components.interfaces.nsIMessageListenerManager); + +// Load frame scripts from the same dir as this module. +// Since this JSM will be loaded using require(), PATH will be +// overridden while running tests, just like any other module. +const PATH = __URI__.replace('framescript/FrameScriptManager.jsm', ''); + +// Builds a unique loader ID for this runtime. We prefix with the SDK path so +// overriden versions of the SDK don't conflict +var LOADER_ID = 0; +this.getNewLoaderID = () => { + return PATH + ":" + LOADER_ID++; +} + +const frame_script = function(contentFrame, PATH) { + let { registerContentFrame } = Components.utils.import(PATH + 'framescript/content.jsm', {}); + registerContentFrame(contentFrame); +} +globalMM.loadFrameScript("data:,(" + frame_script.toString() + ")(this, " + JSON.stringify(PATH) + ");", true); + +this.EXPORTED_SYMBOLS = ['getNewLoaderID']; diff --git a/toolkit/jetpack/framescript/content.jsm b/toolkit/jetpack/framescript/content.jsm new file mode 100644 index 000000000..eaee26be3 --- /dev/null +++ b/toolkit/jetpack/framescript/content.jsm @@ -0,0 +1,94 @@ +/* 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 { utils: Cu, classes: Cc, interfaces: Ci } = Components; +const { Services } = Cu.import('resource://gre/modules/Services.jsm'); + +const cpmm = Cc['@mozilla.org/childprocessmessagemanager;1']. + getService(Ci.nsISyncMessageSender); + +this.EXPORTED_SYMBOLS = ["registerContentFrame"]; + +// This may be an overriden version of the SDK so use the PATH as a key for the +// initial messages before we have a loaderID. +const PATH = __URI__.replace('framescript/content.jsm', ''); + +const { Loader } = Cu.import(PATH + 'toolkit/loader.js', {}); + +// one Loader instance per addon (per @loader/options to be precise) +var addons = new Map(); + +// Tell the parent that a new process is ready +cpmm.sendAsyncMessage('sdk/remote/process/start', { + modulePath: PATH +}); + +// Load a child process module loader with the given loader options +cpmm.addMessageListener('sdk/remote/process/load', ({ data: { modulePath, loaderID, options, reason } }) => { + if (modulePath != PATH) + return; + + // During startup races can mean we get a second load message + if (addons.has(loaderID)) + return; + + options.waiveInterposition = true; + + let loader = Loader.Loader(options); + let addon = { + loader, + require: Loader.Require(loader, { id: 'LoaderHelper' }), + } + addons.set(loaderID, addon); + + cpmm.sendAsyncMessage('sdk/remote/process/attach', { + loaderID, + processID: Services.appinfo.processID, + isRemote: Services.appinfo.processType != Ci.nsIXULRuntime.PROCESS_TYPE_DEFAULT + }); + + addon.child = addon.require('sdk/remote/child'); + + for (let contentFrame of frames.values()) + addon.child.registerContentFrame(contentFrame); +}); + +// Unload a child process loader +cpmm.addMessageListener('sdk/remote/process/unload', ({ data: { loaderID, reason } }) => { + if (!addons.has(loaderID)) + return; + + let addon = addons.get(loaderID); + Loader.unload(addon.loader, reason); + + // We want to drop the reference to the loader but never allow creating a new + // loader with the same ID + addons.set(loaderID, {}); +}) + + +var frames = new Set(); + +this.registerContentFrame = contentFrame => { + contentFrame.addEventListener("unload", () => { + unregisterContentFrame(contentFrame); + }, false); + + frames.add(contentFrame); + + for (let addon of addons.values()) { + if ("child" in addon) + addon.child.registerContentFrame(contentFrame); + } +}; + +function unregisterContentFrame(contentFrame) { + frames.delete(contentFrame); + + for (let addon of addons.values()) { + if ("child" in addon) + addon.child.unregisterContentFrame(contentFrame); + } +} diff --git a/toolkit/jetpack/framescript/context-menu.js b/toolkit/jetpack/framescript/context-menu.js new file mode 100644 index 000000000..3915b7cd8 --- /dev/null +++ b/toolkit/jetpack/framescript/context-menu.js @@ -0,0 +1,215 @@ +/* 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 { query, constant, cache } = require("sdk/lang/functional"); +const { pairs, each, map, object } = require("sdk/util/sequence"); +const { nodeToMessageManager } = require("./util"); + +// Decorator function that takes `f` function and returns one that attempts +// to run `f` with given arguments. In case of exception error is logged +// and `fallback` is returned instead. +const Try = (fn, fallback=null) => (...args) => { + try { + return fn(...args); + } catch(error) { + console.error(error); + return fallback; + } +}; + +// Decorator funciton that takes `f` function and returns one that returns +// JSON cloned result of whatever `f` returns for given arguments. +const JSONReturn = f => (...args) => JSON.parse(JSON.stringify(f(...args))); + +const Null = constant(null); + +// Table of readers mapped to field names they're going to be reading. +const readers = Object.create(null); +// Read function takes "contextmenu" event target `node` and returns table of +// read field names mapped to appropriate values. Read uses above defined read +// table to read data for all registered readers. +const read = node => + object(...map(([id, read]) => [id, read(node, id)], pairs(readers))); + +// Table of built-in readers, each takes a descriptor and returns a reader: +// descriptor -> node -> JSON +const parsers = Object.create(null) +// Function takes a descriptor of the remotely defined reader and parsese it +// to construct a local reader that's going to read out data from context menu +// target. +const parse = descriptor => { + const parser = parsers[descriptor.category]; + if (!parser) { + console.error("Unknown reader descriptor was received", descriptor, `"${descriptor.category}"`); + return Null + } + return Try(parser(descriptor)); +} + +// TODO: Test how chrome's mediaType behaves to try and match it's behavior. +const HTML_NS = "http://www.w3.org/1999/xhtml"; +const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; +const SVG_NS = "http://www.w3.org/2000/svg"; + +// Firefox always creates a HTMLVideoElement when loading an ogg file +// directly. If the media is actually audio, be smarter and provide a +// context menu with audio operations. +// Source: https://github.com/mozilla/gecko-dev/blob/28c2fca3753c5371643843fc2f2f205146b083b7/browser/base/content/nsContextMenu.js#L632-L637 +const isVideoLoadingAudio = node => + node.readyState >= node.HAVE_METADATA && + (node.videoWidth == 0 || node.videoHeight == 0) + +const isVideo = node => + node instanceof node.ownerDocument.defaultView.HTMLVideoElement && + !isVideoLoadingAudio(node); + +const isAudio = node => { + const {HTMLVideoElement, HTMLAudioElement} = node.ownerDocument.defaultView; + return node instanceof HTMLAudioElement ? true : + node instanceof HTMLVideoElement ? isVideoLoadingAudio(node) : + false; +}; + +const isImage = ({namespaceURI, localName}) => + namespaceURI === HTML_NS && localName === "img" ? true : + namespaceURI === XUL_NS && localName === "image" ? true : + namespaceURI === SVG_NS && localName === "image" ? true : + false; + +parsers["reader/MediaType()"] = constant(node => + isImage(node) ? "image" : + isAudio(node) ? "audio" : + isVideo(node) ? "video" : + null); + + +const readLink = node => + node.namespaceURI === HTML_NS && node.localName === "a" ? node.href : + readLink(node.parentNode); + +parsers["reader/LinkURL()"] = constant(node => + node.matches("a, a *") ? readLink(node) : null); + +// Reader that reads out `true` if "contextmenu" `event.target` matches +// `descriptor.selector` and `false` if it does not. +parsers["reader/SelectorMatch()"] = ({selector}) => + node => node.matches(selector); + +// Accessing `selectionStart` and `selectionEnd` properties on non +// editable input nodes throw exceptions, there for we need this util +// function to guard us against them. +const getInputSelection = node => { + try { + if ("selectionStart" in node && "selectionEnd" in node) { + const {selectionStart, selectionEnd} = node; + return {selectionStart, selectionEnd} + } + } + catch(_) {} + + return null; +} + +// Selection reader does not really cares about descriptor so it is +// a constant function returning selection reader. Selection reader +// returns string of the selected text or `null` if there is no selection. +parsers["reader/Selection()"] = constant(node => { + const selection = node.ownerDocument.getSelection(); + if (!selection.isCollapsed) { + return selection.toString(); + } + // If target node is editable (text, input, textarea, etc..) document does + // not really handles selections there. There for we fallback to checking + // `selectionStart` `selectionEnd` properties and if they are present we + // extract selections manually from the `node.value`. + else { + const selection = getInputSelection(node); + const isSelected = selection && + Number.isInteger(selection.selectionStart) && + Number.isInteger(selection.selectionEnd) && + selection.selectionStart !== selection.selectionEnd; + return isSelected ? node.value.substring(selection.selectionStart, + selection.selectionEnd) : + null; + } +}); + +// Query reader just reads out properties from the node, so we just use `query` +// utility function. +parsers["reader/Query()"] = ({path}) => JSONReturn(query(path)); +// Attribute reader just reads attribute of the event target node. +parsers["reader/Attribute()"] = ({name}) => node => node.getAttribute(name); + +// Extractor reader defines generates a reader out of serialized function, who's +// return value is JSON cloned. Note: We do know source will evaluate to function +// as that's what we serialized on the other end, it's also ok if generated function +// is going to throw as registered readers are wrapped in try catch to avoid breakting +// unrelated readers. +parsers["reader/Extractor()"] = ({source}) => + JSONReturn(new Function("return (" + source + ")")()); + +// If the context-menu target node or any of its ancestors is one of these, +// Firefox uses a tailored context menu, and so the page context doesn't apply. +// There for `reader/isPage()` will read `false` in that case otherwise it's going +// to read `true`. +const nonPageElements = ["a", "applet", "area", "button", "canvas", "object", + "embed", "img", "input", "map", "video", "audio", "menu", + "option", "select", "textarea", "[contenteditable=true]"]; +const nonPageSelector = nonPageElements. + concat(nonPageElements.map(tag => `${tag} *`)). + join(", "); + +// Note: isPageContext implementation could have actually used SelectorMatch reader, +// but old implementation was also checked for collapsed selection there for to keep +// the behavior same we end up implementing a new reader. +parsers["reader/isPage()"] = constant(node => + node.ownerDocument.defaultView.getSelection().isCollapsed && + !node.matches(nonPageSelector)); + +// Reads `true` if node is in an iframe otherwise returns true. +parsers["reader/isFrame()"] = constant(node => + !!node.ownerDocument.defaultView.frameElement); + +parsers["reader/isEditable()"] = constant(node => { + const selection = getInputSelection(node); + return selection ? !node.readOnly && !node.disabled : node.isContentEditable; +}); + + +// TODO: Add some reader to read out tab id. + +const onReadersUpdate = message => { + each(([id, descriptor]) => { + if (descriptor) { + readers[id] = parse(descriptor); + } + else { + delete readers[id]; + } + }, pairs(message.data)); +}; +exports.onReadersUpdate = onReadersUpdate; + + +const onContextMenu = event => { + if (!event.defaultPrevented) { + const manager = nodeToMessageManager(event.target); + manager.sendSyncMessage("sdk/context-menu/read", read(event.target), readers); + } +}; +exports.onContextMenu = onContextMenu; + + +const onContentFrame = (frame) => { + // Listen for contextmenu events in on this frame. + frame.addEventListener("contextmenu", onContextMenu); + // Listen to registered reader changes and update registry. + frame.addMessageListener("sdk/context-menu/readers", onReadersUpdate); + + // Request table of readers (if this is loaded in a new process some table + // changes may be missed, this is way to sync up). + frame.sendAsyncMessage("sdk/context-menu/readers?"); +}; +exports.onContentFrame = onContentFrame; diff --git a/toolkit/jetpack/framescript/manager.js b/toolkit/jetpack/framescript/manager.js new file mode 100644 index 000000000..1f261e1fa --- /dev/null +++ b/toolkit/jetpack/framescript/manager.js @@ -0,0 +1,26 @@ +/* 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 mime = "application/javascript"; +const requireURI = module.uri.replace("framescript/manager.js", + "toolkit/require.js"); + +const requireLoadURI = `data:${mime},this["Components"].utils.import("${requireURI}")` + +// Loads module with given `id` into given `messageManager` via shared module loader. If `init` +// string is passed, will call module export with that name and pass frame script environment +// of the `messageManager` into it. Since module will load only once per process (which is +// once for chrome proces & second for content process) it is useful to have an init function +// to setup event listeners on each content frame. +const loadModule = (messageManager, id, allowDelayed, init) => { + const moduleLoadURI = `${requireLoadURI}.require("${id}")` + const uri = init ? `${moduleLoadURI}.${init}(this)` : moduleLoadURI; + messageManager.loadFrameScript(uri, allowDelayed); +}; +exports.loadModule = loadModule; diff --git a/toolkit/jetpack/framescript/util.js b/toolkit/jetpack/framescript/util.js new file mode 100644 index 000000000..fb6834608 --- /dev/null +++ b/toolkit/jetpack/framescript/util.js @@ -0,0 +1,25 @@ +/* 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 } = require("chrome"); + +const windowToMessageManager = window => + window. + QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIDocShell). + sameTypeRootTreeItem. + QueryInterface(Ci.nsIDocShell). + QueryInterface(Ci.nsIInterfaceRequestor). + getInterface(Ci.nsIContentFrameMessageManager); +exports.windowToMessageManager = windowToMessageManager; + +const nodeToMessageManager = node => + windowToMessageManager(node.ownerDocument.defaultView); +exports.nodeToMessageManager = nodeToMessageManager; -- cgit v1.2.3