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 {...@@ -5,6 +5,7 @@ import {
5 MAX_FAVICON_RESPONSE_BYTES,5 MAX_FAVICON_RESPONSE_BYTES,
6 captureFavicon,6 captureFavicon,
7 selectFaviconCandidates,7 selectFaviconCandidates,
8 sourceDimensions,
8} from "./FaviconCapture.ts";9} from "./FaviconCapture.ts";
910
10const PNG = "data:image/png;base64,cG5n";11const PNG = "data:image/png;base64,cG5n";
...@@ -47,6 +48,26 @@ function sourceGif(...@@ -47,6 +48,26 @@ function sourceGif(
47 return buffer;48 return buffer;
48}49}
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
50function sourceJpeg(71function sourceJpeg(
51 width: number,72 width: number,
52 height: number,73 height: number,
apps/desktop/src/preview/FaviconCapture.ts+18-12
...@@ -246,14 +246,17 @@ interface ImageDimensions {...@@ -246,14 +246,17 @@ interface ImageDimensions {
246 readonly height: number;246 readonly height: number;
247}247}
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 {
250 return (253 return (
251 dimensions !== null &&254 dimensions !== null &&
252 Number.isSafeInteger(dimensions.width) &&255 Number.isSafeInteger(dimensions.width) &&
253 Number.isSafeInteger(dimensions.height) &&256 Number.isSafeInteger(dimensions.height) &&
254 dimensions.width > 0 &&257 dimensions.width > 0 &&
255 dimensions.height > 0 &&258 dimensions.height > 0 &&
256 dimensions.width * dimensions.height <= MAX_FAVICON_SOURCE_PIXELS259 dimensions.width * dimensions.height <= maxPixels
257 );260 );
258}261}
259262
...@@ -276,13 +279,13 @@ function skipGifSubBlocks(buffer: Buffer, startOffset: number): number | null {...@@ -276,13 +279,13 @@ function skipGifSubBlocks(buffer: Buffer, startOffset: number): number | null {
276 return null;279 return null;
277}280}
278281
279function gifDimensions(buffer: Buffer): ImageDimensions | null {282function gifDimensions(buffer: Buffer, maxPixels: number): ImageDimensions | null {
280 if (buffer.byteLength < 13 || !/^GIF8[79]a$/u.test(buffer.subarray(0, 6).toString("ascii"))) {283 if (buffer.byteLength < 13 || !/^GIF8[79]a$/u.test(buffer.subarray(0, 6).toString("ascii"))) {
281 return null;284 return null;
282 }285 }
283 const logicalWidth = buffer.readUInt16LE(6);286 const logicalWidth = buffer.readUInt16LE(6);
284 const logicalHeight = buffer.readUInt16LE(8);287 const logicalHeight = buffer.readUInt16LE(8);
285 if (!safeDimensions({ width: logicalWidth, height: logicalHeight })) return null;288 if (!safeDimensions({ width: logicalWidth, height: logicalHeight }, maxPixels)) return null;
286 const packed = buffer[10]!;289 const packed = buffer[10]!;
287 let offset = 13 + ((packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 0x07) + 1));290 let offset = 13 + ((packed & 0x80) === 0 ? 0 : 3 * 2 ** ((packed & 0x07) + 1));
288 if (offset > buffer.byteLength) return null;291 if (offset > buffer.byteLength) return null;
...@@ -301,10 +304,10 @@ function gifDimensions(buffer: Buffer): ImageDimensions | null {...@@ -301,10 +304,10 @@ function gifDimensions(buffer: Buffer): ImageDimensions | null {
301 const frameHeight = buffer.readUInt16LE(offset + 7);304 const frameHeight = buffer.readUInt16LE(offset + 7);
302 if (frameWidth === 0 || frameHeight === 0) return null;305 if (frameWidth === 0 || frameHeight === 0) return null;
303 framePixels += frameWidth * frameHeight;306 framePixels += frameWidth * frameHeight;
304 if (framePixels > MAX_FAVICON_SOURCE_PIXELS) return null;307 if (framePixels > maxPixels) return null;
305 width = Math.max(width, left + frameWidth);308 width = Math.max(width, left + frameWidth);
306 height = Math.max(height, top + frameHeight);309 height = Math.max(height, top + frameHeight);
307 if (!safeDimensions({ width, height })) return null;310 if (!safeDimensions({ width, height }, maxPixels)) return null;
308 const framePacked = buffer[offset + 9]!;311 const framePacked = buffer[offset + 9]!;
309 offset += 10;312 offset += 10;
310 if ((framePacked & 0x80) !== 0) {313 if ((framePacked & 0x80) !== 0) {
...@@ -491,7 +494,7 @@ function dibDimensions(buffer: Buffer): ImageDimensions | null {...@@ -491,7 +494,7 @@ function dibDimensions(buffer: Buffer): ImageDimensions | null {
491 };494 };
492}495}
493496
494function icoDimensions(buffer: Buffer): ImageDimensions | null {497function icoDimensions(buffer: Buffer, maxPixels: number): ImageDimensions | null {
495 if (498 if (
496 buffer.byteLength < 22 ||499 buffer.byteLength < 22 ||
497 buffer.readUInt16LE(0) !== 0 ||500 buffer.readUInt16LE(0) !== 0 ||
...@@ -507,7 +510,7 @@ function icoDimensions(buffer: Buffer): ImageDimensions | null {...@@ -507,7 +510,7 @@ function icoDimensions(buffer: Buffer): ImageDimensions | null {
507 const offset = 6 + index * 16;510 const offset = 6 + index * 16;
508 width = Math.max(width, buffer[offset] === 0 ? 256 : buffer[offset]!);511 width = Math.max(width, buffer[offset] === 0 ? 256 : buffer[offset]!);
509 height = Math.max(height, buffer[offset + 1] === 0 ? 256 : buffer[offset + 1]!);512 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;
511 const byteLength = buffer.readUInt32LE(offset + 8);514 const byteLength = buffer.readUInt32LE(offset + 8);
512 const imageOffset = buffer.readUInt32LE(offset + 12);515 const imageOffset = buffer.readUInt32LE(offset + 12);
513 if (516 if (
...@@ -519,18 +522,21 @@ function icoDimensions(buffer: Buffer): ImageDimensions | null {...@@ -519,18 +522,21 @@ function icoDimensions(buffer: Buffer): ImageDimensions | null {
519 return null;522 return null;
520 const embedded = buffer.subarray(imageOffset, imageOffset + byteLength);523 const embedded = buffer.subarray(imageOffset, imageOffset + byteLength);
521 const embeddedDimensions = pngDimensions(embedded) ?? dibDimensions(embedded);524 const embeddedDimensions = pngDimensions(embedded) ?? dibDimensions(embedded);
522 if (!safeDimensions(embeddedDimensions)) return null;525 if (!safeDimensions(embeddedDimensions, maxPixels)) return null;
523 }526 }
524 return { width, height };527 return { width, height };
525}528}
526529
527function sourceDimensions(buffer: Buffer): ImageDimensions | null {530export function sourceDimensions(
531 buffer: Buffer,
532 maxPixels = MAX_FAVICON_SOURCE_PIXELS,
533): ImageDimensions | null {
528 return (534 return (
529 pngDimensions(buffer) ??535 pngDimensions(buffer) ??
530 gifDimensions(buffer) ??536 gifDimensions(buffer, maxPixels) ??
531 jpegDimensions(buffer) ??537 jpegDimensions(buffer) ??
532 webpDimensions(buffer) ??538 webpDimensions(buffer) ??
533 icoDimensions(buffer)539 icoDimensions(buffer, maxPixels)
534 );540 );
535}541}
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", () => {...@@ -468,6 +468,10 @@ describe("DesktopWindow", () => {
468 let focusedContents: unknown = host.window.webContents;468 let focusedContents: unknown = host.window.webContents;
469 const makeContents = () => {469 const makeContents = () => {
470 const contents = Object.assign(new NodeEvents.EventEmitter(), {470 const contents = Object.assign(new NodeEvents.EventEmitter(), {
471 mainFrame: {
472 routingId: 7,
473 isDestroyed: vi.fn(() => false),
474 } as unknown as Electron.WebFrameMain,
471 isDestroyed: vi.fn(() => false),475 isDestroyed: vi.fn(() => false),
472 focus: vi.fn(() => {476 focus: vi.fn(() => {
473 focusedContents = contents;477 focusedContents = contents;
...@@ -546,7 +550,7 @@ describe("DesktopWindow", () => {...@@ -546,7 +550,7 @@ describe("DesktopWindow", () => {
546 { preventDefault },550 { preventDefault },
547 {551 {
548 ...params,552 ...params,
549 frame: null,553 frame: contents.mainFrame,
550 misspelledWord: "",554 misspelledWord: "",
551 dictionarySuggestions: [],555 dictionarySuggestions: [],
552 mediaType: "image",556 mediaType: "image",
...@@ -554,7 +558,7 @@ describe("DesktopWindow", () => {...@@ -554,7 +558,7 @@ describe("DesktopWindow", () => {
554 },558 },
555 );559 );
556 const imageMenu = (yield* Queue.take(menus)).input;560 const imageMenu = (yield* Queue.take(menus)).input;
557 assert.isUndefined(imageMenu.frame);561 assert.strictEqual(imageMenu.frame, contents.mainFrame);
558 const copyImage = imageMenu.template.find((item) => item.label === "Copy Image");562 const copyImage = imageMenu.template.find((item) => item.label === "Copy Image");
559 const copyLink = imageMenu.template.find((item) => item.label === "Copy Link");563 const copyLink = imageMenu.template.find((item) => item.label === "Copy Link");
560 assert.isDefined(copyImage?.click);564 assert.isDefined(copyImage?.click);
apps/desktop/src/window/DesktopWindow.ts+6-1
...@@ -33,6 +33,7 @@ import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";...@@ -33,6 +33,7 @@ import * as DesktopClientSettings from "../settings/DesktopClientSettings.ts";
33import * as ElectronApp from "../electron/ElectronApp.ts";33import * as ElectronApp from "../electron/ElectronApp.ts";
34import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";34import * as DesktopRendererHistory from "../telemetry/DesktopRendererHistory.ts";
35import { makeQuitShortcutHandler } from "./QuitHold.ts";35import { makeQuitShortcutHandler } from "./QuitHold.ts";
36import { copyContextMenuImage } from "./ContextMenuImage.ts";
3637
37const TITLEBAR_HEIGHT = 40;38const TITLEBAR_HEIGHT = 40;
38// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS39// Matches --workspace-topbar-height in apps/web/src/index.css. Native macOS
...@@ -595,7 +596,11 @@ export const make = Effect.gen(function* () {...@@ -595,7 +596,11 @@ export const make = Effect.gen(function* () {
595 menuTemplate.push({596 menuTemplate.push({
596 label: "Copy Image",597 label: "Copy Image",
597 click: () => {598 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 );
599 },604 },
600 });605 });
601 menuTemplate.push({ type: "separator" });606 menuTemplate.push({ type: "separator" });