screenshot.js 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586
  1. /* This Source Code Form is subject to the terms of the Mozilla Public
  2. * License, v. 2.0. If a copy of the MPL was not distributed with this
  3. * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
  4. "use strict";
  5. const { Cc, Ci, Cr, Cu } = require("chrome");
  6. const l10n = require("gcli/l10n");
  7. const Services = require("Services");
  8. const { NetUtil } = require("resource://gre/modules/NetUtil.jsm");
  9. const { getRect } = require("devtools/shared/layout/utils");
  10. const promise = require("promise");
  11. const defer = require("devtools/shared/defer");
  12. const { Task } = require("devtools/shared/task");
  13. loader.lazyImporter(this, "Downloads", "resource://gre/modules/Downloads.jsm");
  14. loader.lazyImporter(this, "OS", "resource://gre/modules/osfile.jsm");
  15. loader.lazyImporter(this, "FileUtils", "resource://gre/modules/FileUtils.jsm");
  16. loader.lazyImporter(this, "PrivateBrowsingUtils",
  17. "resource://gre/modules/PrivateBrowsingUtils.jsm");
  18. const BRAND_SHORT_NAME = Cc["@mozilla.org/intl/stringbundle;1"]
  19. .getService(Ci.nsIStringBundleService)
  20. .createBundle("chrome://branding/locale/brand.properties")
  21. .GetStringFromName("brandShortName");
  22. // String used as an indication to generate default file name in the following
  23. // format: "Screen Shot yyyy-mm-dd at HH.MM.SS.png"
  24. const FILENAME_DEFAULT_VALUE = " ";
  25. /*
  26. * There are 2 commands and 1 converter here. The 2 commands are nearly
  27. * identical except that one runs on the client and one in the server.
  28. *
  29. * The server command is hidden, and is designed to be called from the client
  30. * command.
  31. */
  32. /**
  33. * Both commands have the same initial filename parameter
  34. */
  35. const filenameParam = {
  36. name: "filename",
  37. type: {
  38. name: "file",
  39. filetype: "file",
  40. existing: "maybe",
  41. },
  42. defaultValue: FILENAME_DEFAULT_VALUE,
  43. description: l10n.lookup("screenshotFilenameDesc"),
  44. manual: l10n.lookup("screenshotFilenameManual")
  45. };
  46. /**
  47. * Both commands have the same set of standard optional parameters
  48. */
  49. const standardParams = {
  50. group: l10n.lookup("screenshotGroupOptions"),
  51. params: [
  52. {
  53. name: "clipboard",
  54. type: "boolean",
  55. description: l10n.lookup("screenshotClipboardDesc"),
  56. manual: l10n.lookup("screenshotClipboardManual")
  57. },
  58. {
  59. name: "imgur",
  60. type: "boolean",
  61. description: l10n.lookup("screenshotImgurDesc"),
  62. manual: l10n.lookup("screenshotImgurManual")
  63. },
  64. {
  65. name: "delay",
  66. type: { name: "number", min: 0 },
  67. defaultValue: 0,
  68. description: l10n.lookup("screenshotDelayDesc"),
  69. manual: l10n.lookup("screenshotDelayManual")
  70. },
  71. {
  72. name: "dpr",
  73. type: { name: "number", min: 0, allowFloat: true },
  74. defaultValue: 0,
  75. description: l10n.lookup("screenshotDPRDesc"),
  76. manual: l10n.lookup("screenshotDPRManual")
  77. },
  78. {
  79. name: "fullpage",
  80. type: "boolean",
  81. description: l10n.lookup("screenshotFullPageDesc"),
  82. manual: l10n.lookup("screenshotFullPageManual")
  83. },
  84. {
  85. name: "selector",
  86. type: "node",
  87. defaultValue: null,
  88. description: l10n.lookup("inspectNodeDesc"),
  89. manual: l10n.lookup("inspectNodeManual")
  90. }
  91. ]
  92. };
  93. exports.items = [
  94. {
  95. /**
  96. * Format an 'imageSummary' (as output by the screenshot command).
  97. * An 'imageSummary' is a simple JSON object that looks like this:
  98. *
  99. * {
  100. * destinations: [ "..." ], // Required array of descriptions of the
  101. * // locations of the result image (the command
  102. * // can have multiple outputs)
  103. * data: "...", // Optional Base64 encoded image data
  104. * width:1024, height:768, // Dimensions of the image data, required
  105. * // if data != null
  106. * filename: "...", // If set, clicking the image will open the
  107. * // folder containing the given file
  108. * href: "...", // If set, clicking the image will open the
  109. * // link in a new tab
  110. * }
  111. */
  112. item: "converter",
  113. from: "imageSummary",
  114. to: "dom",
  115. exec: function(imageSummary, context) {
  116. const document = context.document;
  117. const root = document.createElement("div");
  118. // Add a line to the result for each destination
  119. imageSummary.destinations.forEach(destination => {
  120. const title = document.createElement("div");
  121. title.textContent = destination;
  122. root.appendChild(title);
  123. });
  124. // Add the thumbnail image
  125. if (imageSummary.data != null) {
  126. const image = context.document.createElement("div");
  127. const previewHeight = parseInt(256 * imageSummary.height / imageSummary.width);
  128. const style = "" +
  129. "width: 256px;" +
  130. "height: " + previewHeight + "px;" +
  131. "max-height: 256px;" +
  132. "background-image: url('" + imageSummary.data + "');" +
  133. "background-size: 256px " + previewHeight + "px;" +
  134. "margin: 4px;" +
  135. "display: block;";
  136. image.setAttribute("style", style);
  137. root.appendChild(image);
  138. }
  139. // Click handler
  140. if (imageSummary.href || imageSummary.filename) {
  141. root.style.cursor = "pointer";
  142. root.addEventListener("click", () => {
  143. if (imageSummary.href) {
  144. let mainWindow = context.environment.chromeWindow;
  145. mainWindow.openUILinkIn(imageSummary.href, "tab");
  146. } else if (imageSummary.filename) {
  147. const file = Cc["@mozilla.org/file/local;1"].createInstance(Ci.nsILocalFile);
  148. file.initWithPath(imageSummary.filename);
  149. file.reveal();
  150. }
  151. });
  152. }
  153. return root;
  154. }
  155. },
  156. {
  157. item: "command",
  158. runAt: "client",
  159. name: "screenshot",
  160. description: l10n.lookup("screenshotDesc"),
  161. manual: l10n.lookup("screenshotManual"),
  162. returnType: "imageSummary",
  163. buttonId: "command-button-screenshot",
  164. buttonClass: "command-button command-button-invertable",
  165. tooltipText: l10n.lookup("screenshotTooltipPage"),
  166. params: [
  167. filenameParam,
  168. standardParams,
  169. ],
  170. exec: function (args, context) {
  171. // Re-execute the command on the server
  172. const command = context.typed.replace(/^screenshot/, "screenshot_server");
  173. let capture = context.updateExec(command).then(output => {
  174. return output.error ? Promise.reject(output.data) : output.data;
  175. });
  176. simulateCameraEffect(context.environment.chromeDocument, "shutter");
  177. return capture.then(saveScreenshot.bind(null, args, context));
  178. },
  179. },
  180. {
  181. item: "command",
  182. runAt: "server",
  183. name: "screenshot_server",
  184. hidden: true,
  185. returnType: "imageSummary",
  186. params: [ filenameParam, standardParams ],
  187. exec: function (args, context) {
  188. return captureScreenshot(args, context.environment.document);
  189. },
  190. }
  191. ];
  192. /**
  193. * This function is called to simulate camera effects
  194. */
  195. function simulateCameraEffect(document, effect) {
  196. let window = document.defaultView;
  197. if (effect === "shutter") {
  198. const audioCamera = new window.Audio("resource://devtools/client/themes/audio/shutter.wav");
  199. audioCamera.play();
  200. }
  201. if (effect == "flash") {
  202. const frames = Cu.cloneInto({ opacity: [ 0, 1 ] }, window);
  203. document.documentElement.animate(frames, 500);
  204. }
  205. }
  206. /**
  207. * This function simply handles the --delay argument before calling
  208. * createScreenshotData
  209. */
  210. function captureScreenshot(args, document) {
  211. if (args.delay > 0) {
  212. return new Promise((resolve, reject) => {
  213. document.defaultView.setTimeout(() => {
  214. createScreenshotData(document, args).then(resolve, reject);
  215. }, args.delay * 1000);
  216. });
  217. }
  218. else {
  219. return createScreenshotData(document, args);
  220. }
  221. }
  222. /**
  223. * There are several possible destinations for the screenshot, SKIP is used
  224. * in saveScreenshot() whenever one of them is not used
  225. */
  226. const SKIP = Promise.resolve();
  227. /**
  228. * Save the captured screenshot to one of several destinations.
  229. */
  230. function saveScreenshot(args, context, reply) {
  231. const fileNeeded = args.filename != FILENAME_DEFAULT_VALUE ||
  232. (!args.imgur && !args.clipboard);
  233. return Promise.all([
  234. args.clipboard ? saveToClipboard(context, reply) : SKIP,
  235. args.imgur ? uploadToImgur(reply) : SKIP,
  236. fileNeeded ? saveToFile(context, reply) : SKIP,
  237. ]).then(() => reply);
  238. }
  239. /**
  240. * This does the dirty work of creating a base64 string out of an
  241. * area of the browser window
  242. */
  243. function createScreenshotData(document, args) {
  244. const window = document.defaultView;
  245. let left = 0;
  246. let top = 0;
  247. let width;
  248. let height;
  249. const currentX = window.scrollX;
  250. const currentY = window.scrollY;
  251. let filename = getFilename(args.filename);
  252. if (args.fullpage) {
  253. // Bug 961832: GCLI screenshot shows fixed position element in wrong
  254. // position if we don't scroll to top
  255. window.scrollTo(0,0);
  256. width = window.innerWidth + window.scrollMaxX - window.scrollMinX;
  257. height = window.innerHeight + window.scrollMaxY - window.scrollMinY;
  258. let writingMode = "horizontal-tb";
  259. if (window.getComputedStyle(document.documentElement)) {
  260. writingMode = window.getComputedStyle(document.documentElement).writingMode;
  261. }
  262. let orientation = writingMode.substring(0, writingMode.indexOf("-")).toLowerCase();
  263. left = ((orientation != "vertical") ? left : (-width + window.innerWidth));
  264. filename = filename.replace(".png", "-fullpage.png");
  265. }
  266. else if (args.selector) {
  267. ({ top, left, width, height } = getRect(window, args.selector, window));
  268. }
  269. else {
  270. left = window.scrollX;
  271. top = window.scrollY;
  272. width = window.innerWidth;
  273. height = window.innerHeight;
  274. }
  275. // Only adjust for scrollbars when considering the full window
  276. if (!args.selector) {
  277. const winUtils = window.QueryInterface(Ci.nsIInterfaceRequestor)
  278. .getInterface(Ci.nsIDOMWindowUtils);
  279. const scrollbarHeight = {};
  280. const scrollbarWidth = {};
  281. winUtils.getScrollbarSize(false, scrollbarWidth, scrollbarHeight);
  282. width -= scrollbarWidth.value;
  283. height -= scrollbarHeight.value;
  284. }
  285. const canvas = document.createElementNS("http://www.w3.org/1999/xhtml", "canvas");
  286. const ctx = canvas.getContext("2d");
  287. const ratio = args.dpr ? args.dpr : window.devicePixelRatio;
  288. canvas.width = width * ratio;
  289. canvas.height = height * ratio;
  290. ctx.scale(ratio, ratio);
  291. ctx.drawWindow(window, left, top, width, height, "#fff");
  292. const data = canvas.toDataURL("image/png", "");
  293. // See comment above on bug 961832
  294. if (args.fullpage) {
  295. window.scrollTo(currentX, currentY);
  296. }
  297. simulateCameraEffect(document, "flash");
  298. return Promise.resolve({
  299. destinations: [],
  300. data: data,
  301. height: height,
  302. width: width,
  303. filename: filename,
  304. });
  305. }
  306. /**
  307. * We may have a filename specified in args, or we might have to generate
  308. * one.
  309. */
  310. function getFilename(defaultName) {
  311. // Create a name for the file if not present
  312. if (defaultName != FILENAME_DEFAULT_VALUE) {
  313. return defaultName;
  314. }
  315. const date = new Date();
  316. let dateString = date.getFullYear() + "-" + (date.getMonth() + 1) +
  317. "-" + date.getDate();
  318. dateString = dateString.split("-").map(function(part) {
  319. if (part.length == 1) {
  320. part = "0" + part;
  321. }
  322. return part;
  323. }).join("-");
  324. const timeString = date.toTimeString().replace(/:/g, ".").split(" ")[0];
  325. return l10n.lookupFormat("screenshotGeneratedFilename",
  326. [ dateString, timeString ]) + ".png";
  327. }
  328. /**
  329. * Save the image data to the clipboard. This returns a promise, so it can
  330. * be treated exactly like imgur / file processing, but it's really sync
  331. * for now.
  332. */
  333. function saveToClipboard(context, reply) {
  334. try {
  335. const channel = NetUtil.newChannel({
  336. uri: reply.data,
  337. loadUsingSystemPrincipal: true,
  338. contentPolicyType: Ci.nsIContentPolicy.TYPE_INTERNAL_IMAGE
  339. });
  340. const input = channel.open2();
  341. const loadContext = context.environment.chromeWindow
  342. .QueryInterface(Ci.nsIInterfaceRequestor)
  343. .getInterface(Ci.nsIWebNavigation)
  344. .QueryInterface(Ci.nsILoadContext);
  345. const imgTools = Cc["@mozilla.org/image/tools;1"]
  346. .getService(Ci.imgITools);
  347. const container = {};
  348. imgTools.decodeImageData(input, channel.contentType, container);
  349. const wrapped = Cc["@mozilla.org/supports-interface-pointer;1"]
  350. .createInstance(Ci.nsISupportsInterfacePointer);
  351. wrapped.data = container.value;
  352. const trans = Cc["@mozilla.org/widget/transferable;1"]
  353. .createInstance(Ci.nsITransferable);
  354. trans.init(loadContext);
  355. trans.addDataFlavor(channel.contentType);
  356. trans.setTransferData(channel.contentType, wrapped, -1);
  357. const clip = Cc["@mozilla.org/widget/clipboard;1"]
  358. .getService(Ci.nsIClipboard);
  359. clip.setData(trans, null, Ci.nsIClipboard.kGlobalClipboard);
  360. reply.destinations.push(l10n.lookup("screenshotCopied"));
  361. }
  362. catch (ex) {
  363. console.error(ex);
  364. reply.destinations.push(l10n.lookup("screenshotErrorCopying"));
  365. }
  366. return Promise.resolve();
  367. }
  368. /**
  369. * Upload screenshot data to Imgur, returning a promise of a URL (as a string)
  370. */
  371. function uploadToImgur(reply) {
  372. return new Promise((resolve, reject) => {
  373. const xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"]
  374. .createInstance(Ci.nsIXMLHttpRequest);
  375. const fd = Cc["@mozilla.org/files/formdata;1"]
  376. .createInstance(Ci.nsIDOMFormData);
  377. fd.append("image", reply.data.split(",")[1]);
  378. fd.append("type", "base64");
  379. fd.append("title", reply.filename);
  380. const postURL = Services.prefs.getCharPref("devtools.gcli.imgurUploadURL");
  381. const clientID = "Client-ID " + Services.prefs.getCharPref("devtools.gcli.imgurClientID");
  382. xhr.open("POST", postURL);
  383. xhr.setRequestHeader("Authorization", clientID);
  384. xhr.send(fd);
  385. xhr.responseType = "json";
  386. xhr.onreadystatechange = function() {
  387. if (xhr.readyState == 4) {
  388. if (xhr.status == 200) {
  389. reply.href = xhr.response.data.link;
  390. reply.destinations.push(l10n.lookupFormat("screenshotImgurUploaded",
  391. [ reply.href ]));
  392. } else {
  393. reply.destinations.push(l10n.lookup("screenshotImgurError"));
  394. }
  395. resolve();
  396. }
  397. };
  398. });
  399. }
  400. /**
  401. * Progress listener that forwards calls to a transfer object.
  402. *
  403. * This is used below in saveToFile to forward progress updates from the
  404. * nsIWebBrowserPersist object that does the actual saving to the nsITransfer
  405. * which just represents the operation for the Download Manager. This keeps the
  406. * Download Manager updated on saving progress and completion, so that it gives
  407. * visual feedback from the downloads toolbar button when the save is done.
  408. *
  409. * It also allows the browser window to show auth prompts if needed (should not
  410. * be needed for saving screenshots).
  411. *
  412. * This code is borrowed directly from contentAreaUtils.js.
  413. */
  414. function DownloadListener(win, transfer) {
  415. this.window = win;
  416. this.transfer = transfer;
  417. // For most method calls, forward to the transfer object.
  418. for (let name in transfer) {
  419. if (name != "QueryInterface" &&
  420. name != "onStateChange") {
  421. this[name] = (...args) => transfer[name].apply(transfer, args);
  422. }
  423. }
  424. // Allow saveToFile to await completion for error handling
  425. this._completedDeferred = defer();
  426. this.completed = this._completedDeferred.promise;
  427. }
  428. DownloadListener.prototype = {
  429. QueryInterface: function(iid) {
  430. if (iid.equals(Ci.nsIInterfaceRequestor) ||
  431. iid.equals(Ci.nsIWebProgressListener) ||
  432. iid.equals(Ci.nsIWebProgressListener2) ||
  433. iid.equals(Ci.nsISupports)) {
  434. return this;
  435. }
  436. throw Cr.NS_ERROR_NO_INTERFACE;
  437. },
  438. getInterface: function(iid) {
  439. if (iid.equals(Ci.nsIAuthPrompt) ||
  440. iid.equals(Ci.nsIAuthPrompt2)) {
  441. let ww = Cc["@mozilla.org/embedcomp/window-watcher;1"]
  442. .getService(Ci.nsIPromptFactory);
  443. return ww.getPrompt(this.window, iid);
  444. }
  445. throw Cr.NS_ERROR_NO_INTERFACE;
  446. },
  447. onStateChange: function(webProgress, request, state, status) {
  448. // Check if the download has completed
  449. if ((state & Ci.nsIWebProgressListener.STATE_STOP) &&
  450. (state & Ci.nsIWebProgressListener.STATE_IS_NETWORK)) {
  451. if (status == Cr.NS_OK) {
  452. this._completedDeferred.resolve();
  453. } else {
  454. this._completedDeferred.reject();
  455. }
  456. }
  457. this.transfer.onStateChange.apply(this.transfer, arguments);
  458. }
  459. };
  460. /**
  461. * Save the screenshot data to disk, returning a promise which is resolved on
  462. * completion.
  463. */
  464. var saveToFile = Task.async(function*(context, reply) {
  465. let document = context.environment.chromeDocument;
  466. let window = context.environment.chromeWindow;
  467. // Check there is a .png extension to filename
  468. if (!reply.filename.match(/.png$/i)) {
  469. reply.filename += ".png";
  470. }
  471. let downloadsDir = yield Downloads.getPreferredDownloadsDirectory();
  472. let downloadsDirExists = yield OS.File.exists(downloadsDir);
  473. if (downloadsDirExists) {
  474. // If filename is absolute, it will override the downloads directory and
  475. // still be applied as expected.
  476. reply.filename = OS.Path.join(downloadsDir, reply.filename);
  477. }
  478. let sourceURI = Services.io.newURI(reply.data, null, null);
  479. let targetFile = new FileUtils.File(reply.filename);
  480. let targetFileURI = Services.io.newFileURI(targetFile);
  481. // Create download and track its progress.
  482. // This is adapted from saveURL in contentAreaUtils.js, but simplified greatly
  483. // and modified to allow saving to arbitrary paths on disk. Using these
  484. // objects as opposed to just writing with OS.File allows us to tie into the
  485. // download manager to record a download entry and to get visual feedback from
  486. // the downloads toolbar button when the save is done.
  487. const nsIWBP = Ci.nsIWebBrowserPersist;
  488. const flags = nsIWBP.PERSIST_FLAGS_REPLACE_EXISTING_FILES |
  489. nsIWBP.PERSIST_FLAGS_FORCE_ALLOW_COOKIES |
  490. nsIWBP.PERSIST_FLAGS_BYPASS_CACHE |
  491. nsIWBP.PERSIST_FLAGS_AUTODETECT_APPLY_CONVERSION;
  492. let isPrivate =
  493. PrivateBrowsingUtils.isContentWindowPrivate(document.defaultView);
  494. let persist = Cc["@mozilla.org/embedding/browser/nsWebBrowserPersist;1"]
  495. .createInstance(Ci.nsIWebBrowserPersist);
  496. persist.persistFlags = flags;
  497. let tr = Cc["@mozilla.org/transfer;1"].createInstance(Ci.nsITransfer);
  498. tr.init(sourceURI,
  499. targetFileURI,
  500. "",
  501. null,
  502. null,
  503. null,
  504. persist,
  505. isPrivate);
  506. let listener = new DownloadListener(window, tr);
  507. persist.progressListener = listener;
  508. persist.savePrivacyAwareURI(sourceURI,
  509. null,
  510. document.documentURIObject,
  511. Ci.nsIHttpChannel
  512. .REFERRER_POLICY_NO_REFERRER_WHEN_DOWNGRADE,
  513. null,
  514. null,
  515. targetFileURI,
  516. isPrivate);
  517. try {
  518. // Await successful completion of the save via the listener
  519. yield listener.completed;
  520. reply.destinations.push(l10n.lookup("screenshotSavedToFile") +
  521. ` "${reply.filename}"`);
  522. } catch (ex) {
  523. console.error(ex);
  524. reply.destinations.push(l10n.lookup("screenshotErrorSavingToFile") + " " +
  525. reply.filename);
  526. }
  527. });