diff --git a/app/common/typed-ipc.ts b/app/common/typed-ipc.ts index ca10625a7..e8fbbca1d 100644 --- a/app/common/typed-ipc.ts +++ b/app/common/typed-ipc.ts @@ -13,6 +13,7 @@ export type MainMessage = { "realm-icon-changed": (serverURL: string, iconURL: string) => void; "realm-name-changed": (serverURL: string, realmName: string) => void; "reload-full-app": () => void; + "show-downloaded-file-in-folder": (downloadId: string) => void; "save-last-tab": (index: number) => void; "switch-server-tab": (index: number) => void; "toggle-app": () => void; @@ -59,6 +60,11 @@ export type RendererMessage = { "reload-proxy": (showAlert: boolean) => void; "reload-viewer": () => void; "render-taskbar-icon": (messageCount: number) => void; + "show-download-success": ( + title: string, + description: string, + downloadId: string, + ) => void; "set-active": () => void; "set-idle": () => void; "show-keyboard-shortcuts": () => void; diff --git a/app/main/handle-external-link.ts b/app/main/handle-external-link.ts index c5fe4828c..6d3eb325b 100644 --- a/app/main/handle-external-link.ts +++ b/app/main/handle-external-link.ts @@ -6,6 +6,7 @@ import { type WebContents, app, } from "electron/main"; +import {randomBytes} from "node:crypto"; import fs from "node:fs"; import path from "node:path"; @@ -15,6 +16,35 @@ import * as t from "../common/translation-util.ts"; import {send} from "./typed-ipc-main.ts"; +const maxTrackedDownloads = 50; + +type DownloadedFile = { + id: string; + filePath: string; +}; + +const downloadedFiles = new Map(); + +function trackDownloadedFile(filePath: string): DownloadedFile { + const downloadedFile: DownloadedFile = { + id: randomBytes(16).toString("hex"), + filePath, + }; + + downloadedFiles.set(downloadedFile.id, downloadedFile); + + if (downloadedFiles.size > maxTrackedDownloads) { + const oldestDownloadedFileId = [...downloadedFiles.keys()][0]; + downloadedFiles.delete(oldestDownloadedFileId); + } + + return downloadedFile; +} + +export function getDownloadedFilePath(downloadId: string): string | undefined { + return downloadedFiles.get(downloadId)?.filePath; +} + function isUploadsUrl(server: string, url: URL): boolean { return url.origin === server && url.pathname.startsWith("/user_uploads/"); } @@ -125,9 +155,14 @@ export default function handleExternalLink( url: url.href, downloadPath, async completed(filePath: string, fileName: string) { + const notificationTitle = t.__("Downloaded {{{fileName}}}.", { + fileName, + }); + const notificationBody = t.__("Click to open downloads folder."); + // Show native notification const downloadNotification = new Notification({ - title: t.__("Download Complete"), - body: t.__("Click to show {{{fileName}}} in folder", {fileName}), + title: notificationTitle, + body: notificationBody, silent: true, // We'll play our own sound - ding.ogg }); downloadNotification.on("click", () => { @@ -135,6 +170,16 @@ export default function handleExternalLink( shell.showItemInFolder(filePath); }); downloadNotification.show(); + const {id: downloadId} = trackDownloadedFile(filePath); + // Event to show in-app notification in addition to the native + // notification. + send( + contents, + "show-download-success", + notificationTitle, + notificationBody, + downloadId, + ); // Play sound to indicate download complete if (!ConfigUtil.getConfigItem("silent", false)) { @@ -150,7 +195,7 @@ export default function handleExternalLink( if (state !== "cancelled") { if (ConfigUtil.getConfigItem("promptDownload", false)) { new Notification({ - title: t.__("Download Complete"), + title: t.__("Download complete"), body: t.__("Download failed"), }).show(); } else { diff --git a/app/main/index.ts b/app/main/index.ts index d15de96cb..5e527d605 100644 --- a/app/main/index.ts +++ b/app/main/index.ts @@ -1,4 +1,4 @@ -import {clipboard} from "electron/common"; +import {clipboard, shell} from "electron/common"; import { BrowserWindow, type IpcMainEvent, @@ -25,7 +25,9 @@ import type {MenuProperties} from "../common/types.ts"; import {appUpdater, shouldQuitForUpdate} from "./autoupdater.ts"; import * as BadgeSettings from "./badge-settings.ts"; -import handleExternalLink from "./handle-external-link.ts"; +import handleExternalLink, { + getDownloadedFilePath, +} from "./handle-external-link.ts"; import * as AppMenu from "./menu.ts"; import {_getServerSettings, _isOnline, _saveServerIcon} from "./request.ts"; import {sentryInit} from "./sentry.ts"; @@ -452,6 +454,13 @@ function createMainWindow(): BrowserWindow { }, ); + ipcMain.on("show-downloaded-file-in-folder", (_event, downloadId: string) => { + const filePath = getDownloadedFilePath(downloadId); + if (filePath !== undefined) { + shell.showItemInFolder(filePath); + } + }); + ipcMain.on("save-last-tab", (_event, index: number) => { ConfigUtil.setConfigItem("lastActiveTab", index); }); diff --git a/app/renderer/js/electron-bridge.ts b/app/renderer/js/electron-bridge.ts index 0d5b43939..4f87cfccf 100644 --- a/app/renderer/js/electron-bridge.ts +++ b/app/renderer/js/electron-bridge.ts @@ -103,6 +103,13 @@ bridgeEvents.addEventListener("realm_icon_url", (event) => { ); }); +bridgeEvents.addEventListener("show-downloaded-file-in-folder", (event) => { + const [downloadId] = z + .tuple([z.string()]) + .parse(z.instanceof(BridgeEvent).parse(event).arguments_); + ipcRenderer.send("show-downloaded-file-in-folder", downloadId); +}); + // Set user as active and update the time of last activity ipcRenderer.on("set-active", () => { idle = false; diff --git a/app/renderer/js/preload.ts b/app/renderer/js/preload.ts index bed737b06..2292f8b35 100644 --- a/app/renderer/js/preload.ts +++ b/app/renderer/js/preload.ts @@ -18,6 +18,19 @@ ipcRenderer.on("show-notification-settings", () => { bridgeEvents.dispatchEvent(new BridgeEvent("show-notification-settings")); }); +ipcRenderer.on( + "show-download-success", + (_event, title: string, description: string, downloadId: string) => { + bridgeEvents.dispatchEvent( + new BridgeEvent("show-download-success", [ + title, + description, + downloadId, + ]), + ); + }, +); + window.addEventListener("load", () => { if (!location.href.includes("app/renderer/network.html")) { return;