summaryrefslogtreecommitdiffstats
path: root/devtools/shared/gcli/commands/screenshot.js
diff options
context:
space:
mode:
Diffstat (limited to 'devtools/shared/gcli/commands/screenshot.js')
-rw-r--r--devtools/shared/gcli/commands/screenshot.js579
1 files changed, 579 insertions, 0 deletions
diff --git a/devtools/shared/gcli/commands/screenshot.js b/devtools/shared/gcli/commands/screenshot.js
new file mode 100644
index 000000000..e2f38b6d9
--- /dev/null
+++ b/devtools/shared/gcli/commands/screenshot.js
@@ -0,0 +1,579 @@
+/* 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 { Cc, Ci, Cr, Cu } = require("chrome");
+const l10n = require("gcli/l10n");
+const Services = require("Services");
+const { NetUtil } = require("resource://gre/modules/NetUtil.jsm");
+const { getRect } = require("devtools/shared/layout/utils");
+const promise = require("promise");
+const defer = require("devtools/shared/defer");
+const { Task } = require("devtools/shared/task");
+
+loader.lazyImporter(this, "Downloads", "resource://gre/modules/Downloads.jsm");
+loader.lazyImporter(this, "OS", "resource://gre/modules/osfile.jsm");
+loader.lazyImporter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm");
+loader.lazyImporter(this, "PrivateBrowsingUtils",
+ "resource://gre/modules/PrivateBrowsingUtils.jsm");
+
+const BRAND_SHORT_NAME = Cc["@mozilla.org/intl/stringbundle;1"]
+ .getService(Ci.nsIStringBundleService)
+ .createBundle("chrome://branding/locale/brand.properties")
+ .GetStringFromName("brandShortName");
+
+// String used as an indication to generate default file name in the following
+// format: "Screen Shot yyyy-mm-dd at HH.MM.SS.png"
+const FILENAME_DEFAULT_VALUE = " ";
+
+/*
+ * There are 2 commands and 1 converter here. The 2 commands are nearly
+ * identical except that one runs on the client and one in the server.
+ *
+ * The server command is hidden, and is designed to be called from the client
+ * command.
+ */
+
+/**
+ * Both commands have the same initial filename parameter
+ */
+const filenameParam = {
+ name: "filename",
+ type: {
+ name: "file",
+ filetype: "file",
+ existing: "maybe",
+ },
+ defaultValue: FILENAME_DEFAULT_VALUE,
+ description: l10n.lookup("screenshotFilenameDesc"),
+ manual: l10n.lookup("screenshotFilenameManual")
+};
+
+/**
+ * Both commands have the same set of standard optional parameters
+ */
+const standardParams = {
+ group: l10n.lookup("screenshotGroupOptions"),
+ params: [
+ {
+ name: "clipboard",
+ type: "boolean",
+ description: l10n.lookup("screenshotClipboardDesc"),
+ manual: l10n.lookup("screenshotClipboardManual")
+ },
+ {
+ name: "imgur",
+ type: "boolean",
+ description: l10n.lookup("screenshotImgurDesc"),
+ manual: l10n.lookup("screenshotImgurManual")
+ },
+ {
+ name: "delay",
+ type: { name: "number", min: 0 },
+ defaultValue: 0,
+ description: l10n.lookup("screenshotDelayDesc"),
+ manual: l10n.lookup("screenshotDelayManual")
+ },
+ {
+ name: "dpr",
+ type: { name: "number", min: 0, allowFloat: true },
+ defaultValue: 0,
+ description: l10n.lookup("screenshotDPRDesc"),
+ manual: l10n.lookup("screenshotDPRManual")
+ },
+ {
+ name: "fullpage",
+ type: "boolean",
+ description: l10n.lookup("screenshotFullPageDesc"),
+ manual: l10n.lookup("screenshotFullPageManual")
+ },
+ {
+ name: "selector",
+ type: "node",
+ defaultValue: null,
+ description: l10n.lookup("inspectNodeDesc"),
+ manual: l10n.lookup("inspectNodeManual")
+ }
+ ]
+};
+
+exports.items = [
+ {
+ /**
+ * Format an 'imageSummary' (as output by the screenshot command).
+ * An 'imageSummary' is a simple JSON object that looks like this:
+ *
+ * {
+ * destinations: [ "..." ], // Required array of descriptions of the
+ * // locations of the result image (the command
+ * // can have multiple outputs)
+ * data: "...", // Optional Base64 encoded image data
+ * width:1024, height:768, // Dimensions of the image data, required
+ * // if data != null
+ * filename: "...", // If set, clicking the image will open the
+ * // folder containing the given file
+ * href: "...", // If set, clicking the image will open the
+ * // link in a new tab
+ * }
+ */
+ item: "converter",
+ from: "imageSummary",
+ to: "dom",
+ exec: function(imageSummary, context) {
+ const document = context.document;
+ const root = document.createElement("div");
+
+ // Add a line to the result for each destination
+ imageSummary.destinations.forEach(destination => {
+ const title = document.createElement("div");
+ title.textContent = destination;
+ root.appendChild(title);
+ });
+
+ // Add the thumbnail image
+ if (imageSummary.data != null) {
+ const image = context.document.createElement("div");
+ const previewHeight = parseInt(256 * imageSummary.height / imageSummary.width);
+ const style = "" +
+ "width: 256px;" +
+ "height: " + previewHeight + "px;" +
+ "max-height: 256px;" +
+ "background-image: url('" + imageSummary.data + "');" +
+ "background-size: 256px " + previewHeight + "px;" +
+ "margin: 4px;" +
+ "display: block;";
+ image.setAttribute("style", style);
+ root.appendChild(image);
+ }
+
+ // Click handler
+ if (imageSummary.href || imageSummary.filename) {
+ root.style.cursor = "pointer";
+ root.addEventListener("click", () => {
+ if (imageSummary.href) {
+ let mainWindow = context.environment.chromeWindow;
+ mainWindow.openUILinkIn(imageSummary.href, "tab");
+ } else if (imageSummary.filename) {
+ const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
+ file.initWithPath(imageSummary.filename);
+ file.reveal();
+ }
+ });
+ }
+
+ return root;
+ }
+ },
+ {
+ item: "command",
+ runAt: "client",
+ name: "screenshot",
+ description: l10n.lookup("screenshotDesc"),
+ manual: l10n.lookup("screenshotManual"),
+ returnType: "imageSummary",
+ buttonId: "command-button-screenshot",
+ buttonClass: "command-button command-button-invertable",
+ tooltipText: l10n.lookup("screenshotTooltipPage"),
+ params: [
+ filenameParam,
+ standardParams,
+ ],
+ exec: function (args, context) {
+ // Re-execute the command on the server
+ const command = context.typed.replace(/^screenshot/, "screenshot_server");
+ let capture = context.updateExec(command).then(output => {
+ return output.error ? Promise.reject(output.data) : output.data;
+ });
+
+ simulateCameraEffect(context.environment.chromeDocument, "shutter");
+ return capture.then(saveScreenshot.bind(null, args, context));
+ },
+ },
+ {
+ item: "command",
+ runAt: "server",
+ name: "screenshot_server",
+ hidden: true,
+ returnType: "imageSummary",
+ params: [ filenameParam, standardParams ],
+ exec: function (args, context) {
+ return captureScreenshot(args, context.environment.document);
+ },
+ }
+];
+
+/**
+ * This function is called to simulate camera effects
+ */
+function simulateCameraEffect(document, effect) {
+ let window = document.defaultView;
+ if (effect === "shutter") {
+ const audioCamera = new window.Audio("resource://devtools/client/themes/audio/shutter.wav");
+ audioCamera.play();
+ }
+ if (effect == "flash") {
+ const frames = Cu.cloneInto({ opacity: [ 0, 1 ] }, window);
+ document.documentElement.animate(frames, 500);
+ }
+}
+
+/**
+ * This function simply handles the --delay argument before calling
+ * createScreenshotData
+ */
+function captureScreenshot(args, document) {
+ if (args.delay > 0) {
+ return new Promise((resolve, reject) => {
+ document.defaultView.setTimeout(() => {
+ createScreenshotData(document, args).then(resolve, reject);
+ }, args.delay * 1000);
+ });
+ }
+ else {
+ return createScreenshotData(document, args);
+ }
+}
+
+/**
+ * There are several possible destinations for the screenshot, SKIP is used
+ * in saveScreenshot() whenever one of them is not used
+ */
+const SKIP = Promise.resolve();
+
+/**
+ * Save the captured screenshot to one of several destinations.
+ */
+function saveScreenshot(args, context, reply) {
+ const fileNeeded = args.filename != FILENAME_DEFAULT_VALUE ||
+ (!args.imgur && !args.clipboard);
+
+ return Promise.all([
+ args.clipboard ? saveToClipboard(context, reply) : SKIP,
+ args.imgur ? uploadToImgur(reply) : SKIP,
+ fileNeeded ? saveToFile(context, reply) : SKIP,
+ ]).then(() => reply);
+}
+
+/**
+ * This does the dirty work of creating a base64 string out of an
+ * area of the browser window
+ */
+function createScreenshotData(document, args) {
+ const window = document.defaultView;
+ let left = 0;
+ let top = 0;
+ let width;
+ let height;
+ const currentX = window.scrollX;
+ const currentY = window.scrollY;
+
+ let filename = getFilename(args.filename);
+
+ if (args.fullpage) {
+ // Bug 961832: GCLI screenshot shows fixed position element in wrong
+ // position if we don't scroll to top
+ window.scrollTo(0,0);
+ width = window.innerWidth + window.scrollMaxX - window.scrollMinX;
+ height = window.innerHeight + window.scrollMaxY - window.scrollMinY;
+ filename = filename.replace(".png", "-fullpage.png");
+ }
+ else if (args.selector) {
+ ({ top, left, width, height } = getRect(window, args.selector, window));
+ }
+ else {
+ left = window.scrollX;
+ top = window.scrollY;
+ width = window.innerWidth;
+ height = window.innerHeight;
+ }
+
+ // Only adjust for scrollbars when considering the full window
+ if (!args.selector) {
+ const winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIDOMWindowUtils);
+ const scrollbarHeight = {};
+ const scrollbarWidth = {};
+ winUtils.getScrollbarSize(false, scrollbarWidth, scrollbarHeight);
+ width -= scrollbarWidth.value;
+ height -= scrollbarHeight.value;
+ }
+
+ const canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
+ const ctx = canvas.getContext("2d");
+ const ratio = args.dpr ? args.dpr : window.devicePixelRatio;
+ canvas.width = width * ratio;
+ canvas.height = height * ratio;
+ ctx.scale(ratio, ratio);
+ ctx.drawWindow(window, left, top, width, height, "#fff");
+ const data = canvas.toDataURL("image/png", "");
+
+ // See comment above on bug 961832
+ if (args.fullpage) {
+ window.scrollTo(currentX, currentY);
+ }
+
+ simulateCameraEffect(document, "flash");
+
+ return Promise.resolve({
+ destinations: [],
+ data: data,
+ height: height,
+ width: width,
+ filename: filename,
+ });
+}
+
+/**
+ * We may have a filename specified in args, or we might have to generate
+ * one.
+ */
+function getFilename(defaultName) {
+ // Create a name for the file if not present
+ if (defaultName != FILENAME_DEFAULT_VALUE) {
+ return defaultName;
+ }
+
+ const date = new Date();
+ let dateString = date.getFullYear() + "-" + (date.getMonth() + 1) +
+ "-" + date.getDate();
+ dateString = dateString.split("-").map(function(part) {
+ if (part.length == 1) {
+ part = "0" + part;
+ }
+ return part;
+ }).join("-");
+
+ const timeString = date.toTimeString().replace(/:/g, ".").split(" ")[0];
+ return l10n.lookupFormat("screenshotGeneratedFilename",
+ [ dateString, timeString ]) + ".png";
+}
+
+/**
+ * Save the image data to the clipboard. This returns a promise, so it can
+ * be treated exactly like imgur / file processing, but it's really sync
+ * for now.
+ */
+function saveToClipboard(context, reply) {
+ try {
+ const channel = NetUtil.newChannel({
+ uri: reply.data,
+ loadUsingSystemPrincipal: true,
+ contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE
+ });
+ const input = channel.open2();
+
+ const loadContext = context.environment.chromeWindow
+ .QueryInterface(Ci.nsIInterfaceRequestor)
+ .getInterface(Ci.nsIWebNavigation)
+ .QueryInterface(Ci.nsILoadContext);
+
+ const imgTools = Cc["@mozilla.org/image/tools;1"]
+ .getService(Ci.imgITools);
+
+ const container = {};
+ imgTools.decodeImageData(input, channel.contentType, container);
+
+ const wrapped = Cc["@mozilla.org/supports-interface-pointer;1"]
+ .createInstance(Ci.nsISupportsInterfacePointer);
+ wrapped.data = container.value;
+
+ const trans = Cc["@mozilla.org/widget/transferable;1"]
+ .createInstance(Ci.nsITransferable);
+ trans.init(loadContext);
+ trans.addDataFlavor(channel.contentType);
+ trans.setTransferData(channel.contentType, wrapped, -1);
+
+ const clip = Cc["@mozilla.org/widget/clipboard;1"]
+ .getService(Ci.nsIClipboard);
+ clip.setData(trans, null, Ci.nsIClipboard.kGlobalClipboard);
+
+ reply.destinations.push(l10n.lookup("screenshotCopied"));
+ }
+ catch (ex) {
+ console.error(ex);
+ reply.destinations.push(l10n.lookup("screenshotErrorCopying"));
+ }
+
+ return Promise.resolve();
+}
+
+/**
+ * Upload screenshot data to Imgur, returning a promise of a URL (as a string)
+ */
+function uploadToImgur(reply) {
+ return new Promise((resolve, reject) => {
+ const xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
+ .createInstance(Ci.nsIXMLHttpRequest);
+ const fd = Cc["@mozilla.org/files/formdata;1"]
+ .createInstance(Ci.nsIDOMFormData);
+ fd.append("image", reply.data.split(",")[1]);
+ fd.append("type", "base64");
+ fd.append("title", reply.filename);
+
+ const postURL = Services.prefs.getCharPref("devtools.gcli.imgurUploadURL");
+ const clientID = "Client-ID " + Services.prefs.getCharPref("devtools.gcli.imgurClientID");
+
+ xhr.open("POST", postURL);
+ xhr.setRequestHeader("Authorization", clientID);
+ xhr.send(fd);
+ xhr.responseType = "json";
+
+ xhr.onreadystatechange = function() {
+ if (xhr.readyState == 4) {
+ if (xhr.status == 200) {
+ reply.href = xhr.response.data.link;
+ reply.destinations.push(l10n.lookupFormat("screenshotImgurUploaded",
+ [ reply.href ]));
+ } else {
+ reply.destinations.push(l10n.lookup("screenshotImgurError"));
+ }
+
+ resolve();
+ }
+ };
+ });
+}
+
+/**
+ * Progress listener that forwards calls to a transfer object.
+ *
+ * This is used below in saveToFile to forward progress updates from the
+ * nsIWebBrowserPersist object that does the actual saving to the nsITransfer
+ * which just represents the operation for the Download Manager. This keeps the
+ * Download Manager updated on saving progress and completion, so that it gives
+ * visual feedback from the downloads toolbar button when the save is done.
+ *
+ * It also allows the browser window to show auth prompts if needed (should not
+ * be needed for saving screenshots).
+ *
+ * This code is borrowed directly from contentAreaUtils.js.
+ */
+function DownloadListener(win, transfer) {
+ this.window = win;
+ this.transfer = transfer;
+
+ // For most method calls, forward to the transfer object.
+ for (let name in transfer) {
+ if (name != "QueryInterface" &&
+ name != "onStateChange") {
+ this[name] = (...args) => transfer[name].apply(transfer, args);
+ }
+ }
+
+ // Allow saveToFile to await completion for error handling
+ this._completedDeferred = defer();
+ this.completed = this._completedDeferred.promise;
+}
+
+DownloadListener.prototype = {
+ QueryInterface: function(iid) {
+ if (iid.equals(Ci.nsIInterfaceRequestor) ||
+ iid.equals(Ci.nsIWebProgressListener) ||
+ iid.equals(Ci.nsIWebProgressListener2) ||
+ iid.equals(Ci.nsISupports)) {
+ return this;
+ }
+ throw Cr.NS_ERROR_NO_INTERFACE;
+ },
+
+ getInterface: function(iid) {
+ if (iid.equals(Ci.nsIAuthPrompt) ||
+ iid.equals(Ci.nsIAuthPrompt2)) {
+ let ww = Cc["@mozilla.org/embedcomp/window-watcher;1"]
+ .getService(Ci.nsIPromptFactory);
+ return ww.getPrompt(this.window, iid);
+ }
+
+ throw Cr.NS_ERROR_NO_INTERFACE;
+ },
+
+ onStateChange: function(webProgress, request, state, status) {
+ // Check if the download has completed
+ if ((state & Ci.nsIWebProgressListener.STATE_STOP) &&
+ (state & Ci.nsIWebProgressListener.STATE_IS_NETWORK)) {
+ if (status == Cr.NS_OK) {
+ this._completedDeferred.resolve();
+ } else {
+ this._completedDeferred.reject();
+ }
+ }
+
+ this.transfer.onStateChange.apply(this.transfer, arguments);
+ }
+};
+
+/**
+ * Save the screenshot data to disk, returning a promise which is resolved on
+ * completion.
+ */
+var saveToFile = Task.async(function*(context, reply) {
+ let document = context.environment.chromeDocument;
+ let window = context.environment.chromeWindow;
+
+ // Check there is a .png extension to filename
+ if (!reply.filename.match(/.png$/i)) {
+ reply.filename += ".png";
+ }
+
+ let downloadsDir = yield Downloads.getPreferredDownloadsDirectory();
+ let downloadsDirExists = yield OS.File.exists(downloadsDir);
+ if (downloadsDirExists) {
+ // If filename is absolute, it will override the downloads directory and
+ // still be applied as expected.
+ reply.filename = OS.Path.join(downloadsDir, reply.filename);
+ }
+
+ let sourceURI = Services.io.newURI(reply.data, null, null);
+ let targetFile = new FileUtils.File(reply.filename);
+ let targetFileURI = Services.io.newFileURI(targetFile);
+
+ // Create download and track its progress.
+ // This is adapted from saveURL in contentAreaUtils.js, but simplified greatly
+ // and modified to allow saving to arbitrary paths on disk. Using these
+ // objects as opposed to just writing with OS.File allows us to tie into the
+ // download manager to record a download entry and to get visual feedback from
+ // the downloads toolbar button when the save is done.
+ const nsIWBP = Ci.nsIWebBrowserPersist;
+ const flags = nsIWBP.PERSIST_FLAGS_REPLACE_EXISTING_FILES |
+ nsIWBP.PERSIST_FLAGS_FORCE_ALLOW_COOKIES |
+ nsIWBP.PERSIST_FLAGS_BYPASS_CACHE |
+ nsIWBP.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION;
+ let isPrivate =
+ PrivateBrowsingUtils.isContentWindowPrivate(document.defaultView);
+ let persist = Cc["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
+ .createInstance(Ci.nsIWebBrowserPersist);
+ persist.persistFlags = flags;
+ let tr = Cc["@mozilla.org/transfer;1"].createInstance(Ci.nsITransfer);
+ tr.init(sourceURI,
+ targetFileURI,
+ "",
+ null,
+ null,
+ null,
+ persist,
+ isPrivate);
+ let listener = new DownloadListener(window, tr);
+ persist.progressListener = listener;
+ persist.savePrivacyAwareURI(sourceURI,
+ null,
+ document.documentURIObject,
+ Ci.nsIHttpChannel
+ .REFERRER_POLICY_NO_REFERRER_WHEN_DOWNGRADE,
+ null,
+ null,
+ targetFileURI,
+ isPrivate);
+
+ try {
+ // Await successful completion of the save via the listener
+ yield listener.completed;
+ reply.destinations.push(l10n.lookup("screenshotSavedToFile") +
+ ` "${reply.filename}"`);
+ } catch (ex) {
+ console.error(ex);
+ reply.destinations.push(l10n.lookup("screenshotErrorSavingToFile") + " " +
+ reply.filename);
+ }
+});