/* -*- 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/. */ const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { ContentDOMReference: "resource://gre/modules/ContentDOMReference.sys.mjs", E10SUtils: "resource://gre/modules/E10SUtils.sys.mjs", InlineSpellCheckerContent: "resource://gre/modules/InlineSpellCheckerContent.sys.mjs", LoginHelper: "resource://gre/modules/LoginHelper.sys.mjs", LoginManagerChild: "resource://gre/modules/LoginManagerChild.sys.mjs", SelectionUtils: "resource://gre/modules/SelectionUtils.sys.mjs", SpellCheckHelper: "resource://gre/modules/InlineSpellChecker.sys.mjs", }); let contextMenus = new WeakMap(); export 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 = lazy.ContentDOMReference.resolve( message.data.targetIdentifier ); return Promise.resolve(target.ownerDocument.title); } case "ContextMenu:Canvas:ToBlobURL": { let target = lazy.ContentDOMReference.resolve( message.data.targetIdentifier ); return new Promise(resolve => { target.toBlob(blob => { let blobURL = URL.createObjectURL(blob); resolve(blobURL); }); }); } case "ContextMenu:Hiding": { this.context = null; this.target = null; break; } case "ContextMenu:MediaCommand": { lazy.E10SUtils.wrapHandlingUserInput( this.contentWindow, message.data.handlingUserInput, () => { let media = lazy.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": if (!media.isCloningElementVisually) { Services.telemetry.keyedScalarAdd( "pictureinpicture.opened_method", "contextmenu", 1 ); } let event = new this.contentWindow.CustomEvent( "MozTogglePictureInPicture", { bubbles: true, detail: { reason: "contextMenu" }, }, this.contentWindow ); media.dispatchEvent(event); break; } } ); break; } case "ContextMenu:ReloadFrame": { let target = lazy.ContentDOMReference.resolve( message.data.targetIdentifier ); target.ownerDocument.location.reload(message.data.forceReload); break; } case "ContextMenu:GetImageText": { let img = lazy.ContentDOMReference.resolve( message.data.targetIdentifier ); const { direction } = this.contentWindow.getComputedStyle(img); return img.recognizeCurrentImageText().then(results => { return { results, direction }; }); } case "ContextMenu:ToggleRevealPassword": { let target = lazy.ContentDOMReference.resolve( message.data.targetIdentifier ); target.revealPassword = !target.revealPassword; break; } case "ContextMenu:UseRelayMask": { const input = lazy.ContentDOMReference.resolve( message.data.targetIdentifier ); input.setUserInput(message.data.emailMask); break; } case "ContextMenu:ReloadImage": { let image = lazy.ContentDOMReference.resolve( message.data.targetIdentifier ); if (image instanceof Ci.nsIImageLoadingContent) { image.forceReload(); } break; } case "ContextMenu:SearchFieldBookmarkData": { let node = lazy.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 encodeURIComponent(aName) + "=" + encodeURIComponent(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 = lazy.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); // Note: if changing the content type, don't forget to update // consumers that also hardcode this content type. return Promise.resolve(canvas.toDataURL("image/jpeg", "")); } case "ContextMenu:SetAsDesktopBackground": { let target = lazy.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 { Services.scriptSecurityManager.checkLoadURIWithPrincipal( target.ownerDocument.nodePrincipal, target.currentURI ); 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) { console.error(e); } } return Promise.resolve({ failed: true, dataURL: null, imageName: null, }); } } 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 == "tel" || 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 (this.contentWindow.HTMLImageElement.isInstance(node)) { // 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.isValidObjectURL(aURL); } return true; } _isTargetATextBox(node) { if (this.contentWindow.HTMLInputElement.isInstance(node)) { return node.mozIsTextField(false); } return this.contentWindow.HTMLTextAreaElement.isInstance(node); } _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; } async handleEvent(aEvent) { contextMenus.set(this.browsingContext, this); let defaultPrevented = aEvent.defaultPrevented; if ( // If the event is not from a chrome-privileged document, and if // `dom.event.contextmenu.enabled` is false, force defaultPrevented=false. !aEvent.composedTarget.nodePrincipal.isSystemPrincipal && !Services.prefs.getBoolPref("dom.event.contextmenu.enabled") ) { defaultPrevented = false; } if (defaultPrevented) { return; } let doc = aEvent.composedTarget.ownerDocument; let { mozDocumentURIIfNotForErrorPages: docLocation, characterSet: charSet, baseURI, } = doc; docLocation = docLocation && docLocation.spec; const loginManagerChild = lazy.LoginManagerChild.forWindow(doc.defaultView); const docState = loginManagerChild.stateForDocument(doc); const loginFillInfo = docState.getFieldContext(aEvent.composedTarget); 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 = lazy.SelectionUtils.getSelectionDetails( this.contentWindow ); this._setContext(aEvent); let context = this.context; this.target = context.target; let spellInfo = null; let editFlags = null; let referrerInfo = Cc["@mozilla.org/referrer-info;1"].createInstance( Ci.nsIReferrerInfo ); referrerInfo.initWithElement(aEvent.composedTarget); referrerInfo = lazy.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.initWithElement(context.link); } let target = context.target; if (target) { this._cleanContext(); } editFlags = lazy.SpellCheckHelper.isEditable( aEvent.composedTarget, this.contentWindow ); if (editFlags & lazy.SpellCheckHelper.SPELLCHECKABLE) { spellInfo = lazy.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.docViewer .QueryInterface(Ci.nsIDocumentViewerEdit) .setCommandNode(aEvent.composedTarget); aEvent.composedTarget.ownerGlobal.updateCommands("contentcontextmenu"); let data = { context, charSet, baseURI, referrerInfo, editFlags, contentType, docLocation, loginFillInfo, selectionInfo, contentDisposition, disableSetDesktopBackground, }; if (context.inFrame && !context.inSrcdocFrame) { data.frameReferrerInfo = lazy.E10SUtils.serializeReferrerInfo( doc.referrerInfo ); } if (linkReferrerInfo) { data.linkReferrerInfo = lazy.E10SUtils.serializeReferrerInfo(linkReferrerInfo); } // Notify observers (currently only webextensions) of the context menu being // prepared, allowing them to set webExtContextData for us. let prepareContextMenu = { principal: doc.nodePrincipal, setWebExtContextData(webExtContextData) { data.webExtContextData = webExtContextData; }, }; Services.obs.notifyObservers(prepareContextMenu, "on-prepare-contextmenu"); // 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(); data.spellInfo = null; if (!spellInfo) { this.sendAsyncMessage("contextmenu", data); return; } try { data.spellInfo = await spellInfo; } catch (ex) {} 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.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.screenXDevPx = aEvent.screenX * this.contentWindow.devicePixelRatio; context.screenYDevPx = aEvent.screenY * this.contentWindow.devicePixelRatio; context.inputSource = aEvent.inputSource; let node = aEvent.composedTarget; // Set the node to containing