123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586 |
- /* 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;
- let writingMode = "horizontal-tb";
- if (window.getComputedStyle(document.documentElement)) {
- writingMode = window.getComputedStyle(document.documentElement).writingMode;
- }
- let orientation = writingMode.substring(0, writingMode.indexOf("-")).toLowerCase();
- left = ((orientation != "vertical") ? left : (-width + window.innerWidth));
- 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);
- }
- });
|