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
JavaScript
//#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;