authorgravatar for 62337003+Bil0000@users.noreply.github.comBilal Bakr <62337003+Bil0000@users.noreply.github.com> 2026-10-11 01:43:56+03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-10-10 17:43:56-05:00
logc8b89a01d70fd54cbc6b6a70ba0fdff702b821aa
treedf94480b0c4e2669424a709cc9646940eb53b07c
parent0442f5bfcca6eca556ee79d41f4addb7a2022188
signature Signed by PGP key B5690EEEBB952194

fix(desktop): copy images from HTML previews (#17555)


6 files changed, 359 insertions(+), 15 deletions(-)

apps/desktop/src/preview/FaviconCapture.test.ts+21
......@@ -5,6 +5,7 @@ import {
55 MAX_FAVICON_RESPONSE_BYTES,
66 captureFavicon,
77 selectFaviconCandidates,
8 sourceDimensions,
89} from "./FaviconCapture.ts";
910
1011const PNG = "data:image/png;base64,cG5n";
......@@ -47,6 +48,26 @@ function sourceGif(
4748 return buffer;
4849}
4950
51it.each([
52 ["logical pixels", sourceGif(2048, 1024), { width: 2048, height: 1024 }],
53 [
54 "cumulative frame pixels",
55 sourceGif(1024, 1024, 1024, 1024, [{ width: 1024, height: 1024 }]),
56 { width: 1024, height: 1024 },
57 ],
58])("uses the caller's GIF limit for %s while keeping the favicon limit", (_, gif, dimensions) => {
59 expect(sourceDimensions(gif)).toBeNull();
60 expect(sourceDimensions(gif, 8_388_608)).toEqual(dimensions);
61});
62
63it("rejects GIF frames exceeding the caller's cumulative pixel limit", () => {
64 const gif = sourceGif(2048, 2048, 2048, 2048, [
65 { width: 2048, height: 2048 },
66 { width: 2048, height: 2048 },
67 ]);
68 expect(sourceDimensions(gif, 8_388_608)).toBeNull();
69});
70
5071function sourceJpeg(
5172 width: number,
5273 height: number,
apps/desktop/src/preview/FaviconCapture.ts+18-12
......@@ -246,14 +246,17 @@ interface ImageDimensions {
246246 readonly height: number;
247247}
248248
249function safeDimensions(dimensions: ImageDimensions | null): dimensions is ImageDimensions {
249function safeDimensions(
250 dimensions: ImageDimensions | null,
251 maxPixels = MAX_FAVICON_SOURCE_PIXELS,
252): dimensions is ImageDimensions {
250253 return (
251254 dimensions !== null &&
252255 Number.isSafeInteger(dimensions.width) &&
253256 Number.isSafeInteger(dimensions.height) &&
254257 dimensions.width > 0 &&
255258 dimensions.height > 0 &&
256 dimensions.width * dimensions.height <= MAX_FAVICON_SOURCE_PIXELS
259 dimensions.width * dimensions.height <= maxPixels
257260 );
258261}
259262
......@@ -276,13 +279,13 @@ function skipGifSubBlocks(buffer: Buffer, startOffset: number): number | null {
276279 return null;
277280}
278281
279function gifDimensions(buffer: Buffer): ImageDimensions | null {
282function gifDimensions(buffer: Buffer, maxPixels: number): ImageDimensions | null {
280283 if (buffer.byteLength < 13 || !/^GIF8[79]a$/u.test(buffer.subarray(0, 6).toString("ascii"))) {
281284 return null;
282285 }
283286 const logicalWidth = buffer.readUInt16LE(6);
284287 const logicalHeight = buffer.readUInt16LE(8);
285 if (!safeDimensions({ width: logicalWidth, height: logicalHeight })) return null;
288 if (!safeDimensions({ width: logicalWidth, height: logicalHeight }, maxPixels)) return null;
286289 const packed = buffer[10]!;
287290 let offset = 13 + ((packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 0x07) + 1));
288291 if (offset > buffer.byteLength) return null;
......@@ -301,10 +304,10 @@ function gifDimensions(buffer: Buffer): ImageDimensions | null {
301304 const frameHeight = buffer.readUInt16LE(offset + 7);
302305 if (frameWidth === 0 || frameHeight === 0) return null;
303306 framePixels += frameWidth * frameHeight;
304 if (framePixels > MAX_FAVICON_SOURCE_PIXELS) return null;
307 if (framePixels > maxPixels) return null;
305308 width = Math.max(width, left + frameWidth);
306309 height = Math.max(height, top + frameHeight);
307 if (!safeDimensions({ width, height })) return null;
310 if (!safeDimensions({ width, height }, maxPixels)) return null;
308311 const framePacked = buffer[offset + 9]!;
309312 offset += 10;
310313 if ((framePacked & 0x80) !== 0) {
......@@ -491,7 +494,7 @@ function dibDimensions(buffer: Buffer): ImageDimensions | null {
491494 };
492495}
493496
494function icoDimensions(buffer: Buffer): ImageDimensions | null {
497function icoDimensions(buffer: Buffer, maxPixels: number): ImageDimensions | null {
495498 if (
496499 buffer.byteLength < 22 ||
497500 buffer.readUInt16LE(0) !== 0 ||
......@@ -507,7 +510,7 @@ function icoDimensions(buffer: Buffer): ImageDimensions | null {
507510 const offset = 6 + index * 16;
508511 width = Math.max(width, buffer[offset] === 0 ? 256 : buffer[offset]!);
509512 height = Math.max(height, buffer[offset + 1] === 0 ? 256 : buffer[offset + 1]!);
510 if (!safeDimensions({ width, height })) return null;
513 if (!safeDimensions({ width, height }, maxPixels)) return null;
511514 const byteLength = buffer.readUInt32LE(offset + 8);
512515 const imageOffset = buffer.readUInt32LE(offset + 12);
513516 if (
......@@ -519,18 +522,21 @@ function icoDimensions(buffer: Buffer): ImageDimensions | null {
519522 return null;
520523 const embedded = buffer.subarray(imageOffset, imageOffset + byteLength);
521524 const embeddedDimensions = pngDimensions(embedded) ?? dibDimensions(embedded);
522 if (!safeDimensions(embeddedDimensions)) return null;
525 if (!safeDimensions(embeddedDimensions, maxPixels)) return null;
523526 }
524527 return { width, height };
525528}
526529
527function sourceDimensions(buffer: Buffer): ImageDimensions | null {
530export function sourceDimensions(
531 buffer: Buffer,
532 maxPixels = MAX_FAVICON_SOURCE_PIXELS,
533): ImageDimensions | null {
528534 return (
529535 pngDimensions(buffer) ??
530 gifDimensions(buffer) ??
536 gifDimensions(buffer, maxPixels) ??
531537 jpegDimensions(buffer) ??
532538 webpDimensions(buffer) ??
533 icoDimensions(buffer)
539 icoDimensions(buffer, maxPixels)
534540 );
535541}
536542
apps/desktop/src/window/ContextMenuImage.test.ts created+204
......@@ -0,0 +1,204 @@
1import { beforeEach, expect, it, vi } from "vite-plus/test";
2import type * as Electron from "electron";
3import { copyContextMenuImage } from "./ContextMenuImage.ts";
4
5const native = vi.hoisted(() => ({
6 fetch: vi.fn(),
7 createFromBuffer: vi.fn(),
8 write: vi.fn(),
9 image: { isEmpty: vi.fn(() => false), toPNG: vi.fn(() => Buffer.from("png")) },
10}));
11
12vi.mock("electron", () => ({
13 net: { fetch: native.fetch },
14 nativeImage: { createFromBuffer: native.createFromBuffer },
15 clipboard: { write: native.write },
16 ClipboardItem: class {
17 readonly blobs: Record<string, Blob>;
18 constructor(blobs: Record<string, Blob>) {
19 this.blobs = blobs;
20 }
21 },
22}));
23
24const mainFrame = { isDestroyed: vi.fn(() => false) };
25const frame = { isDestroyed: vi.fn(() => false) };
26const contents = {
27 isDestroyed: vi.fn(() => false),
28 copyImageAt: vi.fn(),
29 executeJavaScriptInIsolatedWorld: vi.fn(),
30 mainFrame,
31};
32const params = {
33 x: 12,
34 y: 34,
35 srcURL: "https://example.com/image.png",
36 frame,
37};
38const sourcePng = Buffer.from(
39 "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAFklEQVR4nGP4TyFgGDVg1IBRA4aLAQBdePwur/3haQAAAABJRU5ErkJggg==",
40 "base64",
41);
42
43const copyImage = (overrides: Partial<typeof params> = {}) =>
44 copyContextMenuImage(
45 contents as unknown as Electron.WebContents,
46 { ...params, ...overrides } as unknown as Electron.ContextMenuParams,
47 );
48
49beforeEach(() => {
50 vi.clearAllMocks();
51 contents.isDestroyed.mockReturnValue(false);
52 frame.isDestroyed.mockReturnValue(false);
53 native.image.isEmpty.mockReturnValue(false);
54 native.createFromBuffer.mockReturnValue(native.image);
55 native.fetch.mockResolvedValue(new Response(Uint8Array.from(sourcePng)));
56 native.write.mockResolvedValue(undefined);
57 contents.executeJavaScriptInIsolatedWorld.mockRejectedValue(
58 new Error("Image could not be decoded"),
59 );
60});
61
62it("preserves the clipboard when the context-menu frame is missing", async () => {
63 await copyContextMenuImage(
64 contents as unknown as Electron.WebContents,
65 { ...params, frame: null } as unknown as Electron.ContextMenuParams,
66 );
67 expect(contents.copyImageAt).not.toHaveBeenCalled();
68 expect(native.fetch).not.toHaveBeenCalled();
69 expect(native.write).not.toHaveBeenCalled();
70});
71
72it("keeps native image copying for the main frame", async () => {
73 await copyImage({ frame: mainFrame });
74 expect(contents.copyImageAt).toHaveBeenCalledWith(12, 34);
75 expect(native.fetch).not.toHaveBeenCalled();
76});
77
78it.each(["https://example.com/image.png", `data:image/png;base64,${sourcePng.toString("base64")}`])(
79 "copies an iframe image from %s",
80 async (srcURL) => {
81 await copyImage({ srcURL });
82 if (srcURL.startsWith("https:")) {
83 expect(native.fetch).toHaveBeenCalledWith(srcURL, {
84 credentials: "omit",
85 redirect: "error",
86 signal: expect.any(AbortSignal),
87 });
88 } else {
89 expect(native.fetch).not.toHaveBeenCalled();
90 }
91 expect(contents.copyImageAt).not.toHaveBeenCalled();
92 expect(native.createFromBuffer).toHaveBeenCalledWith(sourcePng);
93 const blob = native.write.mock.calls[0]?.[0][0].blobs["image/png"] as Blob;
94 expect(blob.type).toBe("image/png");
95 expect(Buffer.from(await blob.arrayBuffer())).toEqual(Buffer.from("png"));
96 },
97);
98
99it.each([
100 ["WebP", "UklGRiQAAABXRUJQVlA4IBgAAAAwAQCdASoBAAEAAUAmJaQAA3AA/vz0AAA="],
101 ["GIF", "R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAkQBADs="],
102])("copies %s using Chromium when nativeImage cannot decode it", async (_, base64) => {
103 native.fetch.mockResolvedValue(new Response(Uint8Array.from(Buffer.from(base64, "base64"))));
104 native.image.isEmpty.mockReturnValue(true);
105 contents.executeJavaScriptInIsolatedWorld.mockResolvedValue(Uint8Array.from(sourcePng).buffer);
106 await copyImage();
107 const blob = native.write.mock.calls[0]?.[0][0].blobs["image/png"] as Blob;
108 expect(Buffer.from(await blob.arrayBuffer())).toEqual(sourcePng);
109 expect(native.image.toPNG).not.toHaveBeenCalled();
110});
111
112it("preserves the clipboard if the source frame closes during Chromium decoding", async () => {
113 native.image.isEmpty.mockReturnValue(true);
114 contents.executeJavaScriptInIsolatedWorld.mockImplementation(() => {
115 frame.isDestroyed.mockReturnValue(true);
116 return Promise.resolve(Uint8Array.from(sourcePng).buffer);
117 });
118 await copyImage();
119 expect(native.write).not.toHaveBeenCalled();
120});
121
122it("copies a GIF larger than the favicon pixel limit", async () => {
123 const gif = Buffer.from("R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAkQBADs=", "base64");
124 gif.writeUInt16LE(2048, 6);
125 gif.writeUInt16LE(1024, 8);
126 native.fetch.mockResolvedValue(new Response(Uint8Array.from(gif)));
127 native.image.isEmpty.mockReturnValue(true);
128 contents.executeJavaScriptInIsolatedWorld.mockResolvedValue(Uint8Array.from(sourcePng).buffer);
129 await copyImage();
130 const blob = native.write.mock.calls[0]?.[0][0].blobs["image/png"] as Blob;
131 expect(Buffer.from(await blob.arrayBuffer())).toEqual(sourcePng);
132});
133
134it.each([
135 "file:///private/image.png",
136 "blob:https://example.com/image",
137 "https://user:pass@example.com/image.png",
138])("rejects unsupported or credential-bearing URLs: %s", async (srcURL) => {
139 await expect(copyImage({ srcURL })).rejects.toThrow("Unsupported image URL");
140 expect(native.fetch).not.toHaveBeenCalled();
141 expect(native.write).not.toHaveBeenCalled();
142});
143
144it("cancels responses whose declared size exceeds the limit", async () => {
145 const cancel = vi.fn();
146 native.fetch.mockResolvedValue(
147 new Response(new ReadableStream({ cancel }), {
148 headers: { "content-length": String(32 * 1024 * 1024 + 1) },
149 }),
150 );
151 await expect(copyImage()).rejects.toThrow("size limit");
152 expect(cancel).toHaveBeenCalled();
153 expect(native.createFromBuffer).not.toHaveBeenCalled();
154});
155
156it("cancels oversized streams without a content-length", async () => {
157 const cancel = vi.fn();
158 native.fetch.mockResolvedValue(
159 new Response(
160 new ReadableStream({
161 start(controller) {
162 controller.enqueue(new Uint8Array(32 * 1024 * 1024));
163 controller.enqueue(new Uint8Array(1));
164 },
165 cancel,
166 }),
167 ),
168 );
169 await expect(copyImage()).rejects.toThrow("size limit");
170 expect(cancel).toHaveBeenCalled();
171 expect(native.write).not.toHaveBeenCalled();
172});
173
174it("preserves the clipboard when the response is not an image", async () => {
175 native.image.isEmpty.mockReturnValue(true);
176 await expect(copyImage()).rejects.toThrow("could not be decoded");
177 expect(native.write).not.toHaveBeenCalled();
178});
179
180it("rejects large decoded dimensions before image allocation", async () => {
181 const oversizedPng = Buffer.from(sourcePng);
182 oversizedPng.writeUInt32BE(4096, 16);
183 oversizedPng.writeUInt32BE(4096, 20);
184 native.fetch.mockResolvedValue(new Response(Uint8Array.from(oversizedPng)));
185 await expect(copyImage()).rejects.toThrow("size limit");
186 expect(native.createFromBuffer).not.toHaveBeenCalled();
187 expect(native.image.toPNG).not.toHaveBeenCalled();
188 expect(native.write).not.toHaveBeenCalled();
189});
190
191it("preserves the clipboard when the encoded PNG exceeds the limit", async () => {
192 native.image.toPNG.mockReturnValueOnce(Buffer.alloc(32 * 1024 * 1024 + 1));
193 await expect(copyImage()).rejects.toThrow("size limit");
194 expect(native.write).not.toHaveBeenCalled();
195});
196
197it("does not copy after the source frame closes during the request", async () => {
198 native.fetch.mockImplementation(() => {
199 frame.isDestroyed.mockReturnValue(true);
200 return Promise.resolve(new Response(Uint8Array.from(sourcePng)));
201 });
202 await copyImage();
203 expect(native.write).not.toHaveBeenCalled();
204});
apps/desktop/src/window/ContextMenuImage.ts created+104
......@@ -0,0 +1,104 @@
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}
apps/desktop/src/window/DesktopWindow.test.ts+6-2
......@@ -468,6 +468,10 @@ describe("DesktopWindow", () => {
468468 let focusedContents: unknown = host.window.webContents;
469469 const makeContents = () => {
470470 const contents = Object.assign(new NodeEvents.EventEmitter(), {
471 mainFrame: {
472 routingId: 7,
473 isDestroyed: vi.fn(() => false),
474 } as unknown as Electron.WebFrameMain,
471475 isDestroyed: vi.fn(() => false),
472476 focus: vi.fn(() => {
473477 focusedContents = contents;
......@@ -546,7 +550,7 @@ describe("DesktopWindow", () => {
546550 { preventDefault },
547551 {
548552 ...params,
549 frame: null,
553 frame: contents.mainFrame,
550554 misspelledWord: "",
551555 dictionarySuggestions: [],
552556 mediaType: "image",
......@@ -554,7 +558,7 @@ describe("DesktopWindow", () => {
554558 },
555559 );
556560 const imageMenu = (yield* Queue.take(menus)).input;
557 assert.isUndefined(imageMenu.frame);
561 assert.strictEqual(imageMenu.frame, contents.mainFrame);
558562 const copyImage = imageMenu.template.find((item) => item.label === "Copy Image");
559563 const copyLink = imageMenu.template.find((item) => item.label === "Copy Link");
560564 assert.isDefined(copyImage?.click);
apps/desktop/src/window/DesktopWindow.ts+6-1
......@@ -33,6 +33,7 @@ import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
3333import * as ElectronApp from "../electron/ElectronApp.ts";
3434import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
3535import { makeQuitShortcutHandler } from "./QuitHold.ts";
36import { copyContextMenuImage } from "./ContextMenuImage.ts";
3637
3738const TITLEBAR_HEIGHT = 40;
3839// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS
......@@ -595,7 +596,11 @@ export const make = Effect.gen(function* () {
595596 menuTemplate.push({
596597 label: "Copy Image",
597598 click: () => {
598 if (!contents.isDestroyed()) contents.copyImageAt(params.x, params.y);
599 void runPromise(
600 Effect.tryPromise(() => copyContextMenuImage(contents, params)).pipe(
601 Effect.catch(() => logWindowWarning("failed to copy context-menu image")),
602 ),
603 );
599604 },
600605 });
601606 menuTemplate.push({ type: "separator" });