History

fix(media): preserve native actions for remote images

t3-code[bot] committed 2 months ago

Commit 4e333bf1 parent 70cd2584 files · +138 −3Browse files

Unified · Split

4 files
  1. apps/desktop/src/window/DesktopWindow.test.ts +81 −2
  2. apps/desktop/src/window/DesktopWindow.ts +15 −0
  3. apps/web/src/components/media/MediaActions.test.ts +29 −0
  4. apps/web/src/components/media/MediaActions.tsx +13 −1
apps/desktop/src/window/DesktopWindow.test.ts+81−2
@@ -66,6 +66,7 @@ function makeFakeBrowserWindow() { · 65 unmodified lines
6666 let zoomLevel = 0;
6767 const webContents = {
6868 copyImageAt: vi.fn(),
69+ downloadURL: vi.fn(),
6970 getURL: vi.fn(() => "t3code-dev://app/"),
7071 getZoomLevel: vi.fn(() => zoomLevel),
7172 setZoomLevel: vi.fn((level: number) => {
@@ -115,6 +116,8 @@ function makeFakeBrowserWindow() { · 43 unmodified lines
115116
116117 return {
117118 window: window as unknown as Electron.BrowserWindow,
119+ copyImageAt: webContents.copyImageAt,
120+ downloadURL: webContents.downloadURL,
118121 getBounds: window.getBounds,
119122 getNormalBounds: window.getNormalBounds,
120123 isDestroyed: window.isDestroyed,
@@ -208,6 +211,8 @@ function makeTestLayer(input: { · 87 unmodified lines
208211 bounds: DesktopAppSettings.DesktopWindowBounds,
209212 ) => Effect.Effect<void>;
210213 readonly openedExternalUrls?: unknown[];
214+ readonly copiedTexts?: string[];
215+ readonly popupTemplates?: Electron.MenuItemConstructorOptions[][];
211216 readonly previewZoomReapplies?: number[];
212217 }) {
213218 let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS;
@@ -273,14 +278,24 @@ function makeTestLayer(input: { · 59 unmodified lines
273278 desktopServerExposureLayer,
274279 DesktopState.layer,
275280 electronAppLayer,
276− electronMenuLayer,
281+ input.popupTemplates
282+ ? Layer.succeed(ElectronMenu.ElectronMenu, {
283+ setApplicationMenu: () => Effect.void,
284+ popupTemplate: ({ template }) =>
285+ Effect.sync(() => input.popupTemplates?.push([...template])),
286+ showContextMenu: () => Effect.succeed(Option.none()),
287+ } satisfies ElectronMenu.ElectronMenu["Service"])
288+ : electronMenuLayer,
277289 Layer.succeed(ElectronShell.ElectronShell, {
278290 openExternal: (url) =>
279291 Effect.sync(() => {
280292 input.openedExternalUrls?.push(url);
281293 return true;
282294 }),
283− copyText: () => Effect.void,
295+ copyText: (text) =>
296+ Effect.sync(() => {
297+ input.copiedTexts?.push(text);
298+ }),
284299 } satisfies ElectronShell.ElectronShell["Service"]),
285300 electronThemeLayer,
286301 electronWindowLayer,
@@ -398,6 +413,70 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n · 111 unmodified lines
398413 });
399414
400415 describe("DesktopWindow", () => {
416+ it.effect("keeps copy, save, and URL actions native for external images", () =>
417+ Effect.gen(function* () {
418+ const fakeWindow = makeFakeBrowserWindow();
419+ const createCount = yield* Ref.make(0);
420+ const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
421+ const copiedTexts: string[] = [];
422+ const popupTemplates: Electron.MenuItemConstructorOptions[][] = [];
423+ const layer = makeTestLayer({
424+ window: fakeWindow.window,
425+ createCount,
426+ mainWindow,
427+ copiedTexts,
428+ popupTemplates,
429+ });
430+ const imageUrl = "https://images.example/screenshot.png";
431+
432+ yield* Effect.gen(function* () {
433+ const desktopWindow = yield* DesktopWindow.DesktopWindow;
434+ yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
435+ const contextMenu = fakeWindow.webContentsListeners.get("context-menu");
436+ if (!contextMenu) return yield* Effect.die("context-menu listener was not registered");
437+
438+ contextMenu({ preventDefault: vi.fn() }, {
439+ dictionarySuggestions: [],
440+ editFlags: { canCopy: false, canCut: false, canPaste: false, canSelectAll: false },
441+ linkURL: "",
442+ mediaType: "image",
443+ misspelledWord: "",
444+ srcURL: imageUrl,
445+ x: 42,
446+ y: 84,
447+ } as unknown as Electron.ContextMenuParams);
448+ yield* Effect.promise(() => Promise.resolve());
449+
450+ const template = popupTemplates.at(-1) ?? [];
451+ const byLabel = (label: string) => template.find((item) => item.label === label);
452+ assert.ok(byLabel("Copy URL"));
453+ assert.ok(byLabel("Save Image"));
454+ assert.ok(byLabel("Copy Image"));
455+
456+ byLabel("Copy URL")?.click?.(
457+ {} as Electron.MenuItem,
458+ fakeWindow.window,
459+ {} as Electron.KeyboardEvent,
460+ );
461+ byLabel("Save Image")?.click?.(
462+ {} as Electron.MenuItem,
463+ fakeWindow.window,
464+ {} as Electron.KeyboardEvent,
465+ );
466+ byLabel("Copy Image")?.click?.(
467+ {} as Electron.MenuItem,
468+ fakeWindow.window,
469+ {} as Electron.KeyboardEvent,
470+ );
471+ yield* Effect.promise(() => Promise.resolve());
472+
473+ assert.deepEqual(copiedTexts, [imageUrl]);
474+ assert.deepEqual(fakeWindow.downloadURL.mock.calls, [[imageUrl]]);
475+ assert.deepEqual(fakeWindow.copyImageAt.mock.calls, [[42, 84]]);
476+ }).pipe(Effect.provide(layer));
477+ }),
478+ );
479+
401480 it("leaves fullscreen before concealing a pending quit", () => {
402481 const fakeWindow = makeFakeBrowserWindow();
403482
apps/desktop/src/window/DesktopWindow.ts+15−0
@@ -523,6 +523,21 @@ export const make = Effect.gen(function* () { · 522 unmodified lines
523523 }
524524
525525 if (params.mediaType === "image") {
526+ const imageUrl = ElectronShell.parseSafeExternalUrl(params.srcURL);
527+ if (Option.isSome(imageUrl)) {
528+ menuTemplate.push(
529+ {
530+ label: "Copy URL",
531+ click: () => {
532+ void runPromise(electronShell.copyText(imageUrl.value));
533+ },
534+ },
535+ {
536+ label: "Save Image",
537+ click: () => window.webContents.downloadURL(imageUrl.value),
538+ },
539+ );
540+ }
526541 menuTemplate.push({
527542 label: "Copy Image",
528543 click: () => window.webContents.copyImageAt(params.x, params.y),
apps/web/src/components/media/MediaActions.test.ts+29−0
@@ -0,0 +1,29 @@
1+import { describe, expect, it } from "vite-plus/test";
2+
3+import { shouldUseNativeImageContextMenu, type MediaActionSource } from "./MediaActions";
4+
5+const remoteImage: MediaActionSource = {
6+ kind: "image",
7+ name: "screenshot.png",
8+ src: "https://images.example/screenshot.png",
9+ reference: { kind: "url", url: "https://images.example/screenshot.png" },
10+};
11+
12+describe("shouldUseNativeImageContextMenu", () => {
13+ it("keeps direct external images on the browser-native menu", () => {
14+ expect(shouldUseNativeImageContextMenu(remoteImage)).toBe(true);
15+ });
16+
17+ it("keeps environment assets on the custom source-aware menu", () => {
18+ expect(
19+ shouldUseNativeImageContextMenu({
20+ ...remoteImage,
21+ asset: {} as NonNullable<MediaActionSource["asset"]>,
22+ }),
23+ ).toBe(false);
24+ });
25+
26+ it("keeps videos on the custom source-aware menu", () => {
27+ expect(shouldUseNativeImageContextMenu({ ...remoteImage, kind: "video" })).toBe(false);
28+ });
29+});
apps/web/src/components/media/MediaActions.tsx+13−1
@@ -25,6 +25,17 @@ export interface MediaActionSource { · 24 unmodified lines
2525 readonly onOpenFile?: () => void;
2626 }
2727
28+/** Browser/Electron native image menus can copy or save rendered cross-origin images without CORS. */
29+export function shouldUseNativeImageContextMenu(source: MediaActionSource): boolean {
30+ return (
31+ source.kind === "image" &&
32+ source.src !== null &&
33+ source.reference?.kind === "url" &&
34+ source.asset === undefined &&
35+ source.onOpenFile === undefined
36+ );
37+}
38+
2839 function mediaFileName(source: MediaActionSource): string {
2940 return (
3041 (source.reference && mediaReferenceFileName(source.reference)) || source.name || source.kind
@@ -83,7 +94,8 @@ export function MediaActions({ · 52 unmodified lines
8394 const menuOpen = useRef(false);
8495 const reference = source.reference;
8596 const hasActions =
86− source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined;
97+ !shouldUseNativeImageContextMenu(source) &&
98+ (source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined);
8799 const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name);
88100
89101 const showMenu = async (position: { x: number; y: number }) => {