Skip to content

Commit 6313f73

Browse files
vlunaklickclaude
andcommitted
fix(desktop): Save image works for images on hosts without CORS
The media menu saved images by fetching them in the renderer. Most image hosts send no CORS headers, so the fetch failed and Save image showed "Could not save image" even though the image was on screen. The CSP also blocks fetching data: URLs. On desktop, hand http(s) and data: URLs to the main process, which starts a native download with webContents.downloadURL and suggests the app's file name in the save dialog. Other clients keep the renderer fetch. Co-Authored-By: Claude Opus 5.5 <[email protected]>
1 parent 952921f commit 6313f73

6 files changed

Lines changed: 29 additions & 0 deletions

File tree

‎apps/desktop/src/ipc/DesktopIpcHandlers.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import {
4444
getSystemLocale,
4545
getWindowFullscreenState,
4646
openExternal,
47+
downloadUrl,
4748
openSystemSettings,
4849
checkSystemPermission,
4950
pasteAsText,
@@ -145,6 +146,7 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
145146
yield* ipc.handle(setTheme);
146147
yield* ipc.handle(showContextMenu);
147148
yield* ipc.handle(openExternal);
149+
yield* ipc.handle(downloadUrl);
148150
yield* ipc.handle(receiveProviderAuthCallback);
149151
yield* ipc.handle(cancelProviderAuthCallback);
150152
yield* ipc.handle(openSystemSettings);

‎apps/desktop/src/ipc/channels.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
55
export const SET_THEME_CHANNEL = "desktop:set-theme";
66
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
77
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
8+
export const DOWNLOAD_URL_CHANNEL = "desktop:download-url";
89
export const OPEN_SYSTEM_SETTINGS_CHANNEL = "desktop:open-system-settings";
910
export const PROBE_REMOTE_EDITORS_CHANNEL = "desktop:probe-remote-editors";
1011
export const MENU_ACTION_CHANNEL = "desktop:menu-action";

‎apps/desktop/src/ipc/methods/window.ts‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -290,6 +290,25 @@ export const openExternal = DesktopIpc.makeIpcMethod({
290290
}),
291291
});
292292

293+
/** The main process downloads outside the renderer's CORS and CSP limits. */
294+
export const downloadUrl = DesktopIpc.makeIpcMethod({
295+
channel: IpcChannels.DOWNLOAD_URL_CHANNEL,
296+
payload: Schema.Struct({ url: Schema.String, fileName: Schema.String }),
297+
result: Schema.Void,
298+
handler: ({ url, fileName }, event) =>
299+
Effect.sync(() => {
300+
const contents = event && Electron.webContents.fromId(event.sender.id);
301+
if (!contents || !/^(https?|data):/i.test(url)) return;
302+
// downloadURL cannot suggest a name, and asset URLs end in an opaque id.
303+
if (/\.\w+$/.test(fileName)) {
304+
contents.session.once("will-download", (_event, item) => {
305+
item.setSaveDialogOptions({ defaultPath: fileName });
306+
});
307+
}
308+
contents.downloadURL(url);
309+
}),
310+
});
311+
293312
export const openSystemSettings = DesktopIpc.makeIpcMethod({
294313
channel: IpcChannels.OPEN_SYSTEM_SETTINGS_CHANNEL,
295314
payload: SystemSettingsPaneSchema,

‎apps/desktop/src/preload.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,8 @@ contextBridge.exposeInMainWorld("desktopBridge", {
199199
cancelProviderAuthCallback: (url: string) =>
200200
ipcRenderer.invoke(IpcChannels.CANCEL_PROVIDER_AUTH_CALLBACK_CHANNEL, url),
201201
openExternal: (url: string) => ipcRenderer.invoke(IpcChannels.OPEN_EXTERNAL_CHANNEL, url),
202+
downloadUrl: (url: string, fileName: string) =>
203+
ipcRenderer.invoke(IpcChannels.DOWNLOAD_URL_CHANNEL, { url, fileName }),
202204
checkSystemPermission: (pane: string) =>
203205
ipcRenderer.invoke(IpcChannels.CHECK_SYSTEM_PERMISSION_CHANNEL, pane),
204206
openSystemSettings: (pane: string) =>

‎apps/web/src/components/media/mediaContent.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ async function readMediaBlob(src: string): Promise<Blob> {
2525
}
2626

2727
export async function downloadMedia(src: string, name: string): Promise<void> {
28+
// The renderer's fetch is bound by CORS and CSP; desktop downloads from the main process.
29+
const downloadUrl = typeof window === "undefined" ? undefined : window.desktopBridge?.downloadUrl;
30+
if (downloadUrl && /^(https?|data):/i.test(src)) return downloadUrl(src, name);
2831
const url = URL.createObjectURL(await readMediaBlob(src));
2932
try {
3033
const anchor = document.createElement("a");

‎packages/contracts/src/ipc.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1108,6 +1108,8 @@ export interface DesktopBridge {
11081108
receiveProviderAuthCallback?: (authorizationUrl: string) => Promise<string>;
11091109
cancelProviderAuthCallback?: (authorizationUrl: string) => Promise<void>;
11101110
openExternal: (url: string) => Promise<boolean>;
1111+
/** Starts a native download of an http(s) or data: URL. Missing in older desktop builds. */
1112+
downloadUrl?: (url: string, fileName: string) => Promise<void>;
11111113
openSystemSettings?: (pane: SystemSettingsPane) => Promise<boolean>;
11121114
checkSystemPermission?: (pane: SystemSettingsPane) => Promise<boolean>;
11131115
/**

0 commit comments

Comments
 (0)