summaryrefslogtreecommitdiffstats
path: root/devtools/client/shared/screenshot.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/client/shared/screenshot.js')
-rw-r--r--devtools/client/shared/screenshot.js424
1 files changed, 424 insertions, 0 deletions
diff --git a/devtools/client/shared/screenshot.js b/devtools/client/shared/screenshot.js
new file mode 100644
index 0000000000..ca746f66e7
--- /dev/null
+++ b/devtools/client/shared/screenshot.js
@@ -0,0 +1,424 @@
+/* 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";
+
+const { LocalizationHelper } = require("resource://devtools/shared/l10n.js");
+
+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",
+});
+
+const STRINGS_URI = "devtools/shared/locales/screenshot.properties";
+const L10N = new LocalizationHelper(STRINGS_URI);
+
+/**
+ * Take a screenshot of a browser element matching the passed target and save it to a file
+ * or the clipboard.
+ *
+ * @param {TargetFront} targetFront: The targetFront of the frame we want to take a screenshot of.
+ * @param {Window} window: The DevTools Client window.
+ * @param {Object} args
+ * @param {Boolean} args.fullpage: Should the screenshot be the height of the whole page
+ * @param {String} args.filename: Expected filename for the screenshot
+ * @param {Boolean} args.clipboard: Whether or not the screenshot should be saved to the clipboard.
+ * @param {Number} args.dpr: Scale of the screenshot. Defaults to the window `devicePixelRatio`.
+ * ⚠️ Note that the scale might be decreased if the resulting
+ * image would be too big to draw safely. Warning will be emitted
+ * to the console if that's the case.
+ * @param {Number} args.delay: Number of seconds to wait before taking the screenshot
+ * @param {Boolean} args.help: Set to true to receive a message with the screenshot command
+ * documentation.
+ * @param {Boolean} args.disableFlash: Set to true to disable the flash animation when the
+ * screenshot is taken.
+ * @param {Boolean} args.ignoreDprForFileScale: Set to true to if the resulting screenshot
+ * file size shouldn't be impacted by the dpr. Note that the dpr will still
+ * be taken into account when taking the screenshot, only the size of the
+ * file will be different.
+ * @returns {Array<Object{text, level}>} An array of object representing the different
+ * messages emitted throught the process, that should be displayed to the user.
+ */
+async function captureAndSaveScreenshot(targetFront, window, args = {}) {
+ if (args.help) {
+ // Wrap message in an array so that the return value is consistant.
+ return [{ text: getFormattedHelpData() }];
+ }
+
+ const captureResponse = await captureScreenshot(targetFront, args);
+
+ if (captureResponse.error) {
+ return captureResponse.messages || [];
+ }
+
+ const saveMessages = await saveScreenshot(window, args, captureResponse);
+ return (captureResponse.messages || []).concat(saveMessages);
+}
+
+/**
+ * Take a screenshot of a browser element matching the passed target
+ * @param {TargetFront} targetFront: The targetFront of the frame we want to take a screenshot of.
+ * @param {Object} args: See args param in captureAndSaveScreenshot
+ */
+async function captureScreenshot(targetFront, args) {
+ // @backward-compat { version 87 } The screenshot-content actor was introduced in 87,
+ // so we can always use it once 87 reaches release.
+ const supportsContentScreenshot = targetFront.hasActor("screenshotContent");
+ if (!supportsContentScreenshot) {
+ const screenshotFront = await targetFront.getFront("screenshot");
+ return screenshotFront.capture(args);
+ }
+
+ if (args.delay > 0) {
+ await new Promise(res => setTimeout(res, args.delay * 1000));
+ }
+
+ const screenshotContentFront = await targetFront.getFront(
+ "screenshot-content"
+ );
+
+ // Call the content-process on the server to retrieve informations that will be needed
+ // by the parent process.
+ const { rect, windowDpr, windowZoom, messages, error } =
+ await screenshotContentFront.prepareCapture(args);
+
+ if (error) {
+ return { error, messages };
+ }
+
+ if (rect) {
+ args.rect = rect;
+ }
+
+ args.dpr ||= windowDpr;
+
+ args.snapshotScale = args.dpr * windowZoom;
+ if (args.ignoreDprForFileScale) {
+ args.fileScale = windowZoom;
+ }
+
+ args.browsingContextID = targetFront.browsingContextID;
+
+ // We can now call the parent process which will take the screenshot via
+ // the drawSnapshot API
+ const rootFront = targetFront.client.mainRoot;
+ const parentProcessScreenshotFront = await rootFront.getFront("screenshot");
+ const captureResponse = await parentProcessScreenshotFront.capture(args);
+
+ return {
+ ...captureResponse,
+ messages: (messages || []).concat(captureResponse.messages || []),
+ };
+}
+
+const screenshotDescription = L10N.getStr("screenshotDesc");
+const screenshotGroupOptions = L10N.getStr("screenshotGroupOptions");
+const screenshotCommandParams = [
+ {
+ name: "clipboard",
+ type: "boolean",
+ description: L10N.getStr("screenshotClipboardDesc"),
+ manual: L10N.getStr("screenshotClipboardManual"),
+ },
+ {
+ name: "delay",
+ type: "number",
+ description: L10N.getStr("screenshotDelayDesc"),
+ manual: L10N.getStr("screenshotDelayManual"),
+ },
+ {
+ name: "dpr",
+ type: "number",
+ description: L10N.getStr("screenshotDPRDesc"),
+ manual: L10N.getStr("screenshotDPRManual"),
+ },
+ {
+ name: "fullpage",
+ type: "boolean",
+ description: L10N.getStr("screenshotFullPageDesc"),
+ manual: L10N.getStr("screenshotFullPageManual"),
+ },
+ {
+ name: "selector",
+ type: "string",
+ description: L10N.getStr("inspectNodeDesc"),
+ manual: L10N.getStr("inspectNodeManual"),
+ },
+ {
+ name: "file",
+ type: "boolean",
+ description: L10N.getStr("screenshotFileDesc"),
+ manual: L10N.getStr("screenshotFileManual"),
+ },
+ {
+ name: "filename",
+ type: "string",
+ description: L10N.getStr("screenshotFilenameDesc"),
+ manual: L10N.getStr("screenshotFilenameManual"),
+ },
+];
+
+/**
+ * Creates a string from an object for use when screenshot is passed the `--help` argument
+ *
+ * @param object param
+ * The param object to be formatted.
+ * @return string
+ * The formatted information from the param object as a string
+ */
+function formatHelpField(param) {
+ const padding = " ".repeat(5);
+ return Object.entries(param)
+ .map(([key, value]) => {
+ if (key === "name") {
+ const name = `${padding}--${value}`;
+ return name;
+ }
+ return `${padding.repeat(2)}${key}: ${value}`;
+ })
+ .join("\n");
+}
+
+/**
+ * Creates a string response from the screenshot options for use when
+ * screenshot is passed the `--help` argument
+ *
+ * @return string
+ * The formatted information from the param object as a string
+ */
+function getFormattedHelpData() {
+ const formattedParams = screenshotCommandParams
+ .map(formatHelpField)
+ .join("\n\n");
+
+ return `${screenshotDescription}\n${screenshotGroupOptions}\n\n${formattedParams}`;
+}
+
+/**
+ * Main entry point in this file; Takes the original arguments that `:screenshot` was
+ * called with and the image value from the server, and uses the client window to add
+ * and audio effect.
+ *
+ * @param object window
+ * The DevTools Client window.
+ *
+ * @param object args
+ * The original args with which the screenshot
+ * was called.
+ * @param object value
+ * an object with a image value and file name
+ *
+ * @return string[]
+ * Response messages from processing the screenshot
+ */
+function saveScreenshot(window, args = {}, value) {
+ // @backward-compat { version 87 } This is still needed by the console when connecting
+ // to an older server. Once 87 is in release, we can remove this whole block since we
+ // already handle args.help in captureScreenshotAndSave.
+ if (args.help) {
+ // Wrap message in an array so that the return value is consistant.
+ return [{ text: getFormattedHelpData() }];
+ }
+
+ // Guard against missing image data.
+ if (!value.data) {
+ return [];
+ }
+
+ simulateCameraShutter(window);
+ return save(window, args, value);
+}
+
+/**
+ * This function is called to simulate camera effects
+ *
+ * @param object document
+ * The DevTools Client document.
+ */
+function simulateCameraShutter(window) {
+ if (Services.prefs.getBoolPref("devtools.screenshot.audio.enabled")) {
+ const audioCamera = new window.Audio(
+ "resource://devtools/client/themes/audio/shutter.wav"
+ );
+ audioCamera.play();
+ }
+}
+
+/**
+ * Save the captured screenshot to one of several destinations.
+ *
+ * @param object window
+ * The DevTools Client window.
+ *
+ * @param object args
+ * The original args with which the screenshot was called.
+ *
+ * @param object image
+ * The image object that was sent from the server.
+ *
+ *
+ * @return string[]
+ * Response messages from processing the screenshot.
+ */
+async function save(window, args, image) {
+ const fileNeeded = args.filename || !args.clipboard || args.file;
+ const results = [];
+
+ if (args.clipboard) {
+ const result = saveToClipboard(image.data);
+ results.push(result);
+ }
+
+ if (fileNeeded) {
+ const result = await saveToFile(window, image);
+ results.push(result);
+ }
+ return results;
+}
+
+/**
+ * Save the image data to the clipboard. This returns a promise, so it can
+ * be treated exactly like file processing.
+ *
+ * @param string base64URI
+ * The image data encoded in a base64 URI that was sent from the server.
+ *
+ * @return string
+ * Response message from processing the screenshot.
+ */
+function saveToClipboard(base64URI) {
+ try {
+ const imageTools = Cc["@mozilla.org/image/tools;1"].getService(
+ Ci.imgITools
+ );
+
+ const base64Data = base64URI.replace("data:image/png;base64,", "");
+
+ const image = atob(base64Data);
+ const img = 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", img);
+
+ Services.clipboard.setData(
+ transferable,
+ null,
+ Services.clipboard.kGlobalClipboard
+ );
+ return { text: L10N.getStr("screenshotCopied") };
+ } catch (ex) {
+ console.error(ex);
+ return { level: "error", text: L10N.getStr("screenshotErrorCopying") };
+ }
+}
+
+let _outputDirectory = null;
+
+/**
+ * Returns the default directory for screenshots.
+ * If a specific directory for screenshots is not defined,
+ * it falls back to the system downloads directory.
+ *
+ * @return {Promise<String>} Resolves the path as a string
+ */
+async function getOutputDirectory() {
+ if (_outputDirectory) {
+ return _outputDirectory;
+ }
+
+ try {
+ // This will throw if there is not a screenshot directory set for the platform
+ _outputDirectory = Services.dirsvc.get("Scrnshts", Ci.nsIFile).path;
+ } catch (e) {
+ _outputDirectory = await lazy.Downloads.getPreferredDownloadsDirectory();
+ }
+
+ return _outputDirectory;
+}
+
+/**
+ * Save the screenshot data to disk, returning a promise which is resolved on
+ * completion.
+ *
+ * @param object window
+ * The DevTools Client window.
+ *
+ * @param object image
+ * The image object that was sent from the server.
+ *
+ * @return string
+ * Response message from processing the screenshot.
+ */
+async function saveToFile(window, image) {
+ let filename = image.filename;
+
+ // Guard against missing image data.
+ if (!image.data) {
+ return "";
+ }
+
+ // Check there is a .png extension to filename
+ if (!filename.match(/.png$/i)) {
+ filename += ".png";
+ }
+
+ const dir = await getOutputDirectory();
+ const dirExists = await IOUtils.exists(dir);
+ if (dirExists) {
+ // If filename is absolute, it will override the downloads directory and
+ // still be applied as expected.
+ filename = PathUtils.isAbsolute(filename)
+ ? filename
+ : PathUtils.joinRelative(dir, filename);
+ }
+
+ const targetFile = new lazy.FileUtils.File(filename);
+
+ // Create download and track its progress.
+ try {
+ const download = await lazy.Downloads.createDownload({
+ source: {
+ url: image.data,
+ // Here we want to know if the window in which the screenshot is taken is private.
+ // We have a ChromeWindow when this is called from Browser Console (:screenshot) and
+ // RDM (screenshot button).
+ isPrivate: window.isChromeWindow
+ ? lazy.PrivateBrowsingUtils.isWindowPrivate(window)
+ : lazy.PrivateBrowsingUtils.isBrowserPrivate(
+ window.browsingContext.embedderElement
+ ),
+ },
+ target: targetFile,
+ });
+ const list = await lazy.Downloads.getList(lazy.Downloads.ALL);
+ // 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();
+ return { text: L10N.getFormatStr("screenshotSavedToFile", filename) };
+ } catch (ex) {
+ console.error(ex);
+ return {
+ level: "error",
+ text: L10N.getFormatStr("screenshotErrorSavingToFile", filename),
+ };
+ }
+}
+
+module.exports = {
+ captureAndSaveScreenshot,
+ captureScreenshot,
+ saveScreenshot,
+};