From 6bf0a5cb5034a7e684dcc3500e841785237ce2dd Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Sun, 7 Apr 2024 19:32:43 +0200 Subject: Adding upstream version 1:115.7.0. Signed-off-by: Daniel Baumann --- browser/actors/ContextMenuChild.sys.mjs | 1239 +++++++++++++++++++++++++++++++ 1 file changed, 1239 insertions(+) create mode 100644 browser/actors/ContextMenuChild.sys.mjs (limited to 'browser/actors/ContextMenuChild.sys.mjs') diff --git a/browser/actors/ContextMenuChild.sys.mjs b/browser/actors/ContextMenuChild.sys.mjs new file mode 100644 index 0000000000..235a0d268a --- /dev/null +++ b/browser/actors/ContextMenuChild.sys.mjs @@ -0,0 +1,1239 @@ +/* -*- 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 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 = 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); + } + + /** + * 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; + } + + 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; + } + + 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; + 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.contentViewer + .QueryInterface(Ci.nsIContentViewerEdit) + .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.mozInputSource = aEvent.mozInputSource; + + let node = aEvent.composedTarget; + + // Set the node to containing