Mypal/devtools/shared/gcli/commands/screenshot.js
2019-03-11 13:26:37 +03:00

586 lines
20 KiB
JavaScript

/* 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);
}
});