mirror of
https://github.com/mozilla/gecko-dev.git
synced 2024-10-09 19:35:51 +00:00
56a8127a68
MozReview-Commit-ID: GlNfyofypHt
580 lines
19 KiB
JavaScript
580 lines
19 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;
|
|
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);
|
|
}
|
|
});
|