Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
83 changes: 81 additions & 2 deletions apps/desktop/src/window/DesktopWindow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ function makeFakeBrowserWindow() {
let zoomLevel = 0;
const webContents = {
copyImageAt: vi.fn(),
downloadURL: vi.fn(),
getURL: vi.fn(() => "t3code-dev://app/"),
getZoomLevel: vi.fn(() => zoomLevel),
setZoomLevel: vi.fn((level: number) => {
Expand Down Expand Up @@ -115,6 +116,8 @@ function makeFakeBrowserWindow() {

return {
window: window as unknown as Electron.BrowserWindow,
copyImageAt: webContents.copyImageAt,
downloadURL: webContents.downloadURL,
getBounds: window.getBounds,
getNormalBounds: window.getNormalBounds,
isDestroyed: window.isDestroyed,
Expand Down Expand Up @@ -208,6 +211,8 @@ function makeTestLayer(input: {
bounds: DesktopAppSettings.DesktopWindowBounds,
) => Effect.Effect<void>;
readonly openedExternalUrls?: unknown[];
readonly copiedTexts?: string[];
readonly popupTemplates?: Electron.MenuItemConstructorOptions[][];
readonly previewZoomReapplies?: number[];
}) {
let desktopSettings = input.desktopSettings ?? DesktopAppSettings.DEFAULT_DESKTOP_SETTINGS;
Expand Down Expand Up @@ -273,14 +278,24 @@ function makeTestLayer(input: {
desktopServerExposureLayer,
DesktopState.layer,
electronAppLayer,
electronMenuLayer,
input.popupTemplates
? Layer.succeed(ElectronMenu.ElectronMenu, {
setApplicationMenu: () => Effect.void,
popupTemplate: ({ template }) =>
Effect.sync(() => input.popupTemplates?.push([...template])),
showContextMenu: () => Effect.succeed(Option.none()),
} satisfies ElectronMenu.ElectronMenu["Service"])
: electronMenuLayer,
Layer.succeed(ElectronShell.ElectronShell, {
openExternal: (url) =>
Effect.sync(() => {
input.openedExternalUrls?.push(url);
return true;
}),
copyText: () => Effect.void,
copyText: (text) =>
Effect.sync(() => {
input.copiedTexts?.push(text);
}),
} satisfies ElectronShell.ElectronShell["Service"]),
electronThemeLayer,
electronWindowLayer,
Expand Down Expand Up @@ -398,6 +413,70 @@ const makeSplashScenario = (createOutcomes: readonly (Electron.BrowserWindow | n
});

describe("DesktopWindow", () => {
it.effect("keeps copy, save, and URL actions native for external images", () =>
Effect.gen(function* () {
const fakeWindow = makeFakeBrowserWindow();
const createCount = yield* Ref.make(0);
const mainWindow = yield* Ref.make<Option.Option<Electron.BrowserWindow>>(Option.none());
const copiedTexts: string[] = [];
const popupTemplates: Electron.MenuItemConstructorOptions[][] = [];
const layer = makeTestLayer({
window: fakeWindow.window,
createCount,
mainWindow,
copiedTexts,
popupTemplates,
});
const imageUrl = "https://images.example/screenshot.png";

yield* Effect.gen(function* () {
const desktopWindow = yield* DesktopWindow.DesktopWindow;
yield* desktopWindow.handleBackendReady(new URL("http://127.0.0.1:3773"));
const contextMenu = fakeWindow.webContentsListeners.get("context-menu");
if (!contextMenu) return yield* Effect.die("context-menu listener was not registered");

contextMenu({ preventDefault: vi.fn() }, {
dictionarySuggestions: [],
editFlags: { canCopy: false, canCut: false, canPaste: false, canSelectAll: false },
linkURL: "",
mediaType: "image",
misspelledWord: "",
srcURL: imageUrl,
x: 42,
y: 84,
} as unknown as Electron.ContextMenuParams);
yield* Effect.promise(() => Promise.resolve());

const template = popupTemplates.at(-1) ?? [];
const byLabel = (label: string) => template.find((item) => item.label === label);
assert.ok(byLabel("Copy URL"));
assert.ok(byLabel("Save Image"));
assert.ok(byLabel("Copy Image"));

byLabel("Copy URL")?.click?.(
{} as Electron.MenuItem,
fakeWindow.window,
{} as Electron.KeyboardEvent,
);
byLabel("Save Image")?.click?.(
{} as Electron.MenuItem,
fakeWindow.window,
{} as Electron.KeyboardEvent,
);
byLabel("Copy Image")?.click?.(
{} as Electron.MenuItem,
fakeWindow.window,
{} as Electron.KeyboardEvent,
);
yield* Effect.promise(() => Promise.resolve());

assert.deepEqual(copiedTexts, [imageUrl]);
assert.deepEqual(fakeWindow.downloadURL.mock.calls, [[imageUrl]]);
assert.deepEqual(fakeWindow.copyImageAt.mock.calls, [[42, 84]]);
}).pipe(Effect.provide(layer));
}),
);

it("leaves fullscreen before concealing a pending quit", () => {
const fakeWindow = makeFakeBrowserWindow();

Expand Down
15 changes: 15 additions & 0 deletions apps/desktop/src/window/DesktopWindow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,21 @@ export const make = Effect.gen(function* () {
}

if (params.mediaType === "image") {
const imageUrl = ElectronShell.parseSafeExternalUrl(params.srcURL);
if (Option.isSome(imageUrl)) {
menuTemplate.push(
{
label: "Copy URL",
click: () => {
void runPromise(electronShell.copyText(imageUrl.value));
},
},
{
label: "Save Image",
click: () => window.webContents.downloadURL(imageUrl.value),
},
);
}
menuTemplate.push({
label: "Copy Image",
click: () => window.webContents.copyImageAt(params.x, params.y),
Expand Down
48 changes: 48 additions & 0 deletions apps/web/src/components/media/MediaActions.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, it, vi } from "vite-plus/test";

import {
handleNativeImageContextMenu,
shouldUseNativeImageContextMenu,
type MediaActionSource,
} from "./MediaActions";

const remoteImage: MediaActionSource = {
kind: "image",
name: "screenshot.png",
src: "https://images.example/screenshot.png",
reference: { kind: "url", url: "https://images.example/screenshot.png" },
};

describe("shouldUseNativeImageContextMenu", () => {
it("keeps direct external images on the browser-native menu", () => {
expect(shouldUseNativeImageContextMenu(remoteImage)).toBe(true);
});

it("keeps environment assets on the custom source-aware menu", () => {
expect(
shouldUseNativeImageContextMenu({
...remoteImage,
asset: {} as NonNullable<MediaActionSource["asset"]>,
}),
).toBe(false);
});

it("keeps videos on the custom source-aware menu", () => {
expect(shouldUseNativeImageContextMenu({ ...remoteImage, kind: "video" })).toBe(false);
});

it("stops linked-image ancestors without preventing the native menu", () => {
const preventDefault = vi.fn();
const stopPropagation = vi.fn();

expect(
handleNativeImageContextMenu(remoteImage, {
defaultPrevented: false,
preventDefault,
stopPropagation,
}),
).toBe(true);
expect(stopPropagation).toHaveBeenCalledOnce();
expect(preventDefault).not.toHaveBeenCalled();
});
});
31 changes: 30 additions & 1 deletion apps/web/src/components/media/MediaActions.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,30 @@ export interface MediaActionSource {
readonly onOpenFile?: () => void;
}

/** Browser/Electron native image menus can copy or save rendered cross-origin images without CORS. */
export function shouldUseNativeImageContextMenu(source: MediaActionSource): boolean {
return (
source.kind === "image" &&
source.src !== null &&
source.reference?.kind === "url" &&
source.asset === undefined &&
source.onOpenFile === undefined
);
}

export function handleNativeImageContextMenu(
source: MediaActionSource,
event: {
readonly defaultPrevented: boolean;
readonly preventDefault: () => void;
readonly stopPropagation: () => void;
},
): boolean {
if (event.defaultPrevented || !shouldUseNativeImageContextMenu(source)) return false;
event.stopPropagation();
return true;
}

function mediaFileName(source: MediaActionSource): string {
return (
(source.reference && mediaReferenceFileName(source.reference)) || source.name || source.kind
Expand Down Expand Up @@ -83,7 +107,8 @@ export function MediaActions({
const menuOpen = useRef(false);
const reference = source.reference;
const hasActions =
source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined;
!shouldUseNativeImageContextMenu(source) &&
(source.kind === "image" || reference !== undefined || source.onOpenFile !== undefined);
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved
const tooltip = reference?.kind === "file" ? reference.path : (reference?.url ?? source.name);

const showMenu = async (position: { x: number; y: number }) => {
Expand Down Expand Up @@ -158,6 +183,10 @@ export function MediaActions({
render={children}
tabIndex={0}
onContextMenu={(event) => {
if (handleNativeImageContextMenu(source, event)) {
setTooltipOpen(false);
return;
}
if (!hasActions || event.defaultPrevented) return;
event.preventDefault();
event.stopPropagation();
Expand Down
Loading