screenshot.js (13182B)
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 5 "use strict"; 6 7 const { LocalizationHelper } = require("resource://devtools/shared/l10n.js"); 8 9 const lazy = {}; 10 11 ChromeUtils.defineESModuleGetters(lazy, { 12 Downloads: "resource://gre/modules/Downloads.sys.mjs", 13 FileUtils: "resource://gre/modules/FileUtils.sys.mjs", 14 PrivateBrowsingUtils: "resource://gre/modules/PrivateBrowsingUtils.sys.mjs", 15 }); 16 17 const STRINGS_URI = "devtools/shared/locales/screenshot.properties"; 18 const L10N = new LocalizationHelper(STRINGS_URI); 19 20 /** 21 * Take a screenshot of a browser element matching the passed target and save it to a file 22 * or the clipboard. 23 * 24 * @param {TargetFront} targetFront: The targetFront of the frame we want to take a screenshot of. 25 * @param {Window} window: The DevTools Client window. 26 * @param {object} args 27 * @param {boolean} args.fullpage: Should the screenshot be the height of the whole page 28 * @param {string} args.filename: Expected filename for the screenshot 29 * @param {boolean} args.clipboard: Whether or not the screenshot should be saved to the clipboard. 30 * @param {number} args.dpr: Scale of the screenshot. Defaults to the window `devicePixelRatio`. 31 * ⚠️ Note that the scale might be decreased if the resulting 32 * image would be too big to draw safely. Warning will be emitted 33 * to the console if that's the case. 34 * @param {number} args.delay: Number of seconds to wait before taking the screenshot 35 * @param {boolean} args.help: Set to true to receive a message with the screenshot command 36 * documentation. 37 * @param {boolean} args.disableFlash: Set to true to disable the flash animation when the 38 * screenshot is taken. 39 * @param {boolean} args.ignoreDprForFileScale: Set to true to if the resulting screenshot 40 * file size shouldn't be impacted by the dpr. Note that the dpr will still 41 * be taken into account when taking the screenshot, only the size of the 42 * file will be different. 43 * @returns {Array<Object{text, level}>} An array of object representing the different 44 * messages emitted throught the process, that should be displayed to the user. 45 */ 46 async function captureAndSaveScreenshot(targetFront, window, args = {}) { 47 if (args.help) { 48 // Wrap message in an array so that the return value is consistant. 49 return [{ text: getFormattedHelpData() }]; 50 } 51 52 const captureResponse = await captureScreenshot(targetFront, args); 53 54 if (captureResponse.error) { 55 return captureResponse.messages || []; 56 } 57 58 const saveMessages = await saveScreenshot(window, args, captureResponse); 59 return (captureResponse.messages || []).concat(saveMessages); 60 } 61 62 /** 63 * Take a screenshot of a browser element matching the passed target 64 * 65 * @param {TargetFront} targetFront: The targetFront of the frame we want to take a screenshot of. 66 * @param {object} args: See args param in captureAndSaveScreenshot 67 */ 68 async function captureScreenshot(targetFront, args) { 69 // @backward-compat { version 87 } The screenshot-content actor was introduced in 87, 70 // so we can always use it once 87 reaches release. 71 const supportsContentScreenshot = targetFront.hasActor("screenshotContent"); 72 if (!supportsContentScreenshot) { 73 const screenshotFront = await targetFront.getFront("screenshot"); 74 return screenshotFront.capture(args); 75 } 76 77 if (args.delay > 0) { 78 await new Promise(res => setTimeout(res, args.delay * 1000)); 79 } 80 81 const screenshotContentFront = 82 await targetFront.getFront("screenshot-content"); 83 84 // Call the content-process on the server to retrieve informations that will be needed 85 // by the parent process. 86 const { rect, windowDpr, windowZoom, messages, error } = 87 await screenshotContentFront.prepareCapture(args); 88 89 if (error) { 90 return { error, messages }; 91 } 92 93 if (rect) { 94 args.rect = rect; 95 } 96 97 args.dpr ||= windowDpr; 98 99 args.snapshotScale = args.dpr * windowZoom; 100 if (args.ignoreDprForFileScale) { 101 args.fileScale = windowZoom; 102 } 103 104 args.browsingContextID = targetFront.browsingContextID; 105 106 // We can now call the parent process which will take the screenshot via 107 // the drawSnapshot API 108 const rootFront = targetFront.client.mainRoot; 109 const parentProcessScreenshotFront = await rootFront.getFront("screenshot"); 110 const captureResponse = await parentProcessScreenshotFront.capture(args); 111 112 return { 113 ...captureResponse, 114 messages: (messages || []).concat(captureResponse.messages || []), 115 }; 116 } 117 118 const screenshotDescription = L10N.getStr("screenshotDesc"); 119 const screenshotGroupOptions = L10N.getStr("screenshotGroupOptions"); 120 const screenshotCommandParams = [ 121 { 122 name: "clipboard", 123 type: "boolean", 124 description: L10N.getStr("screenshotClipboardDesc"), 125 manual: L10N.getStr("screenshotClipboardManual"), 126 }, 127 { 128 name: "delay", 129 type: "number", 130 description: L10N.getStr("screenshotDelayDesc"), 131 manual: L10N.getStr("screenshotDelayManual"), 132 }, 133 { 134 name: "dpr", 135 type: "number", 136 description: L10N.getStr("screenshotDPRDesc"), 137 manual: L10N.getStr("screenshotDPRManual"), 138 }, 139 { 140 name: "fullpage", 141 type: "boolean", 142 description: L10N.getStr("screenshotFullPageDesc"), 143 manual: L10N.getStr("screenshotFullPageManual"), 144 }, 145 { 146 name: "selector", 147 type: "string", 148 description: L10N.getStr("inspectNodeDesc"), 149 manual: L10N.getStr("inspectNodeManual"), 150 }, 151 { 152 name: "file", 153 type: "boolean", 154 description: L10N.getStr("screenshotFileDesc"), 155 manual: L10N.getStr("screenshotFileManual"), 156 }, 157 { 158 name: "filename", 159 type: "string", 160 description: L10N.getStr("screenshotFilenameDesc"), 161 manual: L10N.getStr("screenshotFilenameManual"), 162 }, 163 ]; 164 165 /** 166 * Creates a string from an object for use when screenshot is passed the `--help` argument 167 * 168 * @param object param 169 * The param object to be formatted. 170 * @return string 171 * The formatted information from the param object as a string 172 */ 173 function formatHelpField(param) { 174 const padding = " ".repeat(5); 175 return Object.entries(param) 176 .map(([key, value]) => { 177 if (key === "name") { 178 const name = `${padding}--${value}`; 179 return name; 180 } 181 return `${padding.repeat(2)}${key}: ${value}`; 182 }) 183 .join("\n"); 184 } 185 186 /** 187 * Creates a string response from the screenshot options for use when 188 * screenshot is passed the `--help` argument 189 * 190 * @return string 191 * The formatted information from the param object as a string 192 */ 193 function getFormattedHelpData() { 194 const formattedParams = screenshotCommandParams 195 .map(formatHelpField) 196 .join("\n\n"); 197 198 return `${screenshotDescription}\n${screenshotGroupOptions}\n\n${formattedParams}`; 199 } 200 201 /** 202 * Main entry point in this file; Takes the original arguments that `:screenshot` was 203 * called with and the image value from the server, and uses the client window to add 204 * and audio effect. 205 * 206 * @param object window 207 * The DevTools Client window. 208 * 209 * @param object args 210 * The original args with which the screenshot 211 * was called. 212 * @param object value 213 * an object with a image value and file name 214 * 215 * @return string[] 216 * Response messages from processing the screenshot 217 */ 218 function saveScreenshot(window, args = {}, value) { 219 // @backward-compat { version 87 } This is still needed by the console when connecting 220 // to an older server. Once 87 is in release, we can remove this whole block since we 221 // already handle args.help in captureScreenshotAndSave. 222 if (args.help) { 223 // Wrap message in an array so that the return value is consistant. 224 return [{ text: getFormattedHelpData() }]; 225 } 226 227 // Guard against missing image data. 228 if (!value.data) { 229 return []; 230 } 231 232 simulateCameraShutter(window); 233 return save(window, args, value); 234 } 235 236 /** 237 * This function is called to simulate camera effects 238 * 239 * @param object document 240 * The DevTools Client document. 241 */ 242 function simulateCameraShutter(window) { 243 if (Services.prefs.getBoolPref("devtools.screenshot.audio.enabled")) { 244 const audioCamera = new window.Audio( 245 "resource://devtools/client/themes/audio/shutter.wav" 246 ); 247 audioCamera.play(); 248 } 249 } 250 251 /** 252 * Save the captured screenshot to one of several destinations. 253 * 254 * @param object window 255 * The DevTools Client window. 256 * 257 * @param object args 258 * The original args with which the screenshot was called. 259 * 260 * @param object image 261 * The image object that was sent from the server. 262 * 263 * 264 * @return string[] 265 * Response messages from processing the screenshot. 266 */ 267 async function save(window, args, image) { 268 const fileNeeded = args.filename || !args.clipboard || args.file; 269 const results = []; 270 271 if (args.clipboard) { 272 const result = saveToClipboard(image.data); 273 results.push(result); 274 } 275 276 if (fileNeeded) { 277 const result = await saveToFile(window, image); 278 results.push(result); 279 } 280 return results; 281 } 282 283 /** 284 * Save the image data to the clipboard. This returns a promise, so it can 285 * be treated exactly like file processing. 286 * 287 * @param string base64URI 288 * The image data encoded in a base64 URI that was sent from the server. 289 * 290 * @return string 291 * Response message from processing the screenshot. 292 */ 293 function saveToClipboard(base64URI) { 294 try { 295 const imageTools = Cc["@mozilla.org/image/tools;1"].getService( 296 Ci.imgITools 297 ); 298 299 const base64Data = base64URI.replace("data:image/png;base64,", ""); 300 301 const image = atob(base64Data); 302 const img = imageTools.decodeImageFromBuffer( 303 image, 304 image.length, 305 "image/png" 306 ); 307 308 const transferable = Cc[ 309 "@mozilla.org/widget/transferable;1" 310 ].createInstance(Ci.nsITransferable); 311 transferable.init(null); 312 transferable.addDataFlavor("image/png"); 313 transferable.setTransferData("image/png", img); 314 315 Services.clipboard.setData( 316 transferable, 317 null, 318 Services.clipboard.kGlobalClipboard 319 ); 320 return { text: L10N.getStr("screenshotCopied") }; 321 } catch (ex) { 322 console.error(ex); 323 return { level: "error", text: L10N.getStr("screenshotErrorCopying") }; 324 } 325 } 326 327 let _outputDirectory = null; 328 329 /** 330 * Returns the default directory for DevTools screenshots. 331 * For consistency with the Firefox Screenshots feature, this will default to 332 * the preferred downloads directory. 333 * 334 * @return {Promise<string>} Resolves the path as a string 335 */ 336 async function getOutputDirectory() { 337 if (_outputDirectory) { 338 return _outputDirectory; 339 } 340 341 _outputDirectory = await lazy.Downloads.getPreferredScreenshotsDirectory(); 342 return _outputDirectory; 343 } 344 345 /** 346 * Save the screenshot data to disk, returning a promise which is resolved on 347 * completion. 348 * 349 * @param object window 350 * The DevTools Client window. 351 * 352 * @param object image 353 * The image object that was sent from the server. 354 * 355 * @return string 356 * Response message from processing the screenshot. 357 */ 358 async function saveToFile(window, image) { 359 let filename = image.filename; 360 361 // Guard against missing image data. 362 if (!image.data) { 363 return ""; 364 } 365 366 // Check there is a .png extension to filename 367 if (!filename.match(/.png$/i)) { 368 filename += ".png"; 369 } 370 371 const dir = await getOutputDirectory(); 372 const dirExists = await IOUtils.exists(dir); 373 if (dirExists) { 374 // If filename is absolute, it will override the downloads directory and 375 // still be applied as expected. 376 filename = PathUtils.isAbsolute(filename) 377 ? filename 378 : PathUtils.joinRelative(dir, filename); 379 } 380 381 const targetFile = new lazy.FileUtils.File(filename); 382 383 // Create download and track its progress. 384 try { 385 const download = await lazy.Downloads.createDownload({ 386 source: { 387 url: image.data, 388 // Here we want to know if the window in which the screenshot is taken is private. 389 // We have a ChromeWindow when this is called from Browser Console (:screenshot) and 390 // RDM (screenshot button). 391 isPrivate: window.isChromeWindow 392 ? lazy.PrivateBrowsingUtils.isWindowPrivate(window) 393 : lazy.PrivateBrowsingUtils.isBrowserPrivate( 394 window.browsingContext.embedderElement 395 ), 396 }, 397 target: targetFile, 398 }); 399 const list = await lazy.Downloads.getList(lazy.Downloads.ALL); 400 // add the download to the download list in the Downloads list in the Browser UI 401 list.add(download); 402 // Await successful completion of the save via the download manager 403 await download.start(); 404 return { text: L10N.getFormatStr("screenshotSavedToFile", filename) }; 405 } catch (ex) { 406 console.error(ex); 407 return { 408 level: "error", 409 text: L10N.getFormatStr("screenshotErrorSavingToFile", filename), 410 }; 411 } 412 } 413 414 module.exports = { 415 captureAndSaveScreenshot, 416 captureScreenshot, 417 saveScreenshot, 418 };