/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */ /* vim: set ts=2 sw=2 sts=2 et tw=80: */ /* 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"; var EXPORTED_SYMBOLS = ["ContextMenuChild"]; const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); const { XPCOMUtils } = ChromeUtils.import( "resource://gre/modules/XPCOMUtils.jsm" ); XPCOMUtils.defineLazyGlobalGetters(this, ["URL"]); XPCOMUtils.defineLazyModuleGetters(this, { E10SUtils: "resource://gre/modules/E10SUtils.jsm", BrowserUtils: "resource://gre/modules/BrowserUtils.jsm", findAllCssSelectors: "resource://gre/modules/css-selector.js", SpellCheckHelper: "resource://gre/modules/InlineSpellChecker.jsm", LoginManagerContent: "resource://gre/modules/LoginManagerContent.jsm", WebNavigationFrames: "resource://gre/modules/WebNavigationFrames.jsm", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.jsm", InlineSpellCheckerContent: "resource://gre/modules/InlineSpellCheckerContent.jsm", ContentDOMReference: "resource://gre/modules/ContentDOMReference.jsm", }); XPCOMUtils.defineLazyGetter(this, "PageMenuChild", () => { let tmp = {}; ChromeUtils.import("resource://gre/modules/PageMenu.jsm", tmp); return new tmp.PageMenuChild(); }); let contextMenus = new WeakMap(); class ContextMenuChild extends JSWindowActorChild { // PUBLIC constructor() { super(); this.target = null; this.context = null; this.lastMenuTarget = null; } static getTarget(browsingContext, message, key) { let actor = contextMenus.get(browsingContext); if (!actor) { throw new Error( "Can't find ContextMenu actor for browsing context with " + "ID: " + browsingContext.id ); } return actor.getTarget(message, key); } static getLastTarget(browsingContext) { let contextMenu = contextMenus.get(browsingContext); return contextMenu && contextMenu.lastMenuTarget; } receiveMessage(message) { switch (message.name) { case "ContextMenu:GetFrameTitle": { let target = ContentDOMReference.resolve(message.data.targetIdentifier); return Promise.resolve(target.ownerDocument.title); } case "ContextMenu:Canvas:ToBlobURL": { let target = ContentDOMReference.resolve(message.data.targetIdentifier); return new Promise(resolve => { target.toBlob(blob => { let blobURL = URL.createObjectURL(blob); resolve(blobURL); }); }); } case "ContextMenu:DoCustomCommand": { E10SUtils.wrapHandlingUserInput( this.contentWindow, message.data.handlingUserInput, () => PageMenuChild.executeMenu(message.data.generatedItemId) ); break; } case "ContextMenu:Hiding": { this.context = null; this.target = null; break; } case "ContextMenu:MediaCommand": { E10SUtils.wrapHandlingUserInput( this.contentWindow, message.data.handlingUserInput, () => { let media = ContentDOMReference.resolve( message.data.targetIdentifier ); switch (message.data.command) { case "play": media.play(); break; case "pause": media.pause(); break; case "loop": media.loop = !media.loop; break; case "mute": media.muted = true; break; case "unmute": media.muted = false; break; case "playbackRate": media.playbackRate = message.data.data; break; case "hidecontrols": media.removeAttribute("controls"); break; case "showcontrols": media.setAttribute("controls", "true"); break; case "fullscreen": if (this.document.fullscreenEnabled) { media.requestFullscreen(); } break; case "pictureinpicture": Services.telemetry.keyedScalarAdd( "pictureinpicture.opened_method", "contextmenu", 1 ); let event = new this.contentWindow.CustomEvent( "MozTogglePictureInPicture", { bubbles: true, }, this.contentWindow ); media.dispatchEvent(event); break; } } ); break; } case "ContextMenu:ReloadFrame": { let target = ContentDOMReference.resolve(message.data.targetIdentifier); target.ownerDocument.location.reload(message.data.forceReload); break; } case "ContextMenu:ReloadImage": { let image = ContentDOMReference.resolve(message.data.targetIdentifier); if (image instanceof Ci.nsIImageLoadingContent) { image.forceReload(); } break; } case "ContextMenu:SearchFieldBookmarkData": { let node = ContentDOMReference.resolve(message.data.targetIdentifier); let charset = node.ownerDocument.characterSet; let formBaseURI = Services.io.newURI(node.form.baseURI, charset); let formURI = Services.io.newURI( node.form.getAttribute("action"), charset, formBaseURI ); let spec = formURI.spec; let isURLEncoded = node.form.method.toUpperCase() == "POST" && (node.form.enctype == "application/x-www-form-urlencoded" || node.form.enctype == ""); let title = node.ownerDocument.title; function escapeNameValuePair([aName, aValue]) { if (isURLEncoded) { return escape(aName + "=" + aValue); } return escape(aName) + "=" + escape(aValue); } let formData = new this.contentWindow.FormData(node.form); formData.delete(node.name); formData = Array.from(formData).map(escapeNameValuePair); formData.push( escape(node.name) + (isURLEncoded ? escape("=%s") : "=%s") ); let postData; if (isURLEncoded) { postData = formData.join("&"); } else { let separator = spec.includes("?") ? "&" : "?"; spec += separator + formData.join("&"); } return Promise.resolve({ spec, title, postData, charset }); } case "ContextMenu:SaveVideoFrameAsImage": { let video = ContentDOMReference.resolve(message.data.targetIdentifier); let canvas = this.document.createElementNS( "http://www.w3.org/1999/xhtml", "canvas" ); canvas.width = video.videoWidth; canvas.height = video.videoHeight; let ctxDraw = canvas.getContext("2d"); ctxDraw.drawImage(video, 0, 0); return Promise.resolve(canvas.toDataURL("image/jpeg", "")); } case "ContextMenu:SetAsDesktopBackground": { let target = ContentDOMReference.resolve(message.data.targetIdentifier); // Paranoia: check disableSetDesktopBackground again, in case the // image changed since the context menu was initiated. let disable = this._disableSetDesktopBackground(target); if (!disable) { try { BrowserUtils.urlSecurityCheck( target.currentURI.spec, target.ownerDocument.nodePrincipal ); let canvas = this.document.createElement("canvas"); canvas.width = target.naturalWidth; canvas.height = target.naturalHeight; let ctx = canvas.getContext("2d"); ctx.drawImage(target, 0, 0); let dataURL = canvas.toDataURL(); let url = new URL(target.ownerDocument.location.href).pathname; let imageName = url.substr(url.lastIndexOf("/") + 1); return Promise.resolve({ failed: false, dataURL, imageName }); } catch (e) { Cu.reportError(e); } } return Promise.resolve({ failed: true, dataURL: null, imageName: null, }); } case "ContextMenu:PluginCommand": { let target = ContentDOMReference.resolve(message.data.targetIdentifier); let actor = this.manager.getActor("Plugin"); let { command } = message.data; if (command == "play") { actor.showClickToPlayNotification(target, true); } else if (command == "hide") { actor.hideClickToPlayOverlay(target); } break; } } return undefined; } /** * Returns the event target of the context menu, using a locally stored * reference if possible. If not, and aMessage.objects is defined, * aMessage.objects[aKey] is returned. Otherwise null. * @param {Object} aMessage Message with a objects property * @param {String} aKey Key for the target on aMessage.objects * @return {Object} Context menu target */ getTarget(aMessage, aKey = "target") { return this.target || (aMessage.objects && aMessage.objects[aKey]); } // PRIVATE _isXULTextLinkLabel(aNode) { const XUL_NS = "http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"; return ( aNode.namespaceURI == XUL_NS && aNode.tagName == "label" && aNode.classList.contains("text-link") && aNode.href ); } // Generate fully qualified URL for clicked-on link. _getLinkURL() { let href = this.context.link.href; if (href) { // Handle SVG links: if (typeof href == "object" && href.animVal) { return this._makeURLAbsolute(this.context.link.baseURI, href.animVal); } return href; } href = this.context.link.getAttribute("href") || this.context.link.getAttributeNS("http://www.w3.org/1999/xlink", "href"); if (!href || !href.match(/\S/)) { // Without this we try to save as the current doc, // for example, HTML case also throws if empty throw new Error("Empty href"); } return this._makeURLAbsolute(this.context.link.baseURI, href); } _getLinkURI() { try { return Services.io.newURI(this.context.linkURL); } catch (ex) { // e.g. empty URL string } return null; } // Get text of link. _getLinkText() { let text = this._gatherTextUnder(this.context.link); if (!text || !text.match(/\S/)) { text = this.context.link.getAttribute("title"); if (!text || !text.match(/\S/)) { text = this.context.link.getAttribute("alt"); if (!text || !text.match(/\S/)) { text = this.context.linkURL; } } } return text; } _getLinkProtocol() { if (this.context.linkURI) { return this.context.linkURI.scheme; // can be |undefined| } return null; } // Returns true if clicked-on link targets a resource that can be saved. _isLinkSaveable(aLink) { // We don't do the Right Thing for news/snews yet, so turn them off // until we do. return ( this.context.linkProtocol && !( this.context.linkProtocol == "mailto" || this.context.linkProtocol == "javascript" || this.context.linkProtocol == "news" || this.context.linkProtocol == "snews" ) ); } // Gather all descendent text under given document node. _gatherTextUnder(root) { let text = ""; let node = root.firstChild; let depth = 1; while (node && depth > 0) { // See if this node is text. if (node.nodeType == node.TEXT_NODE) { // Add this text to our collection. text += " " + node.data; } else if (node instanceof this.contentWindow.HTMLImageElement) { // If it has an "alt" attribute, add that. let altText = node.getAttribute("alt"); if (altText && altText != "") { text += " " + altText; } } // Find next node to test. // First, see if this node has children. if (node.hasChildNodes()) { // Go to first child. node = node.firstChild; depth++; } else { // No children, try next sibling (or parent next sibling). while (depth > 0 && !node.nextSibling) { node = node.parentNode; depth--; } if (node.nextSibling) { node = node.nextSibling; } } } // Strip leading and tailing whitespace. text = text.trim(); // Compress remaining whitespace. text = text.replace(/\s+/g, " "); return text; } // Returns a "url"-type computed style attribute value, with the url() stripped. _getComputedURL(aElem, aProp) { let urls = aElem.ownerGlobal.getComputedStyle(aElem).getCSSImageURLs(aProp); if (!urls.length) { return null; } if (urls.length != 1) { throw new Error("found multiple URLs"); } return urls[0]; } _makeURLAbsolute(aBase, aUrl) { return Services.io.newURI(aUrl, null, Services.io.newURI(aBase)).spec; } _isProprietaryDRM() { return ( this.context.target.isEncrypted && this.context.target.mediaKeys && this.context.target.mediaKeys.keySystem != "org.w3.clearkey" ); } _isMediaURLReusable(aURL) { if (aURL.startsWith("blob:")) { return URL.isValidURL(aURL); } return true; } _isTargetATextBox(node) { if (node instanceof this.contentWindow.HTMLInputElement) { return node.mozIsTextField(false); } return node instanceof this.contentWindow.HTMLTextAreaElement; } /** * Check if we are in the parent process and the current iframe is the RDM iframe. */ _isTargetRDMFrame(node) { return ( Services.appinfo.processType === Services.appinfo.PROCESS_TYPE_DEFAULT && node.tagName === "iframe" && node.hasAttribute("mozbrowser") ); } _isSpellCheckEnabled(aNode) { // We can always force-enable spellchecking on textboxes if (this._isTargetATextBox(aNode)) { return true; } // We can never spell check something which is not content editable let editable = aNode.isContentEditable; if (!editable && aNode.ownerDocument) { editable = aNode.ownerDocument.designMode == "on"; } if (!editable) { return false; } // Otherwise make sure that nothing in the parent chain disables spellchecking return aNode.spellcheck; } _disableSetDesktopBackground(aTarget) { // Disable the Set as Desktop Background menu item if we're still trying // to load the image or the load failed. if (!(aTarget instanceof Ci.nsIImageLoadingContent)) { return true; } if ("complete" in aTarget && !aTarget.complete) { return true; } if (aTarget.currentURI.schemeIs("javascript")) { return true; } let request = aTarget.getRequest(Ci.nsIImageLoadingContent.CURRENT_REQUEST); if (!request) { return true; } return false; } handleEvent(aEvent) { contextMenus.set(this.browsingContext, this); let defaultPrevented = aEvent.defaultPrevented; if (!Services.prefs.getBoolPref("dom.event.contextmenu.enabled")) { let plugin = null; try { plugin = aEvent.composedTarget.QueryInterface( Ci.nsIObjectLoadingContent ); } catch (e) {} if ( plugin && plugin.displayedType == Ci.nsIObjectLoadingContent.TYPE_PLUGIN ) { // Don't open a context menu for plugins. return; } defaultPrevented = false; } if (defaultPrevented) { return; } if (this._isTargetRDMFrame(aEvent.composedTarget)) { // The target is in the DevTools RDM iframe, a proper context menu event // will be created from the RDM browser. return; } let doc = aEvent.composedTarget.ownerDocument; let { mozDocumentURIIfNotForErrorPages: docLocation, characterSet: charSet, baseURI, } = doc; docLocation = docLocation && docLocation.spec; let frameOuterWindowID = WebNavigationFrames.getFrameId(doc.defaultView); let loginFillInfo = LoginManagerContent.getFieldContext( aEvent.composedTarget ); // The same-origin check will be done in nsContextMenu.openLinkInTab. let parentAllowsMixedContent = !!this.docShell.mixedContentChannel; let disableSetDesktopBackground = null; // Media related cache info parent needs for saving let contentType = null; let contentDisposition = null; if ( aEvent.composedTarget.nodeType == aEvent.composedTarget.ELEMENT_NODE && aEvent.composedTarget instanceof Ci.nsIImageLoadingContent && aEvent.composedTarget.currentURI ) { disableSetDesktopBackground = this._disableSetDesktopBackground( aEvent.composedTarget ); try { let imageCache = Cc["@mozilla.org/image/tools;1"] .getService(Ci.imgITools) .getImgCacheForDocument(doc); // The image cache's notion of where this image is located is // the currentURI of the image loading content. let props = imageCache.findEntryProperties( aEvent.composedTarget.currentURI, doc ); try { contentType = props.get("type", Ci.nsISupportsCString).data; } catch (e) {} try { contentDisposition = props.get( "content-disposition", Ci.nsISupportsCString ).data; } catch (e) {} } catch (e) {} } let selectionInfo = BrowserUtils.getSelectionDetails(this.contentWindow); let loadContext = this.docShell.QueryInterface(Ci.nsILoadContext); let userContextId = loadContext.originAttributes.userContextId; let popupNodeSelectors = findAllCssSelectors(aEvent.composedTarget); this._setContext(aEvent); let context = this.context; this.target = context.target; let spellInfo = null; let editFlags = null; let principal = null; let customMenuItems = null; let referrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance( Ci.nsIReferrerInfo ); referrerInfo.initWithNode(aEvent.composedTarget); referrerInfo = E10SUtils.serializeReferrerInfo(referrerInfo); // In the case "onLink" we may have to send link referrerInfo to use in // _openLinkInParameters let linkReferrerInfo = null; if (context.onLink) { linkReferrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance( Ci.nsIReferrerInfo ); linkReferrerInfo.initWithNode(context.link); } let target = context.target; if (target) { this._cleanContext(); } editFlags = SpellCheckHelper.isEditable( aEvent.composedTarget, this.contentWindow ); if (editFlags & SpellCheckHelper.SPELLCHECKABLE) { spellInfo = InlineSpellCheckerContent.initContextMenu( aEvent, editFlags, this ); } // Set the event target first as the copy image command needs it to // determine what was context-clicked on. Then, update the state of the // commands on the context menu. this.docShell.contentViewer .QueryInterface(Ci.nsIContentViewerEdit) .setCommandNode(aEvent.composedTarget); aEvent.composedTarget.ownerGlobal.updateCommands("contentcontextmenu"); let data = { context, charSet, baseURI, referrerInfo, editFlags, principal, spellInfo, contentType, docLocation, loginFillInfo, selectionInfo, userContextId, customMenuItems, contentDisposition, frameOuterWindowID, popupNodeSelectors, disableSetDesktopBackground, parentAllowsMixedContent, }; if (context.inFrame && !context.inSrcdocFrame) { data.frameReferrerInfo = E10SUtils.serializeReferrerInfo( doc.referrerInfo ); } if (linkReferrerInfo) { data.linkReferrerInfo = E10SUtils.serializeReferrerInfo(linkReferrerInfo); } if (Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT) { data.customMenuItems = PageMenuChild.build(aEvent.composedTarget); } Services.obs.notifyObservers( { wrappedJSObject: data }, "on-prepare-contextmenu" ); // For now, JS Window Actors don't serialize Principals automatically, so we // have to do it ourselves. See bug 1557852. data.principal = E10SUtils.serializePrincipal(doc.nodePrincipal); data.context.principal = E10SUtils.serializePrincipal(context.principal); data.storagePrincipal = E10SUtils.serializePrincipal( doc.effectiveStoragePrincipal ); data.context.storagePrincipal = E10SUtils.serializePrincipal( context.storagePrincipal ); // In the event that the content is running in the parent process, we don't // actually want the contextmenu events to reach the parent - we'll dispatch // a new contextmenu event after the async message has reached the parent // instead. aEvent.stopPropagation(); this.sendAsyncMessage("contextmenu", data); } /** * Some things are not serializable, so we either have to only send * their needed data or regenerate them in nsContextMenu.js * - target and target.ownerDocument * - link * - linkURI */ _cleanContext(aEvent) { const context = this.context; const cleanTarget = Object.create(null); cleanTarget.ownerDocument = { // used for nsContextMenu.initLeaveDOMFullScreenItems and // nsContextMenu.initMediaPlayerItems fullscreen: context.target.ownerDocument.fullscreen, // used for nsContextMenu.initMiscItems contentType: context.target.ownerDocument.contentType, // used for nsContextMenu.saveLink isPrivate: PrivateBrowsingUtils.isContentWindowPrivate( context.target.ownerGlobal ), }; // used for nsContextMenu.initMediaPlayerItems Object.assign(cleanTarget, { ended: context.target.ended, muted: context.target.muted, paused: context.target.paused, controls: context.target.controls, duration: context.target.duration, }); const onMedia = context.onVideo || context.onAudio; if (onMedia) { Object.assign(cleanTarget, { loop: context.target.loop, error: context.target.error, networkState: context.target.networkState, playbackRate: context.target.playbackRate, NETWORK_NO_SOURCE: context.target.NETWORK_NO_SOURCE, }); if (context.onVideo) { Object.assign(cleanTarget, { readyState: context.target.readyState, HAVE_CURRENT_DATA: context.target.HAVE_CURRENT_DATA, }); } } context.target = cleanTarget; if (context.link) { context.link = { href: context.linkURL }; } delete context.linkURI; } _setContext(aEvent) { this.context = Object.create(null); const context = this.context; context.timeStamp = aEvent.timeStamp; context.screenX = aEvent.screenX; context.screenY = aEvent.screenY; context.mozInputSource = aEvent.mozInputSource; let node = aEvent.composedTarget; // Set the node to containing