1import * as Electron from "electron";
2import { sourceDimensions } from "../preview/FaviconCapture.ts";
3
4const MAX_IMAGE_BYTES = 32 * 1024 * 1024;
5
6export async function copyContextMenuImage(
7 contents: Electron.WebContents,
8 params: Electron.ContextMenuParams,
9): Promise<void> {
10 const frame = params.frame;
11 if (contents.isDestroyed() || !frame || frame.isDestroyed()) return;
12 if (frame === contents.mainFrame) {
13 contents.copyImageAt(params.x, params.y);
14 return;
15 }
16
17 const url = new URL(params.srcURL);
18 if (
19 !["https:", "http:", "data:"].includes(url.protocol) ||
20 url.username ||
21 url.password ||
22 (url.protocol === "data:" && params.srcURL.length > MAX_IMAGE_BYTES * 3)
23 ) {
24 throw new Error("Unsupported image URL");
25 }
26 const fetchImage = url.protocol === "data:" ? globalThis.fetch : Electron.net.fetch;
27 const response = await fetchImage(url.href, {
28 credentials: "omit",
29 redirect: "error",
30 signal: AbortSignal.timeout(10_000),
31 });
32 if (!response.ok || Number(response.headers.get("content-length")) > MAX_IMAGE_BYTES) {
33 await response.body?.cancel();
34 throw new Error("Image request failed or exceeded the size limit");
35 }
36 if (!response.body) throw new Error("Image response has no body");
37
38 const reader = response.body.getReader();
39 const chunks: Uint8Array[] = [];
40 let byteLength = 0;
41 try {
42 while (true) {
43 const chunk = await reader.read();
44 if (chunk.done) break;
45 byteLength += chunk.value.byteLength;
46 if (byteLength > MAX_IMAGE_BYTES) {
47 await reader.cancel();
48 throw new Error("Image exceeded the size limit");
49 }
50 chunks.push(chunk.value);
51 }
52 } finally {
53 reader.releaseLock();
54 }
55
56 const bytes = Buffer.concat(chunks, byteLength);
57 const dimensions = sourceDimensions(bytes, MAX_IMAGE_BYTES / 4);
58 if (
59 !dimensions ||
60 dimensions.width <= 0 ||
61 dimensions.height <= 0 ||
62 dimensions.width * dimensions.height > MAX_IMAGE_BYTES / 4
63 ) {
64 throw new Error("Image dimensions exceed the size limit");
65 }
66 if (contents.isDestroyed() || frame.isDestroyed()) return;
67 const image = Electron.nativeImage.createFromBuffer(bytes);
68 let png: Buffer;
69 if (image.isEmpty()) {
70 const decoded: unknown = await contents.executeJavaScriptInIsolatedWorld(1002, [
71 {
72 code: `(async () => {
73 const source = Uint8Array.from(atob("${bytes.toString("base64")}"), char => char.charCodeAt(0));
74 const bitmap = await createImageBitmap(new Blob([source]));
75 try {
76 if (bitmap.width <= 0 || bitmap.height <= 0 || bitmap.width * bitmap.height > ${MAX_IMAGE_BYTES / 4}) {
77 throw new Error("Image dimensions exceed the size limit");
78 }
79 const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
80 const context = canvas.getContext("2d");
81 if (!context) throw new Error("Image could not be decoded");
82 context.drawImage(bitmap, 0, 0);
83 const output = await canvas.convertToBlob({ type: "image/png" });
84 if (output.size > ${MAX_IMAGE_BYTES}) throw new Error("PNG exceeded the size limit");
85 return await output.arrayBuffer();
86 } finally {
87 bitmap.close();
88 }
89 })()`,
90 },
91 ]);
92 if (!(decoded instanceof ArrayBuffer)) throw new Error("Image could not be decoded");
93 png = Buffer.from(decoded);
94 } else {
95 png = image.toPNG();
96 }
97 if (png.byteLength > MAX_IMAGE_BYTES) throw new Error("PNG exceeded the size limit");
98 if (contents.isDestroyed() || frame.isDestroyed()) return;
99 await Electron.clipboard.write([
100 new Electron.ClipboardItem({
101 "image/png": new Blob([Uint8Array.from(png)], { type: "image/png" }),
102 }),
103 ]);
104}