Skip to content

Commit

Permalink
Merge pull request #37131 from wildan-m/wildan/fix/35189-multi-downlo…
Browse files Browse the repository at this point in the history
…ads-external-link

Fix multiple download issues on Mobile Safari and desktop versions.
  • Loading branch information
techievivek authored May 28, 2024
2 parents 2588312 + c792724 commit 6be3455
Show file tree
Hide file tree
Showing 12 changed files with 325 additions and 5 deletions.
4 changes: 4 additions & 0 deletions desktop/ELECTRON_EVENTS.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
11 changes: 10 additions & 1 deletion desktop/contextBridge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}'`;

Expand Down
116 changes: 116 additions & 0 deletions desktop/createDownloadQueue.ts
Original file line number Diff line number Diff line change
@@ -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<void> =>
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<DownloadItem>(downloadItemProcessor);

const enqueueDownloadItem = (item: DownloadItem): void => {
queue.enqueue(item);
};
return {enqueueDownloadItem, dequeueDownloadItem: queue.dequeue};
};

export default createDownloadQueue;
49 changes: 49 additions & 0 deletions desktop/electronDownloadManagerType.ts
Original file line number Diff line number Diff line change
@@ -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;
11 changes: 11 additions & 0 deletions desktop/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -613,6 +615,15 @@ const mainWindow = (): Promise<void> => {
}
});

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) {
Expand Down
1 change: 1 addition & 0 deletions src/CONST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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}
Expand Down
81 changes: 81 additions & 0 deletions src/libs/Queue/Queue.ts
Original file line number Diff line number Diff line change
@@ -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<T>(processItem: (item: T) => Promise<void>): Queue<T> {
// 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<void> {
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<void> {
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;
10 changes: 10 additions & 0 deletions src/libs/Queue/QueueType.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
type Queue<T> = {
run(): Promise<void>;
enqueue: (item: T) => void;
dequeue: () => T | undefined;
isEmpty: () => boolean;
peek: () => T | undefined;
size: () => number;
};

export default Queue;
37 changes: 37 additions & 0 deletions src/libs/fileDownload/index.desktop.ts
Original file line number Diff line number Diff line change
@@ -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;
5 changes: 3 additions & 2 deletions src/libs/fileDownload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading

0 comments on commit 6be3455

Please sign in to comment.