UNPKG

electron-dl-manager

Version:

A library for implementing file downloads in Electron with 'save as' dialog and id support.

767 lines (759 loc) 25.4 kB
//#region rolldown:runtime var __create = Object.create; var __defProp = Object.defineProperty; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __getOwnPropNames = Object.getOwnPropertyNames; var __getProtoOf = Object.getPrototypeOf; var __hasOwnProp = Object.prototype.hasOwnProperty; var __copyProps = (to, from, except, desc) => { if (from && typeof from === "object" || typeof from === "function") for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) { key = keys[i]; if (!__hasOwnProp.call(to, key) && key !== except) __defProp(to, key, { get: ((k) => from[k]).bind(null, key), enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } return to; }; var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod)); //#endregion const node_crypto = __toESM(require("node:crypto")); const node_path = __toESM(require("node:path")); const electron = __toESM(require("electron")); const ext_name = __toESM(require("ext-name")); const unused_filename = __toESM(require("unused-filename")); const node_fs = __toESM(require("node:fs")); //#region src/CallbackDispatcher.ts /** * Wraps around the callbacks to handle errors and logging */ var CallbackDispatcher = class { logger; callbacks; downloadDataId; constructor(downloadDataId, callbacks, logger) { this.downloadDataId = downloadDataId; this.callbacks = callbacks; this.logger = logger; } log(message) { this.logger(`[${this.downloadDataId}] ${message}`); } async onDownloadStarted(downloadData) { const { callbacks } = this; if (callbacks.onDownloadStarted) { this.log("Calling onDownloadStarted"); try { await callbacks.onDownloadStarted(downloadData); } catch (e) { this.log(`Error during onDownloadStarted: ${e}`); this.handleError(e); } } } async onDownloadCompleted(downloadData) { const { callbacks } = this; if (callbacks.onDownloadCompleted) { this.log("Calling onDownloadCompleted"); try { await callbacks.onDownloadCompleted(downloadData); } catch (e) { this.log(`Error during onDownloadCompleted: ${e}`); this.handleError(e); } } } async onDownloadProgress(downloadData) { const { callbacks } = this; if (callbacks.onDownloadProgress) try { await callbacks.onDownloadProgress(downloadData); } catch (e) { this.log(`Error during onDownloadProgress: ${e}`); this.handleError(e); } } async onDownloadCancelled(downloadData) { const { callbacks } = this; if (callbacks.onDownloadCancelled) { this.log("Calling onDownloadCancelled"); try { await callbacks.onDownloadCancelled(downloadData); } catch (e) { this.log(`Error during onDownloadCancelled: ${e}`); this.handleError(e); } } } async onDownloadInterrupted(downloadData) { const { callbacks } = this; if (callbacks.onDownloadInterrupted) { this.log("Calling onDownloadInterrupted"); try { await callbacks.onDownloadInterrupted(downloadData); } catch (e) { this.log(`Error during onDownloadInterrupted: ${e}`); this.handleError(e); } } } async onDownloadPersisted(downloadData) { const { callbacks } = this; if (callbacks.onDownloadPersisted) { this.log("Calling onDownloadPersisted"); try { await callbacks.onDownloadPersisted(downloadData, downloadData.getRestoreDownloadData()); } catch (e) { this.log(`Error during onDownloadPersisted: ${e}`); this.handleError(e, downloadData); } } } handleError(error, downloadData) { const { callbacks } = this; if (callbacks.onError) callbacks.onError(error, downloadData); } }; //#endregion //#region src/utils.ts function truncateUrl(url) { if (url.length > 50) return `${url.slice(0, 50)}...`; return url; } function generateRandomId() { const currentTime = Date.now(); const randomNum = Math.floor(Math.random() * 1e3); const combinedValue = currentTime.toString() + randomNum.toString(); const hash = node_crypto.default.createHash("sha256"); hash.update(combinedValue); return hash.digest("hex").substring(0, 6); } function getFilenameFromMime(name, mime) { const extensions = ext_name.default.mime(mime); if (extensions.length !== 1) return name; return `${name}.${extensions[0].ext}`; } /** * Determines the initial file path for the download. */ function determineFilePath({ directory, saveAsFilename, item, overwrite }) { if (directory && !node_path.default.isAbsolute(directory)) throw new Error("The `directory` option must be an absolute path"); directory = directory || electron.app?.getPath("downloads"); let filePath; if (saveAsFilename) filePath = node_path.default.join(directory, saveAsFilename); else { const filename = item.getFilename(); const name = node_path.default.extname(filename) ? filename : getFilenameFromMime(filename, item.getMimeType()); filePath = overwrite ? node_path.default.join(directory, name) : unused_filename.default.sync(node_path.default.join(directory, name)); } return filePath; } /** * Calculates the download rate and estimated time remaining for a download. * @returns {object} An object containing the download rate in bytes per second and the estimated time remaining in seconds. */ function calculateDownloadMetrics(item) { const downloadedBytes = item.getReceivedBytes(); const totalBytes = item.getTotalBytes(); const startTimeSecs = item.getStartTime(); const currentTimeSecs = Math.floor(Date.now() / 1e3); const elapsedTimeSecs = currentTimeSecs - startTimeSecs; let downloadRateBytesPerSecond = item.getCurrentBytesPerSecond ? item.getCurrentBytesPerSecond() : 0; let estimatedTimeRemainingSeconds = 0; if (elapsedTimeSecs > 0) { if (!downloadRateBytesPerSecond) downloadRateBytesPerSecond = downloadedBytes / elapsedTimeSecs; if (downloadRateBytesPerSecond > 0) estimatedTimeRemainingSeconds = (totalBytes - downloadedBytes) / downloadRateBytesPerSecond; } let percentCompleted = 0; if (item.getPercentComplete) percentCompleted = item.getPercentComplete(); else percentCompleted = totalBytes > 0 ? Math.min(Number.parseFloat((downloadedBytes / totalBytes * 100).toFixed(2)), 100) : 0; return { percentCompleted, downloadRateBytesPerSecond, estimatedTimeRemainingSeconds }; } //#endregion //#region src/DownloadData.ts /** * Contains the data for a download. */ var DownloadData = class { /** * Generated id for the download */ id; /** * The Electron.DownloadItem. Use this to grab the filename, path, etc. * @see https://www.electronjs.org/docs/latest/api/download-item */ item; /** * The Electron.WebContents * @see https://www.electronjs.org/docs/latest/api/web-contents */ webContents; /** * The Electron.Event * @see https://www.electronjs.org/docs/latest/api/event */ event; /** * The name of the file that is being saved to the user's computer. * Recommended over Item.getFilename() as it may be inaccurate when using the save as dialog. */ resolvedFilename; /** * If true, the download was cancelled from the save as dialog. This flag * will also be true if the download was cancelled by the application when * using the save as dialog. */ cancelledFromSaveAsDialog; /** * The percentage of the download that has been completed */ percentCompleted; /** * The download rate in bytes per second. */ downloadRateBytesPerSecond; /** * The estimated time remaining in seconds. */ estimatedTimeRemainingSeconds; /** * If the download was interrupted, the state in which it was interrupted from */ interruptedVia; /** * If defined, this is the path where the download is persisted to. */ persistedFilePath; constructor(config = {}) { this.id = config.id || generateRandomId(); this.resolvedFilename = "testFile.txt"; this.percentCompleted = 0; this.cancelledFromSaveAsDialog = false; this.item = {}; this.webContents = {}; this.event = {}; this.downloadRateBytesPerSecond = 0; this.estimatedTimeRemainingSeconds = 0; } /** * Returns data necessary for restoring a download */ getRestoreDownloadData() { return { id: this.id, fileSaveAsPath: this.item.getSavePath(), url: this.item.getURL(), urlChain: this.item.getURLChain(), eTag: this.item.getETag(), totalBytes: this.item.getTotalBytes(), mimeType: this.item.getMimeType(), receivedBytes: this.item.getReceivedBytes(), startTime: this.item.getStartTime(), percentCompleted: this.percentCompleted, persistedFilePath: this.persistedFilePath }; } isDownloadInProgress() { return this.item.getState() === "progressing"; } isDownloadCompleted() { return this.item.getState() === "completed"; } isDownloadCancelled() { return this.item.getState() === "cancelled"; } isDownloadInterrupted() { return this.item.getState() === "interrupted"; } isDownloadResumable() { return this.item.canResume(); } isDownloadPaused() { return this.item.isPaused(); } }; //#endregion //#region src/DownloadInitiator.ts var DownloadInitiator = class { logger; /** * When the download is initiated */ onDownloadInit; /** * When cleanup is called */ onCleanup; /** * The callback dispatcher for handling download events back to the user */ callbackDispatcher; /** * The data for the download. */ downloadData; config; /** * The handler for the DownloadItem's `updated` event. */ onUpdateHandler; /** * The handler for the DownloadItem's `done` event. */ onDoneHandler; constructor(config) { this.downloadData = new DownloadData({ id: config.id }); this.logger = config.debugLogger || (() => {}); this.onCleanup = config.onCleanup || (() => {}); this.onDownloadInit = config.onDownloadInit || (() => {}); this.config = {}; this.callbackDispatcher = new CallbackDispatcher(this.downloadData.id, config.callbacks, this.logger); } log(message) { this.logger(`[${this.downloadData.id}] ${message}`); } /** * Returns the download id */ getDownloadId() { return this.downloadData.id; } /** * Returns the current download data */ getDownloadData() { return this.downloadData; } /** * Generates the handler that attaches to the session `will-download` event, * which will execute the workflows for handling a download. */ generateOnWillDownload(downloadParams) { this.config = downloadParams; this.downloadData.percentCompleted = this.config.restoreData?.percentCompleted || 0; return async (event, item, webContents) => { item.pause(); this.downloadData.item = item; this.downloadData.webContents = webContents; this.downloadData.event = event; if (this.onDownloadInit) this.onDownloadInit(this.downloadData); if (this.config.saveDialogOptions) { this.initSaveAsInteractiveDownload(); return; } await this.initNonInteractiveDownload(!!this.config.restoreData); }; } /** * Flow for handling a download that requires user interaction via a "Save as" dialog. */ initSaveAsInteractiveDownload() { this.log("Prompting save as dialog"); const { directory, overwrite, saveDialogOptions } = this.config; const { item } = this.downloadData; const filePath = determineFilePath({ directory, item, overwrite }); item.setSaveDialogOptions({ ...saveDialogOptions, defaultPath: filePath }); const interval = setInterval(async () => { item.pause(); if (item.getSavePath()) { clearInterval(interval); this.log(`User selected save path to ${item.getSavePath()}`); this.log("Initiating download item handlers"); this.downloadData.resolvedFilename = node_path.basename(item.getSavePath()); this.augmentDownloadItem(item); await this.callbackDispatcher.onDownloadStarted(this.downloadData); if (this.downloadData.isDownloadCompleted()) await this.callbackDispatcher.onDownloadCompleted(this.downloadData); else { this.onUpdateHandler = this.generateItemOnUpdated(); this.onDoneHandler = this.generateItemOnDone(); item.on("updated", this.onUpdateHandler); item.once("done", this.onDoneHandler); } if (!item["_userInitiatedPause"]) item.resume(); } else if (this.downloadData.isDownloadCancelled()) { clearInterval(interval); this.log("Download was cancelled"); this.downloadData.cancelledFromSaveAsDialog = true; await this.callbackDispatcher.onDownloadCancelled(this.downloadData); } else this.log("Waiting for save path to be chosen by user"); }, 1e3); } augmentDownloadItem(item) { item["_userInitiatedPause"] = false; const oldPause = item.pause.bind(item); item.pause = () => { item["_userInitiatedPause"] = true; if (this.onUpdateHandler) { item.off("updated", this.onUpdateHandler); this.onUpdateHandler = void 0; } oldPause(); }; const oldResume = item.resume.bind(item); item.resume = () => { if (!this.onUpdateHandler) { this.onUpdateHandler = this.generateItemOnUpdated(); item.on("updated", this.onUpdateHandler); } oldResume(); }; } /** * Flow for handling a download that doesn't require user interaction. */ async initNonInteractiveDownload(isRestoring) { const { directory, saveAsFilename, overwrite } = this.config; const { item } = this.downloadData; const filePath = determineFilePath({ directory, saveAsFilename, item, overwrite }); if (!isRestoring) { this.log(`Setting save path to ${filePath}`); item.setSavePath(filePath); } this.log("Initiating download item handlers"); this.downloadData.resolvedFilename = node_path.basename(filePath); this.augmentDownloadItem(item); await this.callbackDispatcher.onDownloadStarted(this.downloadData); this.onUpdateHandler = this.generateItemOnUpdated(); this.onDoneHandler = this.generateItemOnDone(); item.on("updated", this.onUpdateHandler); item.once("done", this.onDoneHandler); if (!item["_userInitiatedPause"]) item.resume(); } updateProgress() { const { item } = this.downloadData; const metrics = calculateDownloadMetrics(item); const downloadedBytes = item.getReceivedBytes(); const totalBytes = item.getTotalBytes(); if (downloadedBytes > item.getTotalBytes()) this.log(`Downloaded bytes (${downloadedBytes}) is greater than total bytes (${totalBytes})`); this.downloadData.downloadRateBytesPerSecond = metrics.downloadRateBytesPerSecond; this.downloadData.estimatedTimeRemainingSeconds = metrics.estimatedTimeRemainingSeconds; this.downloadData.percentCompleted = metrics.percentCompleted; } /** * Generates the handler for hooking into the DownloadItem's `updated` event. */ generateItemOnUpdated() { return async (_event, state) => { switch (state) { case "progressing": { this.updateProgress(); await this.callbackDispatcher.onDownloadProgress(this.downloadData); break; } case "interrupted": { this.downloadData.interruptedVia = "in-progress"; await this.callbackDispatcher.onDownloadInterrupted(this.downloadData); break; } default: this.log(`Unexpected itemOnUpdated state: ${state}`); } }; } /** * Generates the handler for hooking into the DownloadItem's `done` event. */ generateItemOnDone() { return async (_event, state) => { switch (state) { case "completed": { this.log(`Download completed. Total bytes: ${this.downloadData.item.getTotalBytes()}`); await this.callbackDispatcher.onDownloadCompleted(this.downloadData); break; } case "cancelled": this.log(`Download cancelled. Total bytes: ${this.downloadData.item.getReceivedBytes()} / ${this.downloadData.item.getTotalBytes()}`); await this.callbackDispatcher.onDownloadCancelled(this.downloadData); break; case "interrupted": this.log(`Download interrupted. Total bytes: ${this.downloadData.item.getReceivedBytes()} / ${this.downloadData.item.getTotalBytes()}`); this.downloadData.interruptedVia = "completed"; await this.callbackDispatcher.onDownloadInterrupted(this.downloadData); break; default: this.log(`Unexpected itemOnDone state: ${state}`); } this.cleanup(); }; } cleanup() { const { item } = this.downloadData; if (item) { this.log("Cleaning up download item event listeners"); if (this.onUpdateHandler) item.removeListener("updated", this.onUpdateHandler); if (this.onDoneHandler) item.removeListener("done", this.onDoneHandler); } if (this.onCleanup) this.onCleanup(this.downloadData); this.onUpdateHandler = void 0; this.onDoneHandler = void 0; } getPersistDownloadFilename() { return `${this.downloadData.resolvedFilename}.download`; } /** * Persists the download to an alternative location. * This is useful for saving the download state when the app is about to close. * It copies the current download file to a new location with a `.download` extension. * If the download is already completed or cancelled, it does nothing. */ persistDownload() { if (!this.downloadData.item || this.downloadData.item.getState() === "completed" || this.downloadData.item.getState() === "cancelled") { this.log(`Download ${this.downloadData.resolvedFilename} is already completed, cancelled or does not exist; no need to persist.`); return; } this.downloadData.item.pause(); const originalPath = this.downloadData.item.getSavePath(); const persistPath = node_path.join(node_path.dirname(originalPath), this.getPersistDownloadFilename()); this.log(`Persisting download to ${persistPath}`); try { (0, node_fs.copyFileSync)(originalPath, persistPath); this.downloadData.persistedFilePath = persistPath; this.log(`Download persisted successfully to ${persistPath}`); this.callbackDispatcher.onDownloadPersisted(this.downloadData); } catch (error) { this.callbackDispatcher.handleError(new Error(`Failed to persist download: ${error}`)); } } /** * Restores a download from a persisted state. */ restorePersistedDownload(restoreData) { if (!restoreData.persistedFilePath) { this.log("No persisted file path found for download, cannot restore."); return; } const originalPath = restoreData.fileSaveAsPath; const restorePath = restoreData.persistedFilePath; this.log(`Restoring download from ${restorePath} to ${originalPath}`); try { (0, node_fs.renameSync)(restorePath, originalPath); this.log(`Download restored successfully from ${restorePath} to ${originalPath}`); } catch (error) { this.callbackDispatcher.handleError(new Error(`Failed to restore download: ${error}`)); } } }; //#endregion //#region src/ElectronDownloadManager.ts /** * This is used to solve an issue where multiple downloads are started at the same time. * For example, Promise.all([download1, download2, ...]) will start both downloads at the same * time. This is problematic because the will-download event is not guaranteed to fire in the * order that the downloads were started. * * So we use this to make sure that will-download fires in the order that the downloads were * started by executing the downloads in a sequential fashion. * * For more information see: * https://github.com/theogravity/electron-dl-manager/issues/11 */ var DownloadQueue = class { promise = Promise.resolve(); add(task) { this.promise = this.promise.then(() => task()); return this.promise; } }; /** * Enables handling downloads in Electron. */ var ElectronDownloadManager = class { downloadData; logger; downloadQueue = new DownloadQueue(); constructor(params = {}) { this.downloadData = {}; this.logger = params.debugLogger || (() => {}); } log(message) { this.logger(message); } /** * Returns the current download data */ getDownloadData(id) { return this.downloadData[id]; } /** * Cancels a download */ cancelDownload(id) { const data = this.downloadData[id]; if (data?.item) { this.log(`[${id}] Cancelling download`); data.item.cancel(); } else this.log(`[${id}] Download ${id} not found for cancellation`); } /** * Pauses a download and returns the data necessary * to restore it later via restoreDownload() if the download exists. */ pauseDownload(id) { const data = this.downloadData[id]; if (data?.item) { this.log(`[${id}] Pausing download`); data.item.pause(); return data.getRestoreDownloadData(); } this.log(`[${id}] Download ${id} not found for pausing`); } /** * Resumes a download */ resumeDownload(id) { const data = this.downloadData[id]; if (data?.item?.isPaused()) { this.log(`[${id}] Resuming download`); data.item.resume(); } else this.log(`[${id}] Download ${id} not found or is not in a paused state`); } /** * Returns the number of active downloads */ getActiveDownloadCount() { return Object.values(this.downloadData).filter((data) => data.isDownloadInProgress()).length; } /** * Restores a download that is not registered in the download manager. * If it is already registered, calls resumeDownload() instead. */ async restoreDownload(params) { if (this.getDownloadData(params.restoreData.id)) { this.resumeDownload(params.restoreData.id); return params.restoreData.id; } return this.downloadQueue.add(() => new Promise((resolve, reject) => { try { const restoreData = params.restoreData; const onWillQuit = () => { downloadInitiator.persistDownload(); }; const downloadInitiator = new DownloadInitiator({ id: restoreData.id, debugLogger: this.logger, callbacks: params.callbacks, onCleanup: (data) => { this.cleanup(data); if (params.restoreData.persistedFilePath) params.app.removeListener("will-quit", onWillQuit); }, onDownloadInit: (data) => { this.downloadData[data.id] = data; resolve(data.id); } }); if (restoreData.persistedFilePath) downloadInitiator.restorePersistedDownload(restoreData); this.log(`[${downloadInitiator.getDownloadId()}] Restoring download for url: ${truncateUrl(params.restoreData.url)}`); params.window.webContents.session.once("will-download", downloadInitiator.generateOnWillDownload({ restoreData: params.restoreData })); params.window.webContents.session.createInterruptedDownload({ path: restoreData.fileSaveAsPath, urlChain: restoreData.urlChain, mimeType: restoreData.mimeType, eTag: restoreData.eTag, offset: restoreData.receivedBytes, length: restoreData.totalBytes, startTime: restoreData.startTime }); if (params.restoreData.persistedFilePath) params.app.once("will-quit", onWillQuit); } catch (e) { reject(e); } })); } /** * Starts a download. If saveDialogOptions has been defined in the config, * the saveAs dialog will show up first. * * Returns the id of the download. */ async download(params) { if (params.persistOnAppClose && !params.app) throw Error("You must provide the app instance to persist downloads on app close"); return this.downloadQueue.add(() => new Promise((resolve, reject) => { try { if (params.saveAsFilename && params.saveDialogOptions) return reject(Error("You cannot define both saveAsFilename and saveDialogOptions to start a download")); const onWillQuit = () => { downloadInitiator.persistDownload(); }; const downloadInitiator = new DownloadInitiator({ debugLogger: this.logger, callbacks: params.callbacks, onCleanup: (data) => { this.cleanup(data); if (params.persistOnAppClose && params.app) params.app.removeListener("will-quit", onWillQuit); }, onDownloadInit: (data) => { this.downloadData[data.id] = data; resolve(data.id); } }); this.log(`[${downloadInitiator.getDownloadId()}] Registering download for url: ${truncateUrl(params.url)}`); params.window.webContents.session.once("will-download", downloadInitiator.generateOnWillDownload({ saveDialogOptions: params.saveDialogOptions, saveAsFilename: params.saveAsFilename, directory: params.directory, overwrite: params.overwrite })); params.window.webContents.downloadURL(params.url, params.downloadURLOptions); if (params.persistOnAppClose && params.app) params.app.once("will-quit", onWillQuit); } catch (e) { reject(e); } })); } cleanup(data) { this.log(`[${data.id}] Removing download from manager`); delete this.downloadData[data.id]; } }; //#endregion //#region src/ElectronDownloadManagerMock.ts /** * Mock version of ElectronDownloadManager * that can be used for testing purposes */ var ElectronDownloadManagerMock = class { async download(_params) { return "mock-download-id"; } cancelDownload(_id) {} pauseDownload(_id) { return void 0; } resumeDownload(_id) {} getActiveDownloadCount() { return 0; } getDownloadData(id) { const downloadData = new DownloadData(); downloadData.id = id; return downloadData; } async restoreDownload(_params) { return "mock-restored-download-id"; } }; //#endregion exports.CallbackDispatcher = CallbackDispatcher; exports.DownloadData = DownloadData; exports.DownloadInitiator = DownloadInitiator; exports.ElectronDownloadManager = ElectronDownloadManager; exports.ElectronDownloadManagerMock = ElectronDownloadManagerMock; exports.generateRandomId = generateRandomId; exports.getFilenameFromMime = getFilenameFromMime; exports.truncateUrl = truncateUrl;