/* 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/. */ import { AppConstants } from "resource://gre/modules/AppConstants.sys.mjs"; import { BrowserUtils } from "resource://gre/modules/BrowserUtils.sys.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const APPLE_COPY_LINK = "com.apple.share.CopyLink.invite"; let lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { QRCodeGenerator: "moz-src:///browser/components/qrcode/QRCodeGenerator.sys.mjs", }); XPCOMUtils.defineLazyServiceGetters(lazy, { WindowsUIUtils: ["@mozilla.org/windows-ui-utils;1", Ci.nsIWindowsUIUtils], }); // Use a non-caching getter so tests can swap out the service via MockRegistrar. Object.defineProperty(lazy, "MacSharingService", { get() { return Cc["@mozilla.org/widget/macsharingservice;1"].getService( Ci.nsIMacSharingService ); }, }); class SharingUtilsCls { /** * Ensures a "Share" submenu exists in a given menu, creating it if necessary. * * @param {MozBrowser} contextBrowser * The browser of the right-clicked (context) tab. * @param {MozBrowser[]|null} browsers * All selected browsers in tab strip order. Pass null for single URL sharing. * @param {Element} insertAfterEl * The menu item after which the share item is inserted. */ ensureShareMenu(contextBrowser, browsers, insertAfterEl) { if (!Services.prefs.getBoolPref("browser.menu.share_url.allow", true)) { return; } let hasShareableURL = browsers !== null ? browsers.some(b => BrowserUtils.getShareableURL(b.currentURI)) : !!BrowserUtils.getShareableURL(contextBrowser.currentURI); let shareMenu; let oldElement = insertAfterEl.nextElementSibling; if (oldElement?.matches(".share-tab-url-item")) { shareMenu = oldElement; } else { shareMenu = this.#createShareMenu(insertAfterEl); } shareMenu.contextBrowserToShare = Cu.getWeakReference(contextBrowser); shareMenu.browsersToShare = browsers?.map(b => Cu.getWeakReference(b)) ?? null; if (AppConstants.platform != "macosx") { // On macOS, we keep the item visible and handle enable/disable // inside the menupopup. shareMenu.hidden = !hasShareableURL; } } /** * Creates and inserts the "Share" element with a child. * All platforms now use a submenu. */ #createShareMenu(insertAfterEl) { let parentMenu = insertAfterEl.parentNode; let document = insertAfterEl.ownerDocument; let l10nID = parentMenu.id == "tabContextMenu" ? "tab-context-share-url" : "menu-file-share-url"; let menu = document.createXULElement("menu"); let menuPopup = document.createXULElement("menupopup"); menuPopup.addEventListener("popupshowing", this); menu.appendChild(menuPopup); document.l10n.setAttributes(menu, l10nID); menu.classList.add("share-tab-url-item"); parentMenu.insertBefore(menu, insertAfterEl.nextSibling); return menu; } /** * Return a menuitem that copies the link(s) to the clipboard. */ #createCopyLinkMenuItem(document, shareableCount) { let item = document.createXULElement("menuitem"); document.l10n.setAttributes(item, "menu-share-copy-links", { // shareableCount can be zero when about:blank tabs are selected but no // "real" tabs are. Clamp to 1 so the localized string is correct. count: Math.max(1, shareableCount), }); item.classList.add("menuitem-iconic", "share-copy-link"); item.setAttribute("image", "chrome://global/skin/icons/link.svg"); return item; } async #showQRCodePanel(win, browser, url) { let tab = win.gBrowser.getTabForBrowser(browser); if (tab && win.gBrowser.selectedTab !== tab) { let wait = null; if (!tab.linkedPanel) { wait = this.#waitForTabRestored(tab); } win.gBrowser.selectedTab = tab; if (wait) { if (win.gBrowser.selectedTab === tab) { await wait.promise; } else { wait.cancel(); } } } if (!tab || !tab.linkedBrowser || tab.closing) { return; } let qrCodeDataURI = null; try { qrCodeDataURI = await lazy.QRCodeGenerator.generateQRCode(url); } catch (error) { console.error("Failed to generate QR code:", error); } let params = { url, qrCodeDataURI, }; win.gBrowser .getTabDialogBox(browser) .open( "chrome://browser/content/qrcode/qrcode-dialog.html", { features: "resizable=no", allowDuplicateDialogs: false }, params ); } #waitForTabRestored(tab) { let { promise, resolve } = Promise.withResolvers(); let cleanup = () => { tab.removeEventListener("SSTabRestored", cleanup); tab.removeEventListener("TabClose", cleanup); resolve(); }; tab.addEventListener("SSTabRestored", cleanup, { once: true }); tab.addEventListener("TabClose", cleanup, { once: true }); return { promise, cancel: cleanup }; } /** * Get the sharing data for the context browser on a DOM node. */ getLinkToShare(node) { let browser = node.contextBrowserToShare?.get(); let urlToShare = null; let titleToShare = null; if (browser) { let maybeToShare = BrowserUtils.getShareableURL(browser.currentURI); if (maybeToShare) { let { gURLBar } = node.documentGlobal; urlToShare = gURLBar.makeURIReadable(maybeToShare).displaySpec; titleToShare = browser.contentTitle; } } return { urlToShare, titleToShare }; } /** * Get the link data for all browsers stored on a DOM node. * * @returns {Array<{url: string, title: string}>} */ getLinksToShare(node) { let browsers = node.browsersToShare ?? [node.contextBrowserToShare]; let links = []; for (let weakRef of browsers) { let browser = weakRef.get(); if (!browser) { continue; } let maybeToShare = BrowserUtils.getShareableURL(browser.currentURI); if (maybeToShare) { let { gURLBar } = node.documentGlobal; links.push({ url: gURLBar.makeURIReadable(maybeToShare).displaySpec, title: browser.contentTitle, }); } } return links; } #initSharePopup(menuPopup) { this.populateSharePopup(menuPopup); menuPopup.parentNode .closest("menupopup") .addEventListener("popuphiding", this); } /** * Populates the share submenu popup with platform-appropriate items. */ populateSharePopup(menuPopup) { // Ensure the command listener is registered. menuPopup.addEventListener("command", this); while (menuPopup.firstChild) { menuPopup.firstChild.remove(); } let document = menuPopup.ownerDocument; let node = menuPopup.parentNode; let isMultiTab = node.browsersToShare !== null; let { urlToShare } = this.getLinkToShare(node); // If we can't share the current URL, we display the items disabled, // but enable the "more..." item at the bottom on macOS, to allow the // user to change sharing preferences in the system dialog. let shouldEnable = !!urlToShare; let shareableCount; if (isMultiTab) { shareableCount = this.getLinksToShare(node).length; } else { shareableCount = shouldEnable ? 1 : 0; } let copyLinkEnabled = shareableCount > 0; // On macOS, query native services. We need this list to check whether // Apple already provides a "Copy Link" service. let services = []; if (AppConstants.platform == "macosx") { if (!urlToShare) { // Fake it so we can ask the sharing service for services: urlToShare = "https://mozilla.org/"; } services = lazy.MacSharingService.getSharingProviders(urlToShare); } // Copy Link(s) - all platforms. Apple seems reluctant to provide copy // link as a feature, so add it if it's not already in the services list. if ( AppConstants.platform != "macosx" || !services.some(s => s.name == APPLE_COPY_LINK) ) { let copyItem = this.#createCopyLinkMenuItem(document, shareableCount); if (!copyLinkEnabled) { copyItem.setAttribute("disabled", "true"); } menuPopup.appendChild(copyItem); } // QR code - all platforms if (Services.prefs.getBoolPref("browser.shareqrcode.enabled", false)) { let qrCodeItem = document.createXULElement("menuitem"); qrCodeItem.classList.add("menuitem-iconic", "share-qrcode-item"); document.l10n.setAttributes(qrCodeItem, "menu-file-share-qrcode"); qrCodeItem.setAttribute("image", "chrome://browser/skin/qrcode.svg"); if (!shouldEnable || isMultiTab) { qrCodeItem.setAttribute("disabled", "true"); } menuPopup.appendChild(qrCodeItem); } // macOS: native sharing services + "More..." if (AppConstants.platform == "macosx") { if (services.length) { menuPopup.appendChild(document.createXULElement("menuseparator")); } // Share service items services.forEach(share => { let item = document.createXULElement("menuitem"); item.classList.add("menuitem-iconic"); item.setAttribute("label", share.menuItemTitle); item.setAttribute("data-share-name", share.name); item.setAttribute("image", ChromeUtils.encodeURIForSrcset(share.image)); if (!shouldEnable) { item.setAttribute("disabled", "true"); } menuPopup.appendChild(item); }); menuPopup.appendChild(document.createXULElement("menuseparator")); // More item let moreItem = document.createXULElement("menuitem"); document.l10n.setAttributes(moreItem, "menu-share-more"); moreItem.classList.add("menuitem-iconic", "share-more-button"); moreItem.setAttribute("data-share-name", "share_macosx_more"); menuPopup.appendChild(moreItem); } // Windows: native share dialog if (AppConstants.platform == "win") { menuPopup.appendChild(document.createXULElement("menuseparator")); let winShareItem = document.createXULElement("menuitem"); winShareItem.classList.add("share-windows-item"); document.l10n.setAttributes(winShareItem, "menu-share-windows"); if (!shouldEnable || isMultiTab) { winShareItem.setAttribute("disabled", "true"); } menuPopup.appendChild(winShareItem); } } #onCommand(event) { let node = event.currentTarget.parentNode; if (event.target.classList.contains("share-qrcode-item")) { let { urlToShare: url } = this.getLinkToShare(node); let browser = node.contextBrowserToShare?.get(); if (url && browser) { Glean.qrcode.opened.add(1); this.#showQRCodePanel(node.documentGlobal, browser, url); } } else if (event.target.classList.contains("share-more-button")) { this.openMacSharePreferences(); } else if (event.target.classList.contains("share-copy-link")) { this.copyLink(node); } else if (event.target.classList.contains("share-windows-item")) { this.shareOnWindows(node); } else if (event.target.dataset.shareName) { this.shareOnMac(node, event.target.dataset.shareName); } } onPopupHiding(event) { // We don't want to rebuild the contents of the "Share" menupopup if only its submenu is // hidden. So bail if this isn't the top menupopup in the DOM tree: if (event.target.parentNode.closest("menupopup")) { return; } let menupopup = event.target.querySelector( ".share-tab-url-item" )?.menupopup; menupopup?.removeEventListener("command", this); event.target.removeEventListener("popuphiding", this); } onPopupShowing(event) { this.#initSharePopup(event.target); } handleEvent(aEvent) { switch (aEvent.type) { case "command": this.#onCommand(aEvent); break; case "popuphiding": this.onPopupHiding(aEvent); break; case "popupshowing": this.onPopupShowing(aEvent); break; } } copyLink(node) { let links = this.getLinksToShare(node); if (links.length) { BrowserUtils.copyLinks(links); } } shareOnWindows(node) { let { urlToShare, titleToShare } = this.getLinkToShare(node); if (!urlToShare) { return; } lazy.WindowsUIUtils.shareUrl(urlToShare, titleToShare); } shareOnMac(node, serviceName) { let { urlToShare, titleToShare } = this.getLinkToShare(node); if (!urlToShare) { return; } lazy.MacSharingService.shareUrl(serviceName, urlToShare, titleToShare); } openMacSharePreferences() { lazy.MacSharingService.openSharingPreferences(); } testOnlyMockUIUtils(mock) { if (!Cu.isInAutomation) { throw new Error("Can only mock utils in automation."); } // eslint-disable-next-line mozilla/valid-lazy Object.defineProperty(lazy, "WindowsUIUtils", { get() { if (mock) { return mock; } return Cc["@mozilla.org/windows-ui-utils;1"].getService( Ci.nsIWindowsUIUtils ); }, }); } } export let SharingUtils = new SharingUtilsCls();