diff --git a/desktop/ELECTRON_EVENTS.ts b/desktop/ELECTRON_EVENTS.ts index 607ad7b21580..b06794567c7d 100644 --- a/desktop/ELECTRON_EVENTS.ts +++ b/desktop/ELECTRON_EVENTS.ts @@ -9,6 +9,10 @@ const ELECTRON_EVENTS = { KEYBOARD_SHORTCUTS_PAGE: 'keyboard-shortcuts-page', START_UPDATE: 'start-update', UPDATE_DOWNLOADED: 'update-downloaded', + DOWNLOAD: 'download', + DOWNLOAD_COMPLETED: 'download-completed', + DOWNLOAD_FAILED: 'download-started', + DOWNLOAD_CANCELED: 'download-canceled', SILENT_UPDATE: 'silent-update', } as const; diff --git a/desktop/contextBridge.ts b/desktop/contextBridge.ts index 61ede178da2d..74b91c4634a1 100644 --- a/desktop/contextBridge.ts +++ b/desktop/contextBridge.ts @@ -16,10 +16,19 @@ const WHITELIST_CHANNELS_RENDERER_TO_MAIN = [ ELECTRON_EVENTS.REQUEST_VISIBILITY, ELECTRON_EVENTS.START_UPDATE, ELECTRON_EVENTS.LOCALE_UPDATED, + ELECTRON_EVENTS.DOWNLOAD, ELECTRON_EVENTS.SILENT_UPDATE, ] as const; -const WHITELIST_CHANNELS_MAIN_TO_RENDERER = [ELECTRON_EVENTS.KEYBOARD_SHORTCUTS_PAGE, ELECTRON_EVENTS.UPDATE_DOWNLOADED, ELECTRON_EVENTS.FOCUS, ELECTRON_EVENTS.BLUR] as const; +const WHITELIST_CHANNELS_MAIN_TO_RENDERER = [ + ELECTRON_EVENTS.KEYBOARD_SHORTCUTS_PAGE, + ELECTRON_EVENTS.UPDATE_DOWNLOADED, + ELECTRON_EVENTS.FOCUS, + ELECTRON_EVENTS.BLUR, + ELECTRON_EVENTS.DOWNLOAD_COMPLETED, + ELECTRON_EVENTS.DOWNLOAD_FAILED, + ELECTRON_EVENTS.DOWNLOAD_CANCELED, +] as const; const getErrorMessage = (channel: string): string => `Electron context bridge cannot be used with channel '${channel}'`; diff --git a/desktop/createDownloadQueue.ts b/desktop/createDownloadQueue.ts new file mode 100644 index 000000000000..132848c5da9e --- /dev/null +++ b/desktop/createDownloadQueue.ts @@ -0,0 +1,116 @@ +import type {BrowserWindow} from 'electron'; +import {app} from 'electron'; +import * as path from 'path'; +import createQueue from '@libs/Queue/Queue'; +import CONST from '@src/CONST'; +import ELECTRON_EVENTS from './ELECTRON_EVENTS'; +import type Options from './electronDownloadManagerType'; + +type DownloadItem = { + // The window where the download will be initiated + win: BrowserWindow; + + // The URL of the file to be downloaded + url: string; + + // The options for the download, such as save path, file name, etc. + options: Options; +}; + +/** + * Returns the filename with extension based on the given name and MIME type. + * @param name - The name of the file. + * @param mime - The MIME type of the file. + * @returns The filename with extension. + */ +const getFilenameFromMime = (name: string, mime: string): string => { + const extensions = mime.split('/').pop(); + return `${name}.${extensions}`; +}; + +const createDownloadQueue = () => { + const downloadItemProcessor = (item: DownloadItem): Promise => + new Promise((resolve, reject) => { + let downloadTimeout: NodeJS.Timeout; + let downloadListener: (event: Electron.Event, electronDownloadItem: Electron.DownloadItem) => void; + + const timeoutFunction = () => { + item.win.webContents.session.removeListener('will-download', downloadListener); + resolve(); + }; + + const listenerFunction = (event: Electron.Event, electronDownloadItem: Electron.DownloadItem) => { + clearTimeout(downloadTimeout); + + const options = item.options; + const cleanup = () => item.win.webContents.session.removeListener('will-download', listenerFunction); + const errorMessage = `The download of ${electronDownloadItem.getFilename()} was interrupted`; + + if (options.directory && !path.isAbsolute(options.directory)) { + throw new Error('The `directory` option must be an absolute path'); + } + + const directory = options.directory ?? app.getPath('downloads'); + + let filePath: string; + if (options.filename) { + filePath = path.join(directory, options.filename); + } else { + const filename = electronDownloadItem.getFilename(); + const name = path.extname(filename) ? filename : getFilenameFromMime(filename, electronDownloadItem.getMimeType()); + + filePath = options.overwrite ? path.join(directory, name) : path.join(directory, name); + } + + if (options.saveAs) { + electronDownloadItem.setSaveDialogOptions({defaultPath: filePath, ...options.dialogOptions}); + } else { + electronDownloadItem.setSavePath(filePath); + } + + electronDownloadItem.on('updated', (_, state) => { + if (state !== 'interrupted') { + return; + } + + item.win.webContents.send(ELECTRON_EVENTS.DOWNLOAD_CANCELED, {url: item.url}); + cleanup(); + reject(new Error(errorMessage)); + electronDownloadItem.cancel(); + }); + + electronDownloadItem.on('done', (_, state) => { + cleanup(); + if (state === 'cancelled') { + item.win.webContents.send(ELECTRON_EVENTS.DOWNLOAD_CANCELED, {url: item.url}); + resolve(); + } else if (state === 'interrupted') { + item.win.webContents.send(ELECTRON_EVENTS.DOWNLOAD_FAILED, {url: item.url}); + reject(new Error(errorMessage)); + } else if (state === 'completed') { + if (process.platform === 'darwin') { + const savePath = electronDownloadItem.getSavePath(); + app.dock.downloadFinished(savePath); + } + item.win.webContents.send(ELECTRON_EVENTS.DOWNLOAD_COMPLETED, {url: item.url}); + resolve(); + } + }); + }; + + downloadTimeout = setTimeout(timeoutFunction, CONST.DOWNLOADS_TIMEOUT); + downloadListener = listenerFunction; + + item.win.webContents.downloadURL(item.url); + item.win.webContents.session.on('will-download', downloadListener); + }); + + const queue = createQueue(downloadItemProcessor); + + const enqueueDownloadItem = (item: DownloadItem): void => { + queue.enqueue(item); + }; + return {enqueueDownloadItem, dequeueDownloadItem: queue.dequeue}; +}; + +export default createDownloadQueue; diff --git a/desktop/electronDownloadManagerType.ts b/desktop/electronDownloadManagerType.ts new file mode 100644 index 000000000000..755efe173887 --- /dev/null +++ b/desktop/electronDownloadManagerType.ts @@ -0,0 +1,49 @@ +import type {SaveDialogOptions} from 'electron'; + +type Options = { + /** + Show a `Save As…` dialog instead of downloading immediately. + + Note: Only use this option when strictly necessary. Downloading directly without a prompt is a much better user experience. + + @default false + */ + readonly saveAs?: boolean; + + /** + The directory to save the file in. + + Must be an absolute path. + + Default: [User's downloads directory](https://electronjs.org/docs/api/app/#appgetpathname) + */ + readonly directory?: string; + + /** + Name of the saved file. + This option only makes sense for `electronDownloadManager.download()`. + + Default: [`downloadItem.getFilename()`](https://electronjs.org/docs/api/download-item/#downloaditemgetfilename) + */ + readonly filename?: string; + + /** + Allow downloaded files to overwrite files with the same name in the directory they are saved to. + + The default behavior is to append a number to the filename. + + @default false + */ + readonly overwrite?: boolean; + + /** + Customize the save dialog. + + If `defaultPath` is not explicity defined, a default value is assigned based on the file path. + + @default {} + */ + readonly dialogOptions?: SaveDialogOptions; +}; + +export default Options; diff --git a/desktop/main.ts b/desktop/main.ts index 64587f42bf56..0f4774d3b73b 100644 --- a/desktop/main.ts +++ b/desktop/main.ts @@ -15,6 +15,8 @@ import type PlatformSpecificUpdater from '@src/setup/platformSetup/types'; import type {Locale} from '@src/types/onyx'; import ELECTRON_EVENTS from './ELECTRON_EVENTS'; +const createDownloadQueue = require('./createDownloadQueue').default; + const port = process.env.PORT ?? 8082; const {DESKTOP_SHORTCUT_ACCELERATOR, LOCALES} = CONST; @@ -613,6 +615,15 @@ const mainWindow = (): Promise => { } }); + const downloadQueue = createDownloadQueue(); + ipcMain.on(ELECTRON_EVENTS.DOWNLOAD, (event, downloadData) => { + const downloadItem = { + ...downloadData, + win: browserWindow, + }; + downloadQueue.enqueueDownloadItem(downloadItem); + }); + // Automatically check for and install the latest version in the background ipcMain.on(ELECTRON_EVENTS.SILENT_UPDATE, () => { if (isSilentUpdating) { diff --git a/src/CONST.ts b/src/CONST.ts index 991efad1d0c1..5babcd30ae78 100755 --- a/src/CONST.ts +++ b/src/CONST.ts @@ -4751,6 +4751,7 @@ const CONST = { MAX_TAX_RATE_DECIMAL_PLACES: 4, DOWNLOADS_PATH: '/Downloads', + DOWNLOADS_TIMEOUT: 5000, NEW_EXPENSIFY_PATH: '/New Expensify', ENVIRONMENT_SUFFIX: { diff --git a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx index 0c8af3dfc826..595e28acd3bc 100644 --- a/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx +++ b/src/components/AnchorForAttachmentsOnly/BaseAnchorForAttachmentsOnly.tsx @@ -7,6 +7,7 @@ import {ShowContextMenuContext, showContextMenuForReport} from '@components/Show import useNetwork from '@hooks/useNetwork'; import useThemeStyles from '@hooks/useThemeStyles'; import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL'; +import * as Browser from '@libs/Browser'; import fileDownload from '@libs/fileDownload'; import * as ReportUtils from '@libs/ReportUtils'; import * as Download from '@userActions/Download'; @@ -48,7 +49,7 @@ function BaseAnchorForAttachmentsOnly({style, source = '', displayName = '', dow return; } Download.setDownload(sourceID, true); - fileDownload(sourceURLWithAuth, displayName).then(() => Download.setDownload(sourceID, false)); + fileDownload(sourceURLWithAuth, displayName, '', Browser.isMobileSafari()).then(() => Download.setDownload(sourceID, false)); }} onPressIn={onPressIn} onPressOut={onPressOut} diff --git a/src/libs/Queue/Queue.ts b/src/libs/Queue/Queue.ts new file mode 100644 index 000000000000..8b2011e64371 --- /dev/null +++ b/src/libs/Queue/Queue.ts @@ -0,0 +1,81 @@ +import Log from '@libs/Log'; +import CONST from '@src/CONST'; +import type Queue from './QueueType'; + +// Function to create a new queue +function createQueue(processItem: (item: T) => Promise): Queue { + // Array to hold the elements of the queue + const elements: T[] = []; + let isProcessing = false; + + // Function to remove an item from the front of the queue + function dequeue(): T | undefined { + return elements.shift(); + } + + // Function to check if the queue is empty + function isEmpty(): boolean { + return elements.length === 0; + } + + // Function to process the next item in the queue + function processNext(): Promise { + return new Promise((resolve) => { + if (!isEmpty()) { + const nextItem = dequeue(); + if (nextItem) { + processItem(nextItem) + .catch((error: Error) => { + const errorMessage = error.message ?? CONST.ERROR.UNKNOWN_ERROR; + + Log.hmmm('Queue error:', {errorMessage}); + }) + .finally(() => { + processNext().then(resolve); + }); + } + } else { + isProcessing = false; + resolve(); + } + }); + } + + // Initiates the processing of items in the queue. + // Continues to dequeue and process items as long as the queue is not empty. + // Sets the `isProcessing` flag to true at the start and resets it to false once all items have been processed. + function run(): Promise { + isProcessing = true; + return processNext(); + } + + // Adds an item to the queue and initiates processing if not already in progress + function enqueue(item: T): void { + elements.push(item); + if (!isProcessing) { + run(); + } + } + + // Function to get the item at the front of the queue without removing it + function peek(): T | undefined { + return elements.length > 0 ? elements[0] : undefined; + } + + // Function to get the number of items in the queue + function size(): number { + return elements.length; + } + + // Return an object with the queue operations + return { + run, + enqueue, + dequeue, + isEmpty, + peek, + size, + }; +} + +export default createQueue; diff --git a/src/libs/Queue/QueueType.ts b/src/libs/Queue/QueueType.ts new file mode 100644 index 000000000000..a38eb0864e0a --- /dev/null +++ b/src/libs/Queue/QueueType.ts @@ -0,0 +1,10 @@ +type Queue = { + run(): Promise; + enqueue: (item: T) => void; + dequeue: () => T | undefined; + isEmpty: () => boolean; + peek: () => T | undefined; + size: () => number; +}; + +export default Queue; diff --git a/src/libs/fileDownload/index.desktop.ts b/src/libs/fileDownload/index.desktop.ts new file mode 100644 index 000000000000..8e682225b79a --- /dev/null +++ b/src/libs/fileDownload/index.desktop.ts @@ -0,0 +1,37 @@ +import ELECTRON_EVENTS from '@desktop/ELECTRON_EVENTS'; +import type Options from '@desktop/electronDownloadManagerType'; +import CONST from '@src/CONST'; +import type {FileDownload} from './types'; + +/** + * The function downloads an attachment on desktop platforms. + */ +const fileDownload: FileDownload = (url, fileName) => { + const options: Options = { + filename: fileName, + saveAs: true, + }; + window.electron.send(ELECTRON_EVENTS.DOWNLOAD, {url, options}); + return new Promise((resolve) => { + // This sets a timeout that will resolve the promise after 5 seconds to prevent indefinite hanging + const downloadTimeout = setTimeout(() => { + resolve(); + }, CONST.DOWNLOADS_TIMEOUT); + + const handleDownloadStatus = (...args: unknown[]) => { + const arg = Array.isArray(args) ? args[0] : null; + const eventUrl = arg && typeof arg === 'object' && 'url' in arg ? arg.url : null; + + if (eventUrl === url) { + clearTimeout(downloadTimeout); + resolve(); + } + }; + + window.electron.on(ELECTRON_EVENTS.DOWNLOAD_COMPLETED, handleDownloadStatus); + window.electron.on(ELECTRON_EVENTS.DOWNLOAD_FAILED, handleDownloadStatus); + window.electron.on(ELECTRON_EVENTS.DOWNLOAD_CANCELED, handleDownloadStatus); + }); +}; + +export default fileDownload; diff --git a/src/libs/fileDownload/index.ts b/src/libs/fileDownload/index.ts index 1e12a5ca321b..3bae37c2ed6b 100644 --- a/src/libs/fileDownload/index.ts +++ b/src/libs/fileDownload/index.ts @@ -8,9 +8,10 @@ import type {FileDownload} from './types'; /** * The function downloads an attachment on web/desktop platforms. */ -const fileDownload: FileDownload = (url, fileName) => { +// eslint-disable-next-line @typescript-eslint/no-unused-vars +const fileDownload: FileDownload = (url, fileName, successMessage = '', shouldOpenExternalLink = false) => { const resolvedUrl = tryResolveUrlFromApiRoot(url); - if (!resolvedUrl.startsWith(ApiUtils.getApiRoot()) && !CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => resolvedUrl.startsWith(prefix))) { + if (shouldOpenExternalLink || (!resolvedUrl.startsWith(ApiUtils.getApiRoot()) && !CONST.ATTACHMENT_LOCAL_URL_PREFIX.some((prefix) => resolvedUrl.startsWith(prefix)))) { // Different origin URLs might pose a CORS issue during direct downloads. // Opening in a new tab avoids this limitation, letting the browser handle the download. Link.openExternalLink(url); diff --git a/src/libs/fileDownload/types.ts b/src/libs/fileDownload/types.ts index b59a656d0ac2..fcc210c1c42f 100644 --- a/src/libs/fileDownload/types.ts +++ b/src/libs/fileDownload/types.ts @@ -1,6 +1,6 @@ import type {Asset} from 'react-native-image-picker'; -type FileDownload = (url: string, fileName?: string, successMessage?: string) => Promise; +type FileDownload = (url: string, fileName?: string, successMessage?: string, shouldOpenExternalLink?: boolean) => Promise; type ImageResolution = {width: number; height: number}; type GetImageResolution = (url: File | Asset) => Promise;