| 1 | import { existsSync, statSync, writeFileSync } from "node:fs"; |
| 2 | import { isAbsolute, join } from "node:path"; |
| 3 | import type { DocumentBinding } from "./documents.js"; |
| 4 | import type { GuestPage } from "./guestView.js"; |
| 5 | import { resolveRef } from "./refResolver.js"; |
| 6 | |
| 7 | export interface ScreenshotRequest { |
| 8 | ref: string; |
| 9 | fullPage: boolean; |
| 10 | directory: string; |
| 11 | } |
| 12 | |
| 13 | export interface ScreenshotResult { |
| 14 | path: string; |
| 15 | mime: "image/png"; |
| 16 | width: number; |
| 17 | height: number; |
| 18 | reason?: string; |
| 19 | } |
| 20 | |
| 21 | export interface ScreenshotDeps { |
| 22 | directoryExists?(path: string): boolean; |
| 23 | writeFile?(path: string, data: Buffer): void; |
| 24 | now?(): number; |
| 25 | } |
| 26 | |
| 27 | let sequence = 0; |
| 28 | |
| 29 | const defaultDirectoryExists = (path: string) => { |
| 30 | try { |
| 31 | return existsSync(path) && statSync(path).isDirectory(); |
| 32 | } catch { |
| 33 | return false; |
| 34 | } |
| 35 | }; |
| 36 | |
| 37 | export function pngSize(data: Buffer): { width: number; height: number } { |
| 38 | if (data.length < 24 || data.toString("latin1", 12, 16) !== "IHDR") return { width: 0, height: 0 }; |
| 39 | return { width: data.readUInt32BE(16), height: data.readUInt32BE(20) }; |
| 40 | } |
| 41 | |
| 42 | export function screenshotPath(directory: string, now: number): string { |
| 43 | sequence += 1; |
| 44 | return join(directory, `shot-${now}-${sequence}.png`); |
| 45 | } |
| 46 | |
| 47 | // Full-page captures go through the DevTools protocol because a |
| 48 | // WebContentsView cannot be resized past the window; everything else uses |
| 49 | // capturePage on the visible viewport or the element's rect. |
| 50 | export async function captureScreenshot(page: GuestPage, binding: DocumentBinding | null, zoom: number, request: ScreenshotRequest, deps: ScreenshotDeps = {}): Promise<ScreenshotResult> { |
| 51 | const directoryExists = deps.directoryExists ?? defaultDirectoryExists; |
| 52 | if (!isAbsolute(request.directory) || !directoryExists(request.directory)) throw new Error("screenshot directory must be an existing absolute path"); |
| 53 | const target = screenshotPath(request.directory, (deps.now ?? Date.now)()); |
| 54 | const write = deps.writeFile ?? ((path, data) => writeFileSync(path, data)); |
| 55 | let reason: string | undefined; |
| 56 | let png: Buffer; |
| 57 | let size: { width: number; height: number }; |
| 58 | if (request.fullPage) { |
| 59 | png = await captureFullPage(page); |
| 60 | size = pngSize(png); |
| 61 | } else { |
| 62 | let rect: Electron.Rectangle | undefined; |
| 63 | if (request.ref !== "") { |
| 64 | if (!binding) throw new Error("element screenshots need a snapshot first"); |
| 65 | const resolved = await resolveRef(page, binding, request.ref, true); |
| 66 | if (resolved.ok) { |
| 67 | const { element } = resolved.value; |
| 68 | rect = { |
| 69 | x: Math.max(0, Math.floor(element.x * zoom)), |
| 70 | y: Math.max(0, Math.floor(element.y * zoom)), |
| 71 | width: Math.max(1, Math.ceil(element.width * zoom)), |
| 72 | height: Math.max(1, Math.ceil(element.height * zoom)), |
| 73 | }; |
| 74 | } else reason = `captured the viewport instead: ${resolved.reason}`; |
| 75 | } |
| 76 | const image = await page.capturePage(rect); |
| 77 | png = image.toPNG(); |
| 78 | size = image.getSize(); |
| 79 | } |
| 80 | write(target, png); |
| 81 | return reason ? { path: target, mime: "image/png", width: size.width, height: size.height, reason } : { path: target, mime: "image/png", width: size.width, height: size.height }; |
| 82 | } |
| 83 | |
| 84 | async function captureFullPage(page: GuestPage): Promise<Buffer> { |
| 85 | const dbg = page.debugger; |
| 86 | const attached = dbg.isAttached(); |
| 87 | if (!attached) dbg.attach("1.3"); |
| 88 | try { |
| 89 | const result = (await dbg.sendCommand("Page.captureScreenshot", { format: "png", captureBeyondViewport: true, fromSurface: true })) as { data?: unknown }; |
| 90 | if (typeof result?.data !== "string") throw new Error("Page.captureScreenshot returned no image"); |
| 91 | return Buffer.from(result.data, "base64"); |
| 92 | } finally { |
| 93 | if (!attached && dbg.isAttached()) dbg.detach(); |
| 94 | } |
| 95 | } |
| 96 |