/* -*- 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"]; ChromeUtils.import("resource://gre/modules/Services.jsm"); ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); ChromeUtils.import("resource://gre/modules/ActorChild.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", }); XPCOMUtils.defineLazyGetter(this, "PageMenuChild", () => { let tmp = {}; ChromeUtils.import("resource://gre/modules/PageMenu.jsm", tmp); return new tmp.PageMenuChild(); }); const messageListeners = { "ContextMenu:BookmarkFrame": function(aMessage) { let frame = this.getTarget(aMessage).ownerDocument; this.mm.sendAsyncMessage("ContextMenu:BookmarkFrame:Result", { title: frame.title }); }, "ContextMenu:Canvas:ToBlobURL": function(aMessage) { this.getTarget(aMessage).toBlob((blob) => { let blobURL = URL.createObjectURL(blob); this.mm.sendAsyncMessage("ContextMenu:Canvas:ToBlobURL:Result", { blobURL }); }); }, "ContextMenu:DoCustomCommand": function(aMessage) { E10SUtils.wrapHandlingUserInput( this.content, aMessage.data.handlingUserInput, () => PageMenuChild.executeMenu(aMessage.data.generatedItemId) ); }, "ContextMenu:Hiding": function() { this.context = null; this.target = null; }, "ContextMenu:MediaCommand": function(aMessage) { E10SUtils.wrapHandlingUserInput( this.content, aMessage.data.handlingUserInput, () => { let media = this.getTarget(aMessage, "element"); switch (aMessage.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 = aMessage.data.data; break; case "hidecontrols": media.removeAttribute("controls"); break; case "showcontrols": media.setAttribute("controls", "true"); break; case "fullscreen": if (this.content.document.fullscreenEnabled) { media.requestFullscreen(); } break; } } ); }, "ContextMenu:ReloadFrame": function(aMessage) { let forceReload = aMessage.objects && aMessage.objects.forceReload; this.getTarget(aMessage).ownerDocument.location.reload(forceReload); }, "ContextMenu:ReloadImage": function(aMessage) { let image = this.getTarget(aMessage); if (image instanceof Ci.nsIImageLoadingContent) { image.forceReload(); } }, "ContextMenu:SearchFieldBookmarkData": function(aMessage) { let node = this.getTarget(aMessage); 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; let formData = []; function escapeNameValuePair(aName, aValue, aIsFormUrlEncoded) { if (aIsFormUrlEncoded) { return escape(aName + "=" + aValue); } return escape(aName) + "=" + escape(aValue); } for (let el of node.form.elements) { if (!el.type) // happens with fieldsets continue; if (el == node) { formData.push((isURLEncoded) ? escapeNameValuePair(el.name, "%s", true) : // Don't escape "%s", just append escapeNameValuePair(el.name, "", false) + "%s"); continue; } let type = el.type.toLowerCase(); if (((el instanceof this.content.HTMLInputElement && el.mozIsTextField(true)) || type == "hidden" || type == "textarea") || ((type == "checkbox" || type == "radio") && el.checked)) { formData.push(escapeNameValuePair(el.name, el.value, isURLEncoded)); } else if (el instanceof this.content.HTMLSelectElement && el.selectedIndex >= 0) { for (let j = 0; j < el.options.length; j++) { if (el.options[j].selected) formData.push(escapeNameValuePair(el.name, el.options[j].value, isURLEncoded)); } } } let postData; if (isURLEncoded) { postData = formData.join("&"); } else { let separator = spec.includes("?") ? "&" : "?"; spec += separator + formData.join("&"); } this.mm.sendAsyncMessage("ContextMenu:SearchFieldBookmarkData:Result", { spec, title, postData, charset }); }, "ContextMenu:SaveVideoFrameAsImage": function(aMessage) { let video = this.getTarget(aMessage); let canvas = this.content.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); this.mm.sendAsyncMessage("ContextMenu:SaveVideoFrameAsImage:Result", { dataURL: canvas.toDataURL("image/jpeg", ""), }); }, "ContextMenu:SetAsDesktopBackground": function(aMessage) { let target = this.getTarget(aMessage); // 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.content.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); this.mm.sendAsyncMessage("ContextMenu:SetAsDesktopBackground:Result", { dataUrl, imageName }); } catch (e) { Cu.reportError(e); disable = true; } } if (disable) { this.mm.sendAsyncMessage("ContextMenu:SetAsDesktopBackground:Result", { disable }); } }, }; let contextMenus = new WeakMap(); class ContextMenuChild extends ActorChild { // PUBLIC constructor(dispatcher) { super(dispatcher); contextMenus.set(this.mm, this); this.target = null; this.context = null; this.lastMenuTarget = null; Object.keys(messageListeners).forEach(key => this.mm.addMessageListener(key, messageListeners[key].bind(this)) ); } static getTarget(mm, message, key) { return contextMenus.get(mm).getTarget(message, key); } static getLastTarget(mm) { let contextMenu = contextMenus.get(mm); return contextMenu && contextMenu.lastMenuTarget; } /** * 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 "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.content.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 "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.content.HTMLInputElement) { return node.mozIsTextField(false); } return (node instanceof this.content.HTMLTextAreaElement); } _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) { 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; } let doc = aEvent.composedTarget.ownerDocument; let { mozDocumentURIIfNotForErrorPages: docLocation, characterSet: charSet, baseURI, referrer, referrerPolicy, } = 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; // Get referrer attribute from clicked link and parse it let referrerAttrValue = Services.netUtils.parseAttributePolicyString(aEvent.composedTarget. getAttribute("referrerpolicy")); if (referrerAttrValue !== Ci.nsIHttpChannel.REFERRER_POLICY_UNSET) { referrerPolicy = referrerAttrValue; } let disableSetDesktopBg = 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) { disableSetDesktopBg = 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.content); 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 targetAsCPOW = context.target; if (targetAsCPOW) { this._cleanContext(); } let isRemote = Services.appinfo.processType == Services.appinfo.PROCESS_TYPE_CONTENT; if (isRemote) { editFlags = SpellCheckHelper.isEditable(aEvent.composedTarget, this.content); if (editFlags & SpellCheckHelper.SPELLCHECKABLE) { spellInfo = InlineSpellCheckerContent.initContextMenu(aEvent, editFlags, this.mm); } // 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"); customMenuItems = PageMenuChild.build(aEvent.composedTarget); principal = doc.nodePrincipal; } let data = { context, charSet, baseURI, isRemote, referrer, editFlags, principal, spellInfo, contentType, docLocation, loginFillInfo, selectionInfo, userContextId, referrerPolicy, customMenuItems, contentDisposition, frameOuterWindowID, popupNodeSelectors, disableSetDesktopBg, parentAllowsMixedContent, }; Services.obs.notifyObservers({wrappedJSObject: data}, "on-prepare-contextmenu"); if (isRemote) { this.mm.sendAsyncMessage("contextmenu", data, { targetAsCPOW, }); } else { let browser = this.docShell.chromeEventHandler; let mainWin = browser.ownerGlobal; data.documentURIObject = doc.documentURIObject; data.disableSetDesktopBackground = data.disableSetDesktopBg; delete data.disableSetDesktopBg; data.context.targetAsCPOW = targetAsCPOW; mainWin.setContextMenuContentData(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