electron-dl-manager
Version:
A library for implementing file downloads in Electron with 'save as' dialog and id support.
738 lines (731 loc) • 24 kB
JavaScript
import crypto from "node:crypto";
import * as path$1 from "node:path";
import path from "node:path";
import { app } from "electron";
import extName from "ext-name";
import UnusedFilename from "unused-filename";
import { copyFileSync, renameSync } from "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 = crypto.createHash("sha256");
hash.update(combinedValue);
return hash.digest("hex").substring(0, 6);
}
function getFilenameFromMime(name, mime) {
const extensions = extName.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 && !path.isAbsolute(directory)) throw new Error("The `directory` option must be an absolute path");
directory = directory || app?.getPath("downloads");
let filePath;
if (saveAsFilename) filePath = path.join(directory, saveAsFilename);
else {
const filename = item.getFilename();
const name = path.extname(filename) ? filename : getFilenameFromMime(filename, item.getMimeType());
filePath = overwrite ? path.join(directory, name) : UnusedFilename.sync(path.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 = path$1.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 = path$1.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 = path$1.join(path$1.dirname(originalPath), this.getPersistDownloadFilename());
this.log(`Persisting download to ${persistPath}`);
try {
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 {
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
export { CallbackDispatcher, DownloadData, DownloadInitiator, ElectronDownloadManager, ElectronDownloadManagerMock, generateRandomId, getFilenameFromMime, truncateUrl };