/* 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 { getFilename } from "chrome://browser/content/screenshots/fileHelpers.mjs"; import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; const lazy = {}; ChromeUtils.defineESModuleGetters(lazy, { Downloads: "resource://gre/modules/Downloads.sys.mjs", FileUtils: "resource://gre/modules/FileUtils.sys.mjs", PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", }); XPCOMUtils.defineLazyServiceGetters(lazy, { AlertsService: ["@mozilla.org/alerts-service;1", "nsIAlertsService"], }); XPCOMUtils.defineLazyGetter(lazy, "screenshotsLocalization", () => { return new Localization(["browser/screenshots.ftl"], true); }); const PanelPosition = "bottomright topright"; const PanelOffsetX = -33; const PanelOffsetY = -8; // The max dimension for a canvas is defined https://searchfox.org/mozilla-central/rev/f40d29a11f2eb4685256b59934e637012ea6fb78/gfx/cairo/cairo/src/cairo-image-surface.c#62. // The max number of pixels for a canvas is 124925329 or 11177 x 11177. // We have to limit screenshots to these dimensions otherwise it will cause an error. const MAX_CAPTURE_DIMENSION = 32767; const MAX_CAPTURE_AREA = 124925329; export class ScreenshotsComponentParent extends JSWindowActorParent { async receiveMessage(message) { let browser = message.target.browsingContext.topFrameElement; switch (message.name) { case "Screenshots:CancelScreenshot": await ScreenshotsUtils.closePanel(browser); let { reason } = message.data; ScreenshotsUtils.recordTelemetryEvent("canceled", reason, {}); break; case "Screenshots:CopyScreenshot": await ScreenshotsUtils.closePanel(browser); let copyBox = message.data; ScreenshotsUtils.copyScreenshotFromRegion(copyBox, browser); break; case "Screenshots:DownloadScreenshot": await ScreenshotsUtils.closePanel(browser); let { title, downloadBox } = message.data; ScreenshotsUtils.downloadScreenshotFromRegion( title, downloadBox, browser ); break; case "Screenshots:ShowPanel": ScreenshotsUtils.openPanel(browser); break; case "Screenshots:HidePanel": ScreenshotsUtils.closePanel(browser); break; } } didDestroy() { // When restoring a crashed tab the browser is null let browser = this.browsingContext.topFrameElement; if (browser) { ScreenshotsUtils.closePanel(browser); } } } export var ScreenshotsUtils = { initialized: false, initialize() { if (!this.initialized) { if ( !Services.prefs.getBoolPref( "screenshots.browser.component.enabled", false ) ) { return; } Services.telemetry.setEventRecordingEnabled("screenshots", true); Services.obs.addObserver(this, "menuitem-screenshot"); Services.obs.addObserver(this, "screenshots-take-screenshot"); this.initialized = true; if (Cu.isInAutomation) { Services.obs.notifyObservers(null, "screenshots-component-initialized"); } } }, uninitialize() { if (this.initialized) { Services.obs.removeObserver(this, "menuitem-screenshot"); Services.obs.removeObserver(this, "screenshots-take-screenshot"); this.initialized = false; } }, handleEvent(event) { // We need to add back Escape to hide behavior as we have set noautohide="true" if (event.type === "keydown" && event.key === "Escape") { this.closePanel(event.view.gBrowser.selectedBrowser, true); this.recordTelemetryEvent("canceled", "escape", {}); } }, observe(subj, topic, data) { let { gBrowser } = subj; let browser = gBrowser.selectedBrowser; switch (topic) { case "menuitem-screenshot": let success = this.closeDialogBox(browser); if (!success || data === "retry") { // only toggle the buttons if no dialog box is found because // if dialog box is found then the buttons are hidden and we return early // else no dialog box is found and we need to toggle the buttons // or if retry because the dialog box was closed and we need to show the panel this.togglePanelAndOverlay(browser, data); } break; case "screenshots-take-screenshot": // need to close the preview because screenshot was taken this.closePanel(browser, true); // init UI as a tab dialog box let dialogBox = gBrowser.getTabDialogBox(browser); let { dialog } = dialogBox.open( `chrome://browser/content/screenshots/screenshots.html?browsingContextId=${browser.browsingContext.id}`, { features: "resizable=no", sizeTo: "available", allowDuplicateDialogs: false, } ); this.doScreenshot(browser, dialog, data); } return null; }, /** * Notify screenshots when screenshot command is used. * @param window The current window the screenshot command was used. * @param type The type of screenshot taken. Used for telemetry. */ notify(window, type) { if (Services.prefs.getBoolPref("screenshots.browser.component.enabled")) { Services.obs.notifyObservers( window.event.currentTarget.ownerGlobal, "menuitem-screenshot", type ); } else { Services.obs.notifyObservers(null, "menuitem-screenshot-extension", type); } }, /** * Creates and returns a Screenshots actor. * @param browser The current browser. * @returns JSWindowActor The screenshot actor. */ getActor(browser) { let actor = browser.browsingContext.currentWindowGlobal.getActor( "ScreenshotsComponent" ); return actor; }, /** * Open the panel buttons * @param browser The current browser */ async openPanel(browser) { this.createOrDisplayButtons(browser); let buttonsPanel = this.panelForBrowser(browser); if (buttonsPanel.state !== "open") { await new Promise(resolve => { buttonsPanel.addEventListener("popupshown", resolve, { once: true }); }); } buttonsPanel .querySelector("screenshots-buttons") .focusFirst({ focusVisible: true }); }, /** * Close the panel and call child actor to close the overlay * @param browser The current browser * @param {bool} closeOverlay Whether or not to * send a message to the child to close the overly. * Defaults to false. Will be false when called from didDestroy. */ async closePanel(browser, closeOverlay = false) { let buttonsPanel = this.panelForBrowser(browser); if (buttonsPanel && buttonsPanel.state !== "closed") { buttonsPanel.hidePopup(); } buttonsPanel?.ownerDocument.removeEventListener("keydown", this); if (closeOverlay) { let actor = this.getActor(browser); await actor.sendQuery("Screenshots:HideOverlay"); } }, /** * If the buttons panel exists and is open we will hide both the panel * and the overlay. If the overlay is showing, we will hide the overlay. * Otherwise create or display the buttons. * @param browser The current browser. */ async togglePanelAndOverlay(browser, data) { let buttonsPanel = this.panelForBrowser(browser); let isOverlayShowing = await this.getActor(browser).sendQuery( "Screenshots:isOverlayShowing" ); data = data === "retry" ? "preview_retry" : data; if (buttonsPanel && (isOverlayShowing || buttonsPanel.state !== "closed")) { this.recordTelemetryEvent("canceled", data, {}); return this.closePanel(browser, true); } let actor = this.getActor(browser); actor.sendQuery("Screenshots:ShowOverlay"); this.recordTelemetryEvent("started", data, {}); return this.openPanel(browser); }, /** * Gets the screenshots dialog box * @param browser The selected browser * @returns Screenshots dialog box if it exists otherwise null */ getDialog(browser) { let currTabDialogBox = browser.tabDialogBox; let browserContextId = browser.browsingContext.id; if (currTabDialogBox) { currTabDialogBox.getTabDialogManager(); let manager = currTabDialogBox.getTabDialogManager(); let dialogs = manager.hasDialogs && manager.dialogs; if (dialogs.length) { for (let dialog of dialogs) { if ( dialog._openedURL.endsWith( `browsingContextId=${browserContextId}` ) && dialog._openedURL.includes("screenshots.html") ) { return dialog; } } } } return null; }, /** * Closes the dialog box it it exists * @param browser The selected browser */ closeDialogBox(browser) { let dialog = this.getDialog(browser); if (dialog) { dialog.close(); return true; } return false; }, panelForBrowser(browser) { return browser.ownerDocument.querySelector("#screenshotsPagePanel"); }, /** * Gets the screenshots button if it is visible, otherwise it will get the * element that the screenshots button is nested under. If the screenshots * button doesn't exist then we will default to the navigator toolbox. * @param browser The selected browser * @returns The anchor element for the ConfirmationHint */ getWidgetAnchor(browser) { let window = browser.ownerGlobal; let widgetGroup = window.CustomizableUI.getWidget("screenshot-button"); let widget = widgetGroup?.forWindow(window); let anchor = widget?.anchor; // Check if the anchor exists and is visible if (!anchor || !window.isElementVisible(anchor.parentNode)) { anchor = browser.ownerDocument.getElementById("navigator-toolbox"); } return anchor; }, /** * Indicate that the screenshot has been copied via ConfirmationHint. * @param browser The selected browser */ showCopiedConfirmationHint(browser) { let anchor = this.getWidgetAnchor(browser); browser.ownerGlobal.ConfirmationHint.show( anchor, "confirmation-hint-screenshot-copied" ); }, /** * If the buttons panel does not exist then we will replace the buttons * panel template with the buttons panel then open the buttons panel and * show the screenshots overaly. * @param browser The current browser. */ createOrDisplayButtons(browser) { let doc = browser.ownerDocument; let buttonsPanel = this.panelForBrowser(browser); if (!buttonsPanel) { let template = doc.querySelector("#screenshotsPagePanelTemplate"); let clone = template.content.cloneNode(true); template.replaceWith(clone); buttonsPanel = doc.querySelector("#screenshotsPagePanel"); } else if (buttonsPanel.state !== "closed") { // early return if the panel is already open return; } buttonsPanel.ownerDocument.addEventListener("keydown", this); let anchor = doc.querySelector("#navigator-toolbox"); buttonsPanel.openPopup(anchor, PanelPosition, PanelOffsetX, PanelOffsetY); }, /** * Gets the full page bounds from the screenshots child actor. * @param browser The current browser. * @returns { object } * Contains the full page bounds from the screenshots child actor. */ fetchFullPageBounds(browser) { let actor = this.getActor(browser); return actor.sendQuery("Screenshots:getFullPageBounds"); }, /** * Gets the visible bounds from the screenshots child actor. * @param browser The current browser. * @returns { object } * Contains the visible bounds from the screenshots child actor. */ fetchVisibleBounds(browser) { let actor = this.getActor(browser); return actor.sendQuery("Screenshots:getVisibleBounds"); }, showAlertMessage(title, message) { lazy.AlertsService.showAlertNotification(null, title, message); }, /** * The max one dimesion for a canvas is 32767 and the max canvas area is * 124925329. If the width or height is greater than 32767 we will crop the * screenshot to the max width. If the area is still too large for the canvas * we will adjust the height so we can successfully capture the screenshot. * @param {Object} rect The dimensions of the screenshot. The rect will be * modified in place */ cropScreenshotRectIfNeeded(rect) { let cropped = false; let width = rect.width * rect.devicePixelRatio; let height = rect.height * rect.devicePixelRatio; if (width > MAX_CAPTURE_DIMENSION) { width = MAX_CAPTURE_DIMENSION; cropped = true; } if (height > MAX_CAPTURE_DIMENSION) { height = MAX_CAPTURE_DIMENSION; cropped = true; } if (width * height > MAX_CAPTURE_AREA) { height = Math.floor(MAX_CAPTURE_AREA / width); cropped = true; } rect.width = Math.floor(width / rect.devicePixelRatio); rect.height = Math.floor(height / rect.devicePixelRatio); if (cropped) { let [errorTitle, errorMessage] = lazy.screenshotsLocalization.formatMessagesSync([ { id: "screenshots-too-large-error-title" }, { id: "screenshots-too-large-error-details" }, ]); this.showAlertMessage(errorTitle.value, errorMessage.value); this.recordTelemetryEvent("failed", "screenshot_too_large", null); } }, /** * Add screenshot-ui to the dialog box and then take the screenshot * @param browser The current browser. * @param dialog The dialog box to show the screenshot preview. * @param type The type of screenshot taken. */ async doScreenshot(browser, dialog, type) { await dialog._dialogReady; let screenshotsUI = dialog._frame.contentDocument.createElement("screenshots-ui"); dialog._frame.contentDocument.body.appendChild(screenshotsUI); let rect; if (type === "full-page") { rect = await this.fetchFullPageBounds(browser); type = "full_page"; } else { rect = await this.fetchVisibleBounds(browser); } this.recordTelemetryEvent("selected", type, {}); return this.takeScreenshot(browser, dialog, rect); }, /** * Take the screenshot and add the image to the dialog box * @param browser The current browser. * @param dialog The dialog box to show the screenshot preview. * @param rect DOMRect containing bounds of the screenshot. */ async takeScreenshot(browser, dialog, rect) { let { canvas, snapshot } = await this.createCanvas(rect, browser); let newImg = dialog._frame.contentDocument.createElement("img"); let url = canvas.toDataURL(); newImg.id = "placeholder-image"; newImg.src = url; dialog._frame.contentDocument .getElementById("preview-image-div") .appendChild(newImg); if (Cu.isInAutomation) { Services.obs.notifyObservers(null, "screenshots-preview-ready"); } snapshot.close(); }, /** * Creates a canvas and draws a snapshot of the screenshot on the canvas * @param box The bounds of screenshots * @param browser The current browser * @returns The canvas and snapshot in an object */ async createCanvas(box, browser) { this.cropScreenshotRectIfNeeded(box); let rect = new DOMRect(box.x1, box.y1, box.width, box.height); let { devicePixelRatio } = box; let browsingContext = BrowsingContext.get(browser.browsingContext.id); let snapshot = await browsingContext.currentWindowGlobal.drawSnapshot( rect, devicePixelRatio, "rgb(255,255,255)" ); let canvas = browser.ownerDocument.createElementNS( "http://www.w3.org/1999/xhtml", "html:canvas" ); let context = canvas.getContext("2d"); canvas.width = snapshot.width; canvas.height = snapshot.height; context.drawImage(snapshot, 0, 0); return { canvas, snapshot }; }, /** * Copy the screenshot * @param region The bounds of the screenshots * @param browser The current browser */ async copyScreenshotFromRegion(region, browser) { let { canvas, snapshot } = await this.createCanvas(region, browser); let url = canvas.toDataURL(); this.copyScreenshot(url, browser); snapshot.close(); this.recordTelemetryEvent("copy", "overlay_copy", {}); }, /** * Copy the image to the clipboard * @param dataUrl The image data * @param browser The current browser */ copyScreenshot(dataUrl, browser) { // Guard against missing image data. if (!dataUrl) { return; } const imageTools = Cc["@mozilla.org/image/tools;1"].getService( Ci.imgITools ); const base64Data = dataUrl.replace("data:image/png;base64,", ""); const image = atob(base64Data); const imgDecoded = imageTools.decodeImageFromBuffer( image, image.length, "image/png" ); const transferable = Cc[ "@mozilla.org/widget/transferable;1" ].createInstance(Ci.nsITransferable); transferable.init(null); transferable.addDataFlavor("image/png"); transferable.setTransferData("image/png", imgDecoded); Services.clipboard.setData( transferable, null, Services.clipboard.kGlobalClipboard ); this.showCopiedConfirmationHint(browser); }, /** * Download the screenshot * @param title The title of the current page * @param box The bounds of the screenshot * @param browser The current browser */ async downloadScreenshotFromRegion(title, box, browser) { let { canvas, snapshot } = await this.createCanvas(box, browser); let dataUrl = canvas.toDataURL(); await this.downloadScreenshot(title, dataUrl, browser); snapshot.close(); this.recordTelemetryEvent("download", "overlay_download", {}); }, /** * Download the screenshot * @param title The title of the current page or null and getFilename will get the title * @param dataUrl The image data * @param browser The current browser */ async downloadScreenshot(title, dataUrl, browser) { // Guard against missing image data. if (!dataUrl) { return; } let filename = await getFilename(title, browser); const targetFile = new lazy.FileUtils.File(filename); // Create download and track its progress. try { const download = await lazy.Downloads.createDownload({ source: dataUrl, target: targetFile, }); let isPrivate = lazy.PrivateBrowsingUtils.isWindowPrivate( browser.ownerGlobal ); const list = await lazy.Downloads.getList( isPrivate ? lazy.Downloads.PRIVATE : lazy.Downloads.PUBLIC ); // add the download to the download list in the Downloads list in the Browser UI list.add(download); // Await successful completion of the save via the download manager await download.start(); } catch (ex) {} }, recordTelemetryEvent(type, object, args) { Services.telemetry.recordEvent("screenshots", type, object, null, args); }, };