| 1 | import { existsSync, statSync } from "node:fs"; |
| 2 | import { writeFile, rename, rm } from "node:fs/promises"; |
| 3 | import { isAbsolute, join } from "node:path"; |
| 4 | import type { DocumentBinding } from "./documents.js"; |
| 5 | import type { GuestPage } from "./guestView.js"; |
| 6 | import { resolveRef } from "./refResolver.js"; |
| 7 | import { browserFailure, staleReference } from "./errors.js"; |
| 8 | import { MAX_CAPTURE_PIXELS, validatePNG } from "./png.js"; |
| 9 | import { acquireDebugger } from "./debuggerLease.js"; |
| 10 | export { pngSize } from "./png.js"; |
| 11 | |
| 12 | export interface ScreenshotRequest { |
| 13 | ref: string; |
| 14 | fullPage: boolean; |
| 15 | directory: string; |
| 16 | } |
| 17 | |
| 18 | export interface ScreenshotResult { |
| 19 | path: string; |
| 20 | mime: "image/png"; |
| 21 | width: number; |
| 22 | height: number; |
| 23 | reason?: string; |
| 24 | observationToken?: string; |
| 25 | cssWidth?: number; |
| 26 | cssHeight?: number; |
| 27 | } |
| 28 | |
| 29 | export interface ScreenshotDeps { |
| 30 | directoryExists?(path: string): boolean; |
| 31 | writeFile?(path: string, data: Buffer): void | Promise<void>; |
| 32 | decodePNG?(data: Buffer): { width: number; height: number }; |
| 33 | verify?(): void; |
| 34 | viewport?: { width: number; height: number } | null; |
| 35 | pixelRatio?: number; |
| 36 | now?(): number; |
| 37 | } |
| 38 | |
| 39 | let sequence = 0; |
| 40 | |
| 41 | const defaultDirectoryExists = (path: string) => { |
| 42 | try { |
| 43 | return existsSync(path) && statSync(path).isDirectory(); |
| 44 | } catch { |
| 45 | return false; |
| 46 | } |
| 47 | }; |
| 48 | |
| 49 | export function screenshotPath(directory: string, now: number): string { |
| 50 | sequence += 1; |
| 51 | return join(directory, `shot-${now}-${sequence}.png`); |
| 52 | } |
| 53 | |
| 54 | // Full-page captures go through the DevTools protocol because a |
| 55 | // WebContentsView cannot be resized past the window; everything else uses |
| 56 | // capturePage on the visible viewport or the element's rect. |
| 57 | export async function captureScreenshot(page: GuestPage, binding: DocumentBinding | null, zoom: number, request: ScreenshotRequest, deps: ScreenshotDeps = {}): Promise<ScreenshotResult> { |
| 58 | const directoryExists = deps.directoryExists ?? defaultDirectoryExists; |
| 59 | if (!isAbsolute(request.directory) || !directoryExists(request.directory)) throw new Error("screenshot directory must be an existing absolute path"); |
| 60 | const target = screenshotPath(request.directory, (deps.now ?? Date.now)()); |
| 61 | deps.verify?.(); |
| 62 | let png: Buffer; |
| 63 | let size: { width: number; height: number }; |
| 64 | if (request.fullPage) { |
| 65 | const full = await captureFullPage(page, deps.pixelRatio ?? 1); |
| 66 | png = full.png; |
| 67 | size = full.size; |
| 68 | } else { |
| 69 | let rect: Electron.Rectangle | undefined; |
| 70 | if (request.ref !== "") { |
| 71 | if (!binding) throw new Error("element screenshots need a snapshot first"); |
| 72 | const resolved = await resolveRef(page, binding, request.ref, true); |
| 73 | if (resolved.ok) { |
| 74 | const { element } = resolved.value; |
| 75 | rect = { |
| 76 | x: Math.max(0, Math.floor(element.x * zoom)), |
| 77 | y: Math.max(0, Math.floor(element.y * zoom)), |
| 78 | width: Math.max(1, Math.ceil(element.width * zoom)), |
| 79 | height: Math.max(1, Math.ceil(element.height * zoom)), |
| 80 | }; |
| 81 | } else throw staleReference(`cannot capture the requested element: ${resolved.reason}`); |
| 82 | } |
| 83 | if (deps.viewport) { |
| 84 | const clip = rect ? { x: rect.x / zoom, y: rect.y / zoom, width: rect.width / zoom, height: rect.height / zoom } : { x: 0, y: 0, ...deps.viewport }; |
| 85 | const dbg = page.debugger; |
| 86 | const release = acquireDebugger(dbg); |
| 87 | try { |
| 88 | const metrics = await release.send("Page.getLayoutMetrics") as { cssVisualViewport?: { pageX: number; pageY: number } }; |
| 89 | const result = await release.send("Page.captureScreenshot", { format: "png", fromSurface: true, captureBeyondViewport: true, clip: { ...clip, x: clip.x + (metrics.cssVisualViewport?.pageX ?? 0), y: clip.y + (metrics.cssVisualViewport?.pageY ?? 0), scale: 1 / (deps.pixelRatio ?? 1) } }) as { data?: string }; |
| 90 | png = Buffer.from(result.data ?? "", "base64"); |
| 91 | size = { width: Math.round(clip.width), height: Math.round(clip.height) }; |
| 92 | } finally { release(); } |
| 93 | } else { |
| 94 | const image = await page.capturePage(rect); |
| 95 | png = image.toPNG(); |
| 96 | size = image.getSize(); |
| 97 | } |
| 98 | } |
| 99 | const actual = validatePNG(png); |
| 100 | const decoded = deps.decodePNG?.(png) ?? actual; |
| 101 | if (actual.width !== size.width || actual.height !== size.height || decoded.width !== actual.width || decoded.height !== actual.height) { |
| 102 | throw browserFailure("invalid_image", `PNG dimensions ${actual.width}x${actual.height} do not match captured surface ${size.width}x${size.height} (decoded ${decoded.width}x${decoded.height})`); |
| 103 | } |
| 104 | deps.verify?.(); |
| 105 | if (deps.writeFile) await deps.writeFile(target, png); |
| 106 | else { |
| 107 | const temporary = `${target}.tmp`; |
| 108 | try { |
| 109 | await writeFile(temporary, png, { flag: "wx", mode: 0o600 }); |
| 110 | deps.verify?.(); |
| 111 | await rename(temporary, target); |
| 112 | deps.verify?.(); |
| 113 | } catch (error) { |
| 114 | await rm(temporary, { force: true }); |
| 115 | await rm(target, { force: true }); |
| 116 | throw error; |
| 117 | } |
| 118 | } |
| 119 | return { path: target, mime: "image/png", width: actual.width, height: actual.height }; |
| 120 | } |
| 121 | |
| 122 | async function captureFullPage(page: GuestPage, pixelRatio: number): Promise<{ png: Buffer; size: { width: number; height: number } }> { |
| 123 | const dbg = page.debugger; |
| 124 | const release = acquireDebugger(dbg); |
| 125 | try { |
| 126 | const metrics = await release.send("Page.getLayoutMetrics") as { cssContentSize?: { width: number; height: number } }; |
| 127 | const content = metrics.cssContentSize; |
| 128 | if (!content || !Number.isFinite(content.width * content.height) || content.width <= 0 || content.height <= 0 || content.width * content.height > MAX_CAPTURE_PIXELS) { |
| 129 | throw browserFailure("invalid_image", "full page exceeds the capture pixel budget; capture the viewport or an element"); |
| 130 | } |
| 131 | const size = { width: Math.ceil(content.width), height: Math.ceil(content.height) }; |
| 132 | if (size.width * size.height > MAX_CAPTURE_PIXELS) throw browserFailure("invalid_image", "full page exceeds the capture pixel budget"); |
| 133 | const result = (await release.send("Page.captureScreenshot", { format: "png", captureBeyondViewport: true, fromSurface: true, clip: { x: 0, y: 0, ...size, scale: 1 / pixelRatio } })) as { data?: unknown }; |
| 134 | if (typeof result?.data !== "string") throw new Error("Page.captureScreenshot returned no image"); |
| 135 | return { png: Buffer.from(result.data, "base64"), size }; |
| 136 | } finally { |
| 137 | release(); |
| 138 | } |
| 139 | } |
| 140 |