├── src ├── vendor.d.ts ├── __mocks__ │ ├── utils.ts │ ├── CallbackDispatcher.ts │ ├── DownloadInitiator.ts │ └── DownloadData.ts ├── index.ts ├── ElectronDownloadManagerMock.ts ├── CallbackDispatcher.ts ├── utils.ts ├── DownloadData.ts ├── types.ts ├── ElectronDownloadManager.ts └── DownloadInitiator.ts ├── .npmignore ├── tsdown.config.ts ├── commitlint.config.js ├── vitest.config.ts ├── lefthook.yml ├── syncpack.config.js ├── tsconfig.json ├── __mocks__ └── electron.js ├── .github └── workflows │ ├── test.yml │ └── lint.yml ├── biome.json ├── LICENSE ├── .gitignore ├── package.json ├── CHANGELOG.md ├── test ├── utils.test.ts ├── ElectronDownloadManager.test.ts └── DownloadInitiator.test.ts └── README.md /src/vendor.d.ts: -------------------------------------------------------------------------------- 1 | declare module "ext-name" { 2 | export function mime(mime: string): { ext: string }[]; 3 | } 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | biome.json 2 | vitest.config.ts 3 | test 4 | config 5 | __mocks__ 6 | .github 7 | test-app 8 | pnpm-lock.yaml 9 | -------------------------------------------------------------------------------- /src/__mocks__/utils.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | export const truncateUrl = vi.fn(); 4 | export const getFilenameFromMime = vi.fn(); 5 | export const generateRandomId = vi.fn(); 6 | export const determineFilePath = vi.fn(); 7 | -------------------------------------------------------------------------------- /tsdown.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from "tsdown"; 2 | 3 | export default defineConfig({ 4 | entry: ["src/index.ts"], 5 | outDir: "dist", 6 | format: ["esm", "cjs"], 7 | splitting: false, 8 | sourcemap: false, 9 | clean: true, 10 | target: "es2022", 11 | minify: false, 12 | dts: true, 13 | }); 14 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./CallbackDispatcher"; 2 | export * from "./DownloadData"; 3 | export * from "./DownloadInitiator"; 4 | export * from "./ElectronDownloadManager"; 5 | export * from "./ElectronDownloadManagerMock"; 6 | export * from "./types"; 7 | export { generateRandomId, getFilenameFromMime, truncateUrl } from "./utils"; 8 | -------------------------------------------------------------------------------- /src/__mocks__/CallbackDispatcher.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | 3 | export const CallbackDispatcher = vi.fn().mockImplementation(() => { 4 | return { 5 | onDownloadStarted: vi.fn(), 6 | onDownloadCompleted: vi.fn(), 7 | onDownloadCancelled: vi.fn(), 8 | onDownloadProgress: vi.fn(), 9 | onDownloadInterrupted: vi.fn(), 10 | handleError: vi.fn(), 11 | }; 12 | }); 13 | -------------------------------------------------------------------------------- /commitlint.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['@commitlint/config-conventional'], 3 | rules: { 4 | 'type-enum': [ 5 | 2, 6 | 'always', 7 | [ 8 | 'feat', 9 | 'fix', 10 | 'docs', 11 | 'chore', 12 | 'style', 13 | 'refactor', 14 | 'ci', 15 | 'test', 16 | 'revert', 17 | 'perf' 18 | ], 19 | ], 20 | }, 21 | }; 22 | -------------------------------------------------------------------------------- /vitest.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'vitest/config' 2 | import path from 'path' 3 | 4 | export default defineConfig({ 5 | test: { 6 | globals: true, 7 | environment: 'node', 8 | setupFiles: [], 9 | include: ['test/**/*.{test,spec}.{js,ts}'], 10 | exclude: ['node_modules', 'dist'] 11 | }, 12 | resolve: { 13 | alias: { 14 | electron: path.resolve(__dirname, '__mocks__/electron.js') 15 | } 16 | } 17 | }) 18 | -------------------------------------------------------------------------------- /lefthook.yml: -------------------------------------------------------------------------------- 1 | pre-commit: 2 | parallel: true 3 | commands: 4 | "lint and format staged files": 5 | run: pnpm run lint 6 | stage_fixed: true 7 | "check package.json files": 8 | run: pnpm run syncpack:format && pnpm run lint:packages 9 | stage_fixed: true 10 | "[repo] verify types": 11 | run: pnpm run verify-types 12 | 13 | commit-msg: 14 | commands: 15 | "lint commit message": 16 | run: pnpm run commitlint --edit {1} -------------------------------------------------------------------------------- /syncpack.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "semverRange": "exact", 3 | "sortFirst": ["name", "description", "version", "type", "private", "main", "module", "exports", "types", "license", "repository", "author", "keywords", "scripts", "dependencies", "devDependencies", "peerDependencies", "resolutions"], 4 | "sortAz": [], 5 | "semverGroups": [{ 6 | "range": "", 7 | "dependencyTypes": ["prod", "dev", "resolutions", "overrides"], 8 | "dependencies": ["**"], 9 | "packages": ["**"] 10 | }] 11 | } 12 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2022", 4 | "lib": ["ES2022"], 5 | "sourceMap": true, 6 | "strict": true, 7 | "noImplicitAny": false, 8 | "strictNullChecks": false, 9 | "module": "NodeNext", 10 | "moduleResolution": "NodeNext", 11 | "isolatedModules": true, 12 | "skipLibCheck": true, 13 | "esModuleInterop": false, 14 | "allowSyntheticDefaultImports": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "resolveJsonModule": true, 17 | }, 18 | "include": ["src/**/*.ts", "src/**/*.json"], 19 | "exclude": ["node_modules", "dist"] 20 | } -------------------------------------------------------------------------------- /__mocks__/electron.js: -------------------------------------------------------------------------------- 1 | import { vi } from 'vitest' 2 | 3 | export default { 4 | app: { 5 | getPath: vi.fn().mockReturnValue('/default/path'), 6 | }, 7 | BrowserWindow: vi.fn().mockImplementation(() => ({ 8 | webContents: { 9 | downloadURL: vi.fn(), 10 | session: { 11 | once: vi.fn(), 12 | on: vi.fn(), 13 | off: vi.fn(), 14 | addListener: vi.fn(), 15 | removeListener: vi.fn(), 16 | }, 17 | debugger: { 18 | attach: vi.fn(), 19 | sendCommand: vi.fn(), 20 | detach: vi.fn(), 21 | on: vi.fn(), 22 | off: vi.fn(), 23 | once: vi.fn(), 24 | addListener: vi.fn(), 25 | removeListener: vi.fn(), 26 | }, 27 | }, 28 | })), 29 | } 30 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - reopened 8 | - synchronize 9 | paths: 10 | - 'src/**' 11 | - 'test/**' 12 | 13 | jobs: 14 | test: 15 | name: Testing 16 | runs-on: ubuntu-latest 17 | env: 18 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 19 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 20 | 21 | steps: 22 | - name: Checkout Repository 23 | uses: actions/checkout@v4 24 | with: 25 | fetch-depth: 2 26 | 27 | - name: Use pnpm 28 | uses: pnpm/action-setup@v4 29 | 30 | - uses: actions/setup-node@v4 31 | with: 32 | node-version: 20 33 | cache: 'pnpm' 34 | 35 | - name: Install Dependencies 36 | run: pnpm install --frozen-lockfile 37 | 38 | - name: Build 39 | run: pnpm run build 40 | 41 | - name: Run tests 42 | run: pnpm run test -------------------------------------------------------------------------------- /src/__mocks__/DownloadInitiator.ts: -------------------------------------------------------------------------------- 1 | import { vi } from "vitest"; 2 | import { CallbackDispatcher } from "./CallbackDispatcher"; 3 | import { DownloadData } from "./DownloadData"; 4 | 5 | export const DownloadInitiator = vi.fn().mockImplementation((config) => { 6 | const initator = { 7 | logger: vi.fn(), 8 | onItemUpdated: vi.fn(), 9 | onItemDone: vi.fn(), 10 | onDownloadInit: vi.fn(), 11 | onCleanup: vi.fn(), 12 | callbackDispatcher: new CallbackDispatcher(), 13 | downloadData: new DownloadData(), 14 | config: {}, 15 | log: vi.fn(), 16 | getDownloadId: vi.fn(), 17 | getDownloadData: vi.fn(), 18 | generateOnWillDownload: vi.fn(() => async () => { 19 | config.onDownloadInit(new DownloadData()); 20 | }), 21 | initSaveAsInteractiveDownload: vi.fn(), 22 | initNonInteractiveDownload: vi.fn(), 23 | generateItemOnUpdated: vi.fn(), 24 | generateItemOnDone: vi.fn(), 25 | cleanup: vi.fn(), 26 | updateProgress: vi.fn(), 27 | }; 28 | 29 | return initator; 30 | }); 31 | -------------------------------------------------------------------------------- /biome.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://biomejs.dev/schemas/2.2.0/schema.json", 3 | "assist": { "actions": { "source": { "organizeImports": "on" } } }, 4 | "formatter": { 5 | "enabled": true, 6 | "indentStyle": "space", 7 | "lineWidth": 120 8 | }, 9 | "linter": { 10 | "enabled": true, 11 | "rules": { 12 | "complexity": { 13 | "useLiteralKeys": "off" 14 | }, 15 | "suspicious": { 16 | "noImplicitAnyLet": "off", 17 | "noExplicitAny": "off" 18 | }, 19 | "recommended": true, 20 | "style": { 21 | "noParameterAssign": "error", 22 | "useAsConstAssertion": "error", 23 | "useDefaultParameterLast": "error", 24 | "useEnumInitializers": "error", 25 | "useSelfClosingElements": "error", 26 | "useSingleVarDeclarator": "error", 27 | "noUnusedTemplateLiteral": "error", 28 | "useNumberNamespace": "error", 29 | "noInferrableTypes": "error", 30 | "noUselessElse": "error" 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | types: 6 | - opened 7 | - reopened 8 | - synchronize 9 | paths: 10 | - 'src/**' 11 | - 'test/**' 12 | 13 | jobs: 14 | lint: 15 | name: Linting 16 | runs-on: ubuntu-latest 17 | env: 18 | TURBO_TOKEN: ${{ secrets.TURBO_TOKEN }} 19 | TURBO_TEAM: ${{ vars.TURBO_TEAM }} 20 | 21 | steps: 22 | - name: Checkout Repository 23 | uses: actions/checkout@v4 24 | 25 | - name: Use pnpm 26 | uses: pnpm/action-setup@v4 27 | 28 | - uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | cache: 'pnpm' 32 | 33 | - name: Install Dependencies 34 | run: pnpm install --frozen-lockfile 35 | 36 | - name: Build 37 | run: pnpm run build 38 | 39 | - name: Run package checking 40 | run: pnpm run lint:packages 41 | 42 | - name: Run type checking 43 | run: pnpm run verify-types 44 | 45 | - name: Run linting 46 | run: pnpm run lint -------------------------------------------------------------------------------- /src/ElectronDownloadManagerMock.ts: -------------------------------------------------------------------------------- 1 | import { DownloadData } from "./DownloadData"; 2 | import type { DownloadConfig, IElectronDownloadManager, RestoreDownloadConfig } from "./types"; 3 | 4 | /** 5 | * Mock version of ElectronDownloadManager 6 | * that can be used for testing purposes 7 | */ 8 | export class ElectronDownloadManagerMock implements IElectronDownloadManager { 9 | async download(_params: DownloadConfig): Promise { 10 | return "mock-download-id"; 11 | } 12 | 13 | cancelDownload(_id: string): void {} 14 | 15 | pauseDownload(_id: string): import("./DownloadData").RestoreDownloadData | undefined { 16 | return undefined; 17 | } 18 | 19 | resumeDownload(_id: string): void {} 20 | 21 | getActiveDownloadCount(): number { 22 | return 0; 23 | } 24 | 25 | getDownloadData(id: string) { 26 | const downloadData = new DownloadData(); 27 | downloadData.id = id; 28 | return downloadData; 29 | } 30 | 31 | async restoreDownload(_params: RestoreDownloadConfig): Promise { 32 | return "mock-restored-download-id"; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Theo Gravity 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /src/__mocks__/DownloadData.ts: -------------------------------------------------------------------------------- 1 | import { EventEmitter } from "node:events"; 2 | import type { DownloadItem, WebContents } from "electron"; 3 | import { vi } from "vitest"; 4 | import { generateRandomId } from "../index"; 5 | 6 | export const DownloadData = vi.fn().mockImplementation(() => { 7 | return createMockDownloadData().downloadData; 8 | }); 9 | 10 | export function createMockDownloadData() { 11 | const itemEmitter = new EventEmitter(); 12 | 13 | const item: any = { 14 | setSaveDialogOptions: vi.fn(), 15 | setSavePath: vi.fn(), 16 | getSavePath: vi.fn().mockReturnValue("/path/to/save"), 17 | getReceivedBytes: vi.fn().mockReturnValue(900), 18 | getTotalBytes: vi.fn().mockReturnValue(1000), 19 | getCurrentBytesPerSecond: vi.fn(), 20 | getPercentComplete: vi.fn(), 21 | getStartTime: vi.fn(), 22 | cancel: vi.fn(), 23 | pause: vi.fn(), 24 | resume: vi.fn(), 25 | isPaused: vi.fn(), 26 | getState: vi.fn(), 27 | getFilename: vi.fn().mockReturnValue("filename.txt"), 28 | on: itemEmitter.on.bind(itemEmitter) as DownloadItem["on"], 29 | once: itemEmitter.once.bind(itemEmitter) as DownloadItem["once"], 30 | off: itemEmitter.off.bind(itemEmitter) as DownloadItem["off"], 31 | }; 32 | 33 | const downloadData: any = { 34 | id: generateRandomId(), 35 | cancelledFromSaveAsDialog: false, 36 | percentCompleted: 0, 37 | downloadRateBytesPerSecond: 0, 38 | estimatedTimeRemainingSeconds: 0, 39 | resolvedFilename: `${generateRandomId()}.txt`, 40 | webContents: {} as WebContents, 41 | event: {} as Event, 42 | isDownloadInProgress: vi.fn(), 43 | isDownloadCompleted: vi.fn(), 44 | isDownloadCancelled: vi.fn(), 45 | isDownloadInterrupted: vi.fn(), 46 | isDownloadResumable: vi.fn(), 47 | isDownloadPaused: vi.fn(), 48 | getRestoreDownloadData: vi.fn(), 49 | item, 50 | }; 51 | 52 | return { 53 | downloadData, 54 | item, 55 | itemEmitter, 56 | }; 57 | } 58 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | 2 | # Logs 3 | logs 4 | *.log 5 | pnpm-debug.log* 6 | yarn-debug.log* 7 | yarn-error.log* 8 | lerna-debug.log* 9 | 10 | # Diagnostic reports (https://nodejs.org/api/report.html) 11 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 12 | 13 | # Runtime data 14 | pids 15 | *.pid 16 | *.seed 17 | *.pid.lock 18 | 19 | # Directory for instrumented libs generated by jscoverage/JSCover 20 | lib-cov 21 | 22 | # Coverage directory used by tools like istanbul 23 | coverage 24 | *.lcov 25 | 26 | # nyc test coverage 27 | .nyc_output 28 | 29 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 30 | .grunt 31 | 32 | # Bower dependency directory (https://bower.io/) 33 | bower_components 34 | 35 | # node-waf configuration 36 | .lock-wscript 37 | 38 | # Compiled binary addons (https://nodejs.org/api/addons.html) 39 | build/Release 40 | 41 | # Dependency directories 42 | node_modules/ 43 | jspm_packages/ 44 | 45 | # Snowpack dependency directory (https://snowpack.dev/) 46 | web_modules/ 47 | 48 | # TypeScript cache 49 | *.tsbuildinfo 50 | 51 | # Optional pnpm cache directory 52 | .pnpm-store 53 | 54 | # Optional eslint cache 55 | .eslintcache 56 | 57 | # Microbundle cache 58 | .rpt2_cache/ 59 | .rts2_cache_cjs/ 60 | .rts2_cache_es/ 61 | .rts2_cache_umd/ 62 | 63 | # Optional REPL history 64 | .node_repl_history 65 | 66 | # Output of 'pnpm pack' 67 | *.tgz 68 | 69 | # Yarn Integrity file 70 | .yarn-integrity 71 | 72 | # dotenv environment variables file 73 | .env 74 | .env.test 75 | 76 | # parcel-bundler cache (https://parceljs.org/) 77 | .cache 78 | .parcel-cache 79 | 80 | # Next.js build output 81 | .next 82 | out 83 | 84 | # Nuxt.js build / generate output 85 | .nuxt 86 | dist 87 | 88 | # Gatsby files 89 | .cache/ 90 | # Comment in the public line in if your project uses Gatsby and not Next.js 91 | # https://nextjs.org/blog/next-9-1#public-directory-support 92 | # public 93 | 94 | # vuepress build output 95 | .vuepress/dist 96 | 97 | # Serverless directories 98 | .serverless/ 99 | 100 | # FuseBox cache 101 | .fusebox/ 102 | 103 | # DynamoDB Local files 104 | .dynamodb/ 105 | 106 | # TernJS port file 107 | .tern-port 108 | 109 | # Stores VSCode versions used for testing VSCode extensions 110 | .vscode-test 111 | 112 | # yarn v2 113 | .yarn/cache 114 | .yarn/unplugged 115 | .yarn/build-state.yml 116 | .yarn/install-state.gz 117 | .pnp.* 118 | .idea/ 119 | yarn.lock 120 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "electron-dl-manager", 3 | "description": "A library for implementing file downloads in Electron with 'save as' dialog and id support.", 4 | "version": "4.2.3", 5 | "main": "dist/index.js", 6 | "module": "dist/index.mjs", 7 | "exports": { 8 | "import": { 9 | "types": "./dist/index.d.mts", 10 | "import": "./dist/index.mjs" 11 | }, 12 | "require": { 13 | "types": "./dist/index.d.ts", 14 | "require": "./dist/index.js" 15 | } 16 | }, 17 | "types": "dist/index.d.ts", 18 | "license": "MIT", 19 | "repository": "git@github.com:theogravity/electron-dl-manager.git", 20 | "author": "Theo Gravity ", 21 | "keywords": [ 22 | "app", 23 | "download", 24 | "downloader", 25 | "electron", 26 | "electron-dl", 27 | "file", 28 | "library", 29 | "manager", 30 | "multi", 31 | "progress" 32 | ], 33 | "scripts": { 34 | "build": "tsdown", 35 | "clean": "rm -rf dist", 36 | "commitlint": "commitlint", 37 | "format": "pnpm exec @biomejs/biome format src --write && pnpm exec @biomejs/biome format test --write", 38 | "lint": "biome check --no-errors-on-unmatched --write --unsafe src", 39 | "prepublishOnly": "pnpm run test && pnpm run lint && pnpm run build", 40 | "lint:packages": "pnpm run lint:packages:semver && pnpm run lint:packages:mismatches", 41 | "lint:packages:semver": "syncpack lint-semver-ranges", 42 | "lint:packages:mismatches": "syncpack list-mismatches", 43 | "syncpack:format": "syncpack format", 44 | "syncpack:lint": "syncpack lint", 45 | "syncpack:update": "syncpack update && syncpack fix-mismatches && pnpm i", 46 | "test": "vitest run", 47 | "test:watch": "vitest", 48 | "verify-types": "tsc --noEmit" 49 | }, 50 | "dependencies": { 51 | "ext-name": "5.0.0", 52 | "unused-filename": "3.0.1" 53 | }, 54 | "devDependencies": { 55 | "@biomejs/biome": "2.2.0", 56 | "@commitlint/cli": "19.8.1", 57 | "@commitlint/config-conventional": "19.8.1", 58 | "electron": "30.3.0", 59 | "lefthook": "1.12.3", 60 | "syncpack": "13.0.4", 61 | "tsdown": "0.12.5", 62 | "typescript": "5.9.2", 63 | "vitest": "3.2.4" 64 | }, 65 | "peerDependencies": { 66 | "electron": ">=30.3.0" 67 | }, 68 | "bugs": "https://github.com/theogravity/electron-dl-manager/issues", 69 | "files": [ 70 | "dist" 71 | ], 72 | "homepage": "https://github.com/theogravity/electron-dl-manager", 73 | "packageManager": "pnpm@10.14.0", 74 | "publishConfig": { 75 | "access": "public" 76 | } 77 | } 78 | -------------------------------------------------------------------------------- /src/CallbackDispatcher.ts: -------------------------------------------------------------------------------- 1 | import type { DownloadData } from "./DownloadData"; 2 | import type { DebugLoggerFn, DownloadManagerCallbacks } from "./types"; 3 | 4 | /** 5 | * Wraps around the callbacks to handle errors and logging 6 | */ 7 | export class CallbackDispatcher { 8 | protected logger: DebugLoggerFn; 9 | callbacks: DownloadManagerCallbacks; 10 | downloadDataId: string; 11 | 12 | constructor(downloadDataId: string, callbacks: DownloadManagerCallbacks, logger: (message: string) => void) { 13 | this.downloadDataId = downloadDataId; 14 | this.callbacks = callbacks; 15 | this.logger = logger; 16 | } 17 | 18 | protected log(message: string) { 19 | this.logger(`[${this.downloadDataId}] ${message}`); 20 | } 21 | 22 | async onDownloadStarted(downloadData: DownloadData) { 23 | const { callbacks } = this; 24 | 25 | if (callbacks.onDownloadStarted) { 26 | this.log("Calling onDownloadStarted"); 27 | try { 28 | await callbacks.onDownloadStarted(downloadData); 29 | } catch (e) { 30 | this.log(`Error during onDownloadStarted: ${e}`); 31 | this.handleError(e as Error); 32 | } 33 | } 34 | } 35 | 36 | async onDownloadCompleted(downloadData: DownloadData) { 37 | const { callbacks } = this; 38 | if (callbacks.onDownloadCompleted) { 39 | this.log("Calling onDownloadCompleted"); 40 | 41 | try { 42 | await callbacks.onDownloadCompleted(downloadData); 43 | } catch (e) { 44 | this.log(`Error during onDownloadCompleted: ${e}`); 45 | this.handleError(e as Error); 46 | } 47 | } 48 | } 49 | 50 | async onDownloadProgress(downloadData: DownloadData) { 51 | const { callbacks } = this; 52 | 53 | if (callbacks.onDownloadProgress) { 54 | try { 55 | await callbacks.onDownloadProgress(downloadData); 56 | } catch (e) { 57 | this.log(`Error during onDownloadProgress: ${e}`); 58 | this.handleError(e as Error); 59 | } 60 | } 61 | } 62 | 63 | async onDownloadCancelled(downloadData: DownloadData) { 64 | const { callbacks } = this; 65 | 66 | if (callbacks.onDownloadCancelled) { 67 | this.log("Calling onDownloadCancelled"); 68 | 69 | try { 70 | await callbacks.onDownloadCancelled(downloadData); 71 | } catch (e) { 72 | this.log(`Error during onDownloadCancelled: ${e}`); 73 | this.handleError(e as Error); 74 | } 75 | } 76 | } 77 | 78 | async onDownloadInterrupted(downloadData: DownloadData) { 79 | const { callbacks } = this; 80 | 81 | if (callbacks.onDownloadInterrupted) { 82 | this.log("Calling onDownloadInterrupted"); 83 | try { 84 | await callbacks.onDownloadInterrupted(downloadData); 85 | } catch (e) { 86 | this.log(`Error during onDownloadInterrupted: ${e}`); 87 | this.handleError(e as Error); 88 | } 89 | } 90 | } 91 | 92 | async onDownloadPersisted(downloadData: DownloadData) { 93 | const { callbacks } = this; 94 | 95 | if (callbacks.onDownloadPersisted) { 96 | this.log("Calling onDownloadPersisted"); 97 | 98 | try { 99 | await callbacks.onDownloadPersisted(downloadData, downloadData.getRestoreDownloadData()); 100 | } catch (e) { 101 | this.log(`Error during onDownloadPersisted: ${e}`); 102 | this.handleError(e as Error, downloadData); 103 | } 104 | } 105 | } 106 | 107 | handleError(error: Error, downloadData?: DownloadData) { 108 | const { callbacks } = this; 109 | 110 | if (callbacks.onError) { 111 | callbacks.onError(error, downloadData); 112 | } 113 | } 114 | } 115 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import crypto from "node:crypto"; 2 | import path from "node:path"; 3 | import { app, type DownloadItem } from "electron"; 4 | import extName from "ext-name"; 5 | import UnusedFilename from "unused-filename"; 6 | 7 | export function truncateUrl(url: string) { 8 | if (url.length > 50) { 9 | return `${url.slice(0, 50)}...`; 10 | } 11 | return url; 12 | } 13 | 14 | export function generateRandomId() { 15 | const currentTime = Date.now(); 16 | const randomNum = Math.floor(Math.random() * 1000); 17 | const combinedValue = currentTime.toString() + randomNum.toString(); 18 | 19 | const hash = crypto.createHash("sha256"); 20 | hash.update(combinedValue); 21 | 22 | return hash.digest("hex").substring(0, 6); 23 | } 24 | 25 | // Copied from https://github.com/sindresorhus/electron-dl/blob/main/index.js#L10 26 | export function getFilenameFromMime(name: string, mime: string) { 27 | const extensions = extName.mime(mime); 28 | 29 | if (extensions.length !== 1) { 30 | return name; 31 | } 32 | 33 | return `${name}.${extensions[0].ext}`; 34 | } 35 | 36 | /** 37 | * Determines the initial file path for the download. 38 | */ 39 | export function determineFilePath({ 40 | directory, 41 | saveAsFilename, 42 | item, 43 | overwrite, 44 | }: { 45 | directory?: string; 46 | saveAsFilename?: string; 47 | item: DownloadItem; 48 | overwrite?: boolean; 49 | }) { 50 | // Code adapted from https://github.com/sindresorhus/electron-dl/blob/main/index.js#L73 51 | if (directory && !path.isAbsolute(directory)) { 52 | throw new Error("The `directory` option must be an absolute path"); 53 | } 54 | 55 | directory = directory || app?.getPath("downloads"); 56 | 57 | let filePath: string; 58 | 59 | if (saveAsFilename) { 60 | filePath = path.join(directory, saveAsFilename); 61 | } else { 62 | const filename = item.getFilename(); 63 | const name = path.extname(filename) ? filename : getFilenameFromMime(filename, item.getMimeType()); 64 | 65 | filePath = overwrite ? path.join(directory, name) : UnusedFilename.sync(path.join(directory, name)); 66 | } 67 | 68 | return filePath; 69 | } 70 | 71 | /** 72 | * Calculates the download rate and estimated time remaining for a download. 73 | * @returns {object} An object containing the download rate in bytes per second and the estimated time remaining in seconds. 74 | */ 75 | export function calculateDownloadMetrics(item: DownloadItem): { 76 | percentCompleted: number; 77 | downloadRateBytesPerSecond: number; 78 | estimatedTimeRemainingSeconds: number; 79 | } { 80 | const downloadedBytes = item.getReceivedBytes(); 81 | const totalBytes = item.getTotalBytes(); 82 | const startTimeSecs = item.getStartTime(); 83 | 84 | const currentTimeSecs = Math.floor(Date.now() / 1000); 85 | const elapsedTimeSecs = currentTimeSecs - startTimeSecs; 86 | 87 | // Avail in Electron 30.3.0+ 88 | let downloadRateBytesPerSecond = item.getCurrentBytesPerSecond ? item.getCurrentBytesPerSecond() : 0; 89 | let estimatedTimeRemainingSeconds = 0; 90 | 91 | if (elapsedTimeSecs > 0) { 92 | if (!downloadRateBytesPerSecond) { 93 | downloadRateBytesPerSecond = downloadedBytes / elapsedTimeSecs; 94 | } 95 | 96 | if (downloadRateBytesPerSecond > 0) { 97 | estimatedTimeRemainingSeconds = (totalBytes - downloadedBytes) / downloadRateBytesPerSecond; 98 | } 99 | } 100 | 101 | let percentCompleted = 0; 102 | 103 | // Avail in Electron 30.3.0+ 104 | if (item.getPercentComplete) { 105 | percentCompleted = item.getPercentComplete(); 106 | } else { 107 | percentCompleted = 108 | totalBytes > 0 ? Math.min(Number.parseFloat(((downloadedBytes / totalBytes) * 100).toFixed(2)), 100) : 0; 109 | } 110 | 111 | return { 112 | percentCompleted, 113 | downloadRateBytesPerSecond, 114 | estimatedTimeRemainingSeconds, 115 | }; 116 | } 117 | -------------------------------------------------------------------------------- /src/DownloadData.ts: -------------------------------------------------------------------------------- 1 | import type { DownloadItem, Event, WebContents } from "electron"; 2 | import { generateRandomId } from "./utils"; 3 | 4 | export interface RestoreDownloadData { 5 | /** 6 | * Download id 7 | */ 8 | id: string; 9 | url: string; 10 | /** 11 | * The path and filename where the download will be saved. 12 | */ 13 | fileSaveAsPath: string; 14 | urlChain: string[]; 15 | mimeType: string; 16 | /** 17 | * The ETag of the download, if available. 18 | * This is used to resume downloads. 19 | */ 20 | eTag: string; 21 | receivedBytes: number; 22 | totalBytes: number; 23 | startTime: number; 24 | percentCompleted: number; 25 | /** 26 | * If persistOnAppClose is true, this is the path where the download 27 | * is persisted to. This is used to restore the download later. 28 | */ 29 | persistedFilePath?: string; 30 | } 31 | 32 | export interface DownloadDataConfig { 33 | /** 34 | * Download id 35 | */ 36 | id?: string; 37 | } 38 | 39 | /** 40 | * Contains the data for a download. 41 | */ 42 | export class DownloadData { 43 | /** 44 | * Generated id for the download 45 | */ 46 | id: string; 47 | /** 48 | * The Electron.DownloadItem. Use this to grab the filename, path, etc. 49 | * @see https://www.electronjs.org/docs/latest/api/download-item 50 | */ 51 | item: DownloadItem; 52 | /** 53 | * The Electron.WebContents 54 | * @see https://www.electronjs.org/docs/latest/api/web-contents 55 | */ 56 | webContents: WebContents; 57 | /** 58 | * The Electron.Event 59 | * @see https://www.electronjs.org/docs/latest/api/event 60 | */ 61 | event: Event; 62 | /** 63 | * The name of the file that is being saved to the user's computer. 64 | * Recommended over Item.getFilename() as it may be inaccurate when using the save as dialog. 65 | */ 66 | resolvedFilename: string; 67 | /** 68 | * If true, the download was cancelled from the save as dialog. This flag 69 | * will also be true if the download was cancelled by the application when 70 | * using the save as dialog. 71 | */ 72 | cancelledFromSaveAsDialog?: boolean; 73 | /** 74 | * The percentage of the download that has been completed 75 | */ 76 | percentCompleted: number; 77 | /** 78 | * The download rate in bytes per second. 79 | */ 80 | downloadRateBytesPerSecond: number; 81 | /** 82 | * The estimated time remaining in seconds. 83 | */ 84 | estimatedTimeRemainingSeconds: number; 85 | /** 86 | * If the download was interrupted, the state in which it was interrupted from 87 | */ 88 | interruptedVia?: "in-progress" | "completed"; 89 | /** 90 | * If defined, this is the path where the download is persisted to. 91 | */ 92 | persistedFilePath?: string; 93 | 94 | constructor(config: DownloadDataConfig = {}) { 95 | this.id = config.id || generateRandomId(); 96 | this.resolvedFilename = "testFile.txt"; 97 | this.percentCompleted = 0; 98 | this.cancelledFromSaveAsDialog = false; 99 | this.item = {} as DownloadItem; 100 | this.webContents = {} as WebContents; 101 | this.event = {} as Event; 102 | this.downloadRateBytesPerSecond = 0; 103 | this.estimatedTimeRemainingSeconds = 0; 104 | } 105 | 106 | /** 107 | * Returns data necessary for restoring a download 108 | */ 109 | getRestoreDownloadData(): RestoreDownloadData { 110 | return { 111 | id: this.id, 112 | fileSaveAsPath: this.item.getSavePath(), 113 | url: this.item.getURL(), 114 | urlChain: this.item.getURLChain(), 115 | eTag: this.item.getETag(), 116 | totalBytes: this.item.getTotalBytes(), 117 | mimeType: this.item.getMimeType(), 118 | receivedBytes: this.item.getReceivedBytes(), 119 | startTime: this.item.getStartTime(), 120 | percentCompleted: this.percentCompleted, 121 | persistedFilePath: this.persistedFilePath, 122 | }; 123 | } 124 | 125 | isDownloadInProgress() { 126 | return this.item.getState() === "progressing"; 127 | } 128 | 129 | isDownloadCompleted() { 130 | return this.item.getState() === "completed"; 131 | } 132 | 133 | isDownloadCancelled() { 134 | return this.item.getState() === "cancelled"; 135 | } 136 | 137 | isDownloadInterrupted() { 138 | return this.item.getState() === "interrupted"; 139 | } 140 | 141 | isDownloadResumable() { 142 | return this.item.canResume(); 143 | } 144 | 145 | isDownloadPaused() { 146 | return this.item.isPaused(); 147 | } 148 | } 149 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | import type { BrowserWindow, SaveDialogOptions } from "electron"; 2 | import type { DownloadData, RestoreDownloadData } from "./DownloadData"; 3 | 4 | /** 5 | * The download has started 6 | */ 7 | export type DownloadStartedFn = (data: DownloadData) => Promise | void; 8 | /** 9 | * There is progress on the download 10 | */ 11 | export type DownloadProgressFn = (data: DownloadData) => Promise | void; 12 | /** 13 | * The download has been cancelled 14 | */ 15 | export type DownloadCancelledFn = (data: DownloadData) => Promise | void; 16 | /** 17 | * The download has completed 18 | */ 19 | export type DownloadCompletedFn = (data: DownloadData) => Promise | void; 20 | /** 21 | * The download was interrupted 22 | */ 23 | export type DownloadInterruptedFn = (data: DownloadData) => Promise | void; 24 | /** 25 | * The download has been persisted for later restoration 26 | */ 27 | export type DownloadPersistedFn = ( 28 | data: DownloadData, 29 | restoreDownloadData: RestoreDownloadData, 30 | ) => Promise | void; 31 | /** 32 | * The download has failed 33 | */ 34 | export type ErrorFn = (error: Error, data?: DownloadData) => Promise | void; 35 | 36 | /** 37 | * Function for logging internal debug messages 38 | */ 39 | export type DebugLoggerFn = (message: string) => void; 40 | 41 | export interface DownloadManagerConstructorParams { 42 | /** 43 | * If defined, will log out internal debug messages. Useful for 44 | * troubleshooting downloads. Does not log out progress due to 45 | * how frequent it can be. 46 | */ 47 | debugLogger?: DebugLoggerFn; 48 | } 49 | 50 | export interface DownloadManagerCallbacks { 51 | /** 52 | * When the download has started. When using a "save as" dialog, 53 | * this will be called after the user has selected a location. 54 | * 55 | * This will always be called first before the progress and completed events. 56 | */ 57 | onDownloadStarted?: DownloadStartedFn; 58 | /** 59 | * When there is a progress update on a download. Note: This 60 | * may be skipped entirely in some cases, where the download 61 | * completes immediately. In that case, onDownloadCompleted 62 | * will be called instead. 63 | */ 64 | onDownloadProgress?: DownloadProgressFn; 65 | /** 66 | * When the download has completed 67 | */ 68 | onDownloadCompleted?: DownloadCompletedFn; 69 | /** 70 | * When the download has been cancelled. Also called if the user cancels 71 | * from the save as dialog. 72 | */ 73 | onDownloadCancelled?: DownloadCancelledFn; 74 | /** 75 | * When the download has been interrupted. This could be due to a bad 76 | * connection, the server going down, etc. 77 | */ 78 | onDownloadInterrupted?: DownloadInterruptedFn; 79 | /** 80 | * When the download has been persisted for later restoration. 81 | */ 82 | onDownloadPersisted?: DownloadPersistedFn; 83 | /** 84 | * When an error has been encountered. 85 | * Note: The signature is (error, ). 86 | */ 87 | onError?: ErrorFn; 88 | } 89 | 90 | export interface RestoreDownloadConfig { 91 | /** 92 | * The Electron.App instance 93 | */ 94 | app: Electron.App; 95 | /** 96 | * The Electron.BrowserWindow instance 97 | */ 98 | window: BrowserWindow; 99 | /** 100 | * Data required for resuming the download 101 | */ 102 | restoreData: RestoreDownloadData; 103 | /** 104 | * The callbacks to define to listen for download events 105 | */ 106 | callbacks: DownloadManagerCallbacks; 107 | /** 108 | * Electron.DownloadURLOptions to pass to the downloadURL method 109 | * 110 | * @see https://www.electronjs.org/docs/latest/api/session#sesdownloadurlurl-options 111 | */ 112 | downloadURLOptions?: Electron.DownloadURLOptions; 113 | } 114 | 115 | export interface DownloadConfig { 116 | /** 117 | * The Electron.App instance. Required if persistOnAppClose is enabled. 118 | */ 119 | app?: Electron.App; 120 | /** 121 | * The Electron.BrowserWindow instance 122 | */ 123 | window: BrowserWindow; 124 | /** 125 | * The URL to download 126 | */ 127 | url: string; 128 | /** 129 | * The callbacks to define to listen for download events 130 | */ 131 | callbacks: DownloadManagerCallbacks; 132 | /** 133 | * Electron.DownloadURLOptions to pass to the downloadURL method 134 | * 135 | * @see https://www.electronjs.org/docs/latest/api/session#sesdownloadurlurl-options 136 | */ 137 | downloadURLOptions?: Electron.DownloadURLOptions; 138 | /** 139 | * If defined, will show a save dialog when the user 140 | * downloads a file. 141 | * 142 | * @see https://www.electronjs.org/docs/latest/api/dialog#dialogshowsavedialogbrowserwindow-options 143 | */ 144 | saveDialogOptions?: SaveDialogOptions; 145 | /** 146 | * The filename to save the file as. If not defined, the filename 147 | * from the server will be used. 148 | * 149 | * Only applies if saveDialogOptions is not defined. 150 | */ 151 | saveAsFilename?: string; 152 | /** 153 | * The directory to save the file to. Must be an absolute path. 154 | * @default The user's downloads directory 155 | */ 156 | directory?: string; 157 | /** 158 | * If true, will overwrite the file if it already exists 159 | * @default false 160 | */ 161 | overwrite?: boolean; 162 | /** 163 | * Options for persisting a partial download when the application closes for use with restoring it later. 164 | */ 165 | persistOnAppClose?: boolean; 166 | } 167 | 168 | export interface IElectronDownloadManager { 169 | /** 170 | * Starts a download. If saveDialogOptions has been defined in the config, 171 | * the saveAs dialog will show up first. 172 | * 173 | * Returns the id of the download. 174 | * 175 | * This *must* be called with `await` or unintended behavior may occur. 176 | */ 177 | download(params: DownloadConfig): Promise; 178 | /** 179 | * Cancels a download 180 | */ 181 | cancelDownload(id: string): void; 182 | /** 183 | * Pauses a download and returns the data necessary to restore it later 184 | */ 185 | pauseDownload(id: string): RestoreDownloadData | undefined; 186 | /** 187 | * Resumes a download 188 | */ 189 | resumeDownload(id: string): void; 190 | /** 191 | * Returns the number of active downloads 192 | */ 193 | getActiveDownloadCount(): number; 194 | /** 195 | * Returns the data for a download 196 | */ 197 | getDownloadData(id: string): DownloadData | undefined; 198 | /** 199 | * Restores a download that is not registered in the download manager. 200 | * If it is already registered, calls resumeDownload() instead. 201 | * 202 | * Returns the id of the restored download. 203 | */ 204 | restoreDownload(params: RestoreDownloadConfig): Promise; 205 | } 206 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # 4.2.3 (2025-11-26) 2 | 3 | - Switch build tool from tsup to tsdown, fix cjs references 4 | 5 | # 4.2.2 (2025-11-26) 6 | 7 | - Update the `electron` peer dependency to not be an exact version 8 | 9 | # 4.2.1 (2025-11-25) 10 | 11 | - Fix `debugLogger` logging download progress despite documentation stating it does not log progress due to frequency ([#21](https://github.com/theogravity/electron-dl-manager/issues/21)) 12 | 13 | # 4.2.0 (2025-08-17) 14 | 15 | Adds support for persisting downloads when the application closes, allowing them to be restored later. This is useful for long-running downloads that might be interrupted by app restarts. 16 | 17 | **New Features:** 18 | - Added `persistOnAppClose` option to `download()` method 19 | - Added `onDownloadPersisted` callback for when downloads are persisted 20 | - Added `app` parameter to both `download()` and `restoreDownload()` methods 21 | - Enhanced `RestoreDownloadData` interface with `startTime`, `percentCompleted`, and `persistedFilePath` fields 22 | - Downloads are automatically persisted to temporary files with `.download` extension when the app is about to close 23 | 24 | **Breaking Changes:** 25 | - `restoreDownload()` now requires the `app` parameter to be provided 26 | 27 | See README.md for detailed usage examples. 28 | 29 | # 4.1.0 (2025-08-17) 30 | 31 | *Thanks to [@AngeloGiurano](https://github.com/AngeloGiurano) for 32 | contributing the research and initial code for the restoration feature.* 33 | 34 | Adds the ability to *restore* a download that was interrupted, when the browser window is closed, but the app remains running. 35 | 36 | Support for persistent partial downloads (after an app closes) will be added in a future release. 37 | 38 | See README.md for more information. 39 | 40 | # 4.0.0 (2025-04-14) 41 | 42 | **Breaking Changes:** 43 | 44 | - Removes static methods `ElectronDownloadManager.throttleConnections()` and `ElectronDownloadManager.disableThrottle()` as they never 45 | worked as intended. 46 | 47 | # 3.2.1 (2024-10-21) 48 | 49 | - Add an FAQ section to `README.md` on how to handle invalid URLs 50 | 51 | # 3.2.0 (2024-08-04) 52 | 53 | - Fix issues around starting multiple downloads at the same time. 54 | - No longer need to use `await` before a download call when multiple downloads are started at the same time. 55 | 56 | # 3.1.1 (2024-07-31) 57 | 58 | - Added more debug logging 59 | - Added notes to `README.md` that `download()` *must* be called with `await` or weird things happen. 60 | 61 | # 3.1.0 (2024-07-22) 62 | 63 | - If you are using Electron >= `30.3.0`, you will get native reporting on 64 | download percent and bytes per second via the Electron API instead of manual calculations. 65 | * Provided via [this Electron PR](https://github.com/electron/electron/pull/42914) 66 | 67 | # 3.0.1 (2024-07-13) 68 | 69 | Do not emit progress events when `pause()` is called. 70 | 71 | # 3.0.0 (2024-04-04) 72 | 73 | Adds fixes around `DownloadData` population. 74 | 75 | **Breaking changes** 76 | 77 | `ElectronDownloadManager.download()` is now `async`. 78 | 79 | This change is necessary to fix a race condition where `download()` is called, but if you immediately try to perform an 80 | operation against the returned id, such as `pauseDownload()`, the `DownloadItem` has not been properly initialized 81 | since the event that populates `DownloadItem` is out-of-band. 82 | 83 | So the new `download()` implementation waits until `DownloadItem` is properly initialized before returning the id. 84 | 85 | # 2.4.1 (2024-04-03) 86 | 87 | - Fix issue where pausing a download won't pause it 88 | * This can happen if you pause right after starting a download where internally we pause then resume after 89 | internal handlers are set. Now we'll track if the user has paused and will not auto-resume after. 90 | 91 | # 2.4.0 (2024-03-30) 92 | 93 | - Fix readme. Should be `ElectronDownloadManager` 94 | not `FileDownloadManager` 95 | 96 | # 2.3.1 (2024-03-28) 97 | 98 | No actual logic changes. 99 | 100 | - Remove postinstall script 101 | - Remove eslint and use biome.js for linting / formatting instead 102 | - Remove unnecessary devDep packages. 103 | 104 | # 2.3.0 (2024-03-27) 105 | 106 | - Implement `onDownloadInterrupted()` for the download `completed` state. This should cover urls that result in 404s. 107 | * Added new property `interruptedVia` to `DownloadData` to indicate the state in which the download was interrupted from. 108 | - Removed the restriction on having to specify `saveAsFilename` since it should auto-calculate the filename from the URL if not provided. 109 | - Fixed a bug where `resolvedFilename` was not populated when not using a save as dialog. 110 | 111 | # 2.2.0 (2024-03-25) 112 | 113 | - Added some missing instructions for how to format the download rate / estimated time remaining 114 | 115 | Added to `DownloadData`: 116 | 117 | - `isDownloadResumable()` 118 | 119 | # 2.1.0 (2024-03-25) 120 | 121 | Added to `DownloadData`: 122 | 123 | - Add `downloadRateBytesPerSecond` 124 | - Add `estimatedTimeRemainingSeconds` 125 | 126 | See Readme for more information. 127 | 128 | # 2.0.5 (2024-03-25) 129 | 130 | - Forgot to build before publishing, added build to prebuild script 131 | 132 | # 2.0.4 (2024-03-25) 133 | 134 | - Small readme fix 135 | 136 | # 2.0.3 (2024-03-25) 137 | 138 | - Fix documentation formatting 139 | - Add unit tests for utils file 140 | - Downgraded `unused-filename` from `4` to `3` series due to issue with jest not supporting esmodules well 141 | 142 | # 2.0.2 (2024-03-22) 143 | 144 | - Fix bug where `cancelledFromSaveAsDialog` was not being set if the user cancelled from the save as dialog 145 | 146 | # 2.0.1 (2024-03-22) 147 | 148 | Fix the TOC in the readme. 149 | 150 | # 2.0.0 (2024-03-22) 151 | 152 | Complete refactor to make things more organized and readable. Unit tests are more 153 | sane now. 154 | 155 | **Breaking Changes:** 156 | 157 | - `showBadge` option has been removed. The reasoning is that you may have other items that you need to include in your badge count outside of download counts, so it's better to manage that aspect yourself. 158 | 159 | - The callbacks return a `DownloadData` instance instead of a plain object. The data sent is the same as it was in v1. 160 | 161 | # 1.2.2 (2024-03-21) 162 | 163 | - Internal refactors and small fixes 164 | 165 | # 1.2.1 (2024-03-21) 166 | 167 | - More immediate download fixes 168 | 169 | # 1.2.0 (2024-03-21) 170 | 171 | - Fixes a major issue where a download would not complete if using the save as dialog 172 | - Fixes internal static method `disableThrottle()` where it was not working / throwing 173 | 174 | # 1.1.1 (2024-03-21) 175 | 176 | - Fix issues around downloading smaller files where the download would complete before the progress / completed event was emitted 177 | - When the user cancels from the save as dialog, will fire out `onDownloadCancelled()` 178 | - Add `cancelledFromSaveAsDialog` in the callback data to indicate if the download was cancelled from the save as dialog 179 | 180 | # 1.1.0 (2024-03-21) 181 | 182 | - Add `ElectronDownloadManagerMock` for use in tests 183 | - Add `getActiveDownloadCount()` to get the number of active downloads 184 | - Add `showBadge` option to `download()` to show a badge on the dock icon 185 | 186 | # 1.0.2 (2024-03-21) 187 | 188 | `download()` now returns the `id` of the download 189 | 190 | # 1.0.1 (2024-03-21) 191 | 192 | Readme updates 193 | 194 | # 1.0.0 (2024-03-20) 195 | 196 | Initial version 197 | -------------------------------------------------------------------------------- /test/utils.test.ts: -------------------------------------------------------------------------------- 1 | import { writeFile } from "node:fs/promises"; 2 | import path from "node:path"; 3 | import type { DownloadItem } from "electron"; 4 | import { 5 | calculateDownloadMetrics, 6 | determineFilePath, 7 | generateRandomId, 8 | getFilenameFromMime, 9 | truncateUrl, 10 | } from "../src/utils"; 11 | import { createMockDownloadData } from "../src/__mocks__/DownloadData"; 12 | import { vi, describe, test, expect, beforeEach } from "vitest"; 13 | 14 | vi.mock("electron"); 15 | 16 | let mockedItemData; 17 | 18 | beforeEach(() => { 19 | vi.clearAllMocks(); 20 | mockedItemData = createMockDownloadData().item; 21 | }); 22 | 23 | describe("truncateUrl", () => { 24 | test("it should truncate URL if longer than 50 characters", () => { 25 | const url = "https://www.example.com/this/is/a/very/long/url/which/needs/truncation/to/maintain/50/characters"; 26 | expect(truncateUrl(url)).toEqual("https://www.example.com/this/is/a/very/long/url/wh..."); 27 | }); 28 | 29 | test("it should not truncate URL if already 50 characters or less", () => { 30 | const url = "https://www.example.com/short-url"; 31 | expect(truncateUrl(url)).toEqual(url); 32 | }); 33 | }); 34 | 35 | describe("generateRandomId", () => { 36 | test("it should generate a random ID of length 6", () => { 37 | const randomId = generateRandomId(); 38 | expect(randomId).toHaveLength(6); 39 | }); 40 | }); 41 | 42 | describe("getFilenameFromMime", () => { 43 | test("it should return the name appended with extension if there is a supported mime type", () => { 44 | const name = "test"; 45 | const mime = "application/pdf"; 46 | expect(getFilenameFromMime(name, mime)).toEqual("test.pdf"); 47 | }); 48 | 49 | test("it should return the original name there is no extension associated with the mime type", () => { 50 | const name = "test"; 51 | const mime = ""; 52 | expect(getFilenameFromMime(name, mime)).toEqual(name); 53 | }); 54 | }); 55 | 56 | describe("determineFilePath", () => { 57 | test("it should return a valid file path with provided saveAsFilename", () => { 58 | const downloadDir = "/tmp/downloads"; 59 | const saveAsFilename = "myFile.txt"; 60 | const item = { getFilename: () => "example.txt", getMimeType: () => "text/plain" } as DownloadItem; 61 | const result = determineFilePath({ directory: downloadDir, saveAsFilename, item }); 62 | 63 | expect(result).toEqual("/tmp/downloads/myFile.txt"); 64 | }); 65 | 66 | test("it should return a valid file path with overwrite option set to true", () => { 67 | const downloadDir = "/tmp/downloads"; 68 | const item = { getFilename: () => "example.txt", getMimeType: () => "text/plain" } as DownloadItem; 69 | const result = determineFilePath({ directory: downloadDir, item, overwrite: true }); 70 | 71 | expect(result).toEqual("/tmp/downloads/example.txt"); 72 | }); 73 | 74 | test("it should generate a unique filename when saveAsFilename is not provided and overwrite is false", async () => { 75 | const downloadDir = "/tmp"; 76 | const item = { getFilename: () => "example.txt", getMimeType: () => "text/plain" } as DownloadItem; 77 | 78 | // @todo: mock the file system 79 | // Tried using memfs and mock-fs without success 80 | await writeFile(path.join(downloadDir, item.getFilename()), "test", { 81 | flag: "w", 82 | encoding: "utf-8", 83 | }); 84 | 85 | const result = determineFilePath({ directory: downloadDir, item }); 86 | 87 | expect(result).toEqual("/tmp/example (1).txt"); 88 | }); 89 | 90 | test("it should throw an error when directory is provided but is not an absolute path", () => { 91 | const invalidDirectory = "downloads"; 92 | const item = { getFilename: () => "example.txt", getMimeType: () => "text/plain" } as DownloadItem; 93 | 94 | expect(() => determineFilePath({ directory: invalidDirectory, item })).toThrow( 95 | Error("The `directory` option must be an absolute path"), 96 | ); 97 | }); 98 | }); 99 | 100 | describe("calculateDownloadMetrics", () => { 101 | const mockStartTimeSecs = 1000; 102 | 103 | beforeAll(() => { 104 | vi.spyOn(Date, "now").mockImplementation(() => { 105 | // Mock current time (in ms) 1000 seconds after the start time 106 | return 2000 * mockStartTimeSecs; 107 | }); 108 | }); 109 | 110 | afterAll(() => { 111 | vi.restoreAllMocks(); 112 | }); 113 | 114 | it("calculates the download metrics correctly for positive elapsed time", () => { 115 | mockedItemData.getReceivedBytes.mockReturnValue(1000); 116 | mockedItemData.getTotalBytes.mockReturnValue(5000); 117 | mockedItemData.getStartTime.mockReturnValue(mockStartTimeSecs); 118 | 119 | mockedItemData["getCurrentBytesPerSecond"] = undefined; 120 | mockedItemData["getPercentComplete"] = undefined; 121 | 122 | const result = calculateDownloadMetrics(mockedItemData); 123 | 124 | expect(result).toEqual({ 125 | percentCompleted: 20, 126 | downloadRateBytesPerSecond: 1, 127 | estimatedTimeRemainingSeconds: 4000, // 4000 bytes remaining at 1 byte/second 128 | }); 129 | }); 130 | 131 | it("calculates zero download rate and estimated time if no time has elapsed", () => { 132 | const startTimeWithNoElapsedTime = 2000; // Mock current time is the same as start time 133 | mockedItemData.getReceivedBytes.mockReturnValue(0); 134 | mockedItemData.getTotalBytes.mockReturnValue(5000); 135 | mockedItemData.getStartTime.mockReturnValue(startTimeWithNoElapsedTime); 136 | 137 | mockedItemData["getCurrentBytesPerSecond"] = undefined; 138 | mockedItemData["getPercentComplete"] = undefined; 139 | 140 | const result = calculateDownloadMetrics(mockedItemData); 141 | 142 | expect(result).toEqual({ 143 | percentCompleted: 0, 144 | downloadRateBytesPerSecond: 0, 145 | estimatedTimeRemainingSeconds: 0, 146 | }); 147 | }); 148 | 149 | it("does not exceed 100% completion", () => { 150 | mockedItemData.getReceivedBytes.mockReturnValue(5000); 151 | mockedItemData.getTotalBytes.mockReturnValue(2000); 152 | mockedItemData.getStartTime.mockReturnValue(mockStartTimeSecs); 153 | 154 | mockedItemData["getCurrentBytesPerSecond"] = undefined; 155 | mockedItemData["getPercentComplete"] = undefined; 156 | 157 | const result = calculateDownloadMetrics(mockedItemData); 158 | 159 | expect(result.percentCompleted).toBe(100); 160 | }); 161 | 162 | it("handles zero totalBytes without errors and returns zero for percentCompleted", () => { 163 | mockedItemData.getReceivedBytes.mockReturnValue(1000); 164 | mockedItemData.getTotalBytes.mockReturnValue(0); 165 | mockedItemData.getStartTime.mockReturnValue(mockStartTimeSecs); 166 | 167 | mockedItemData["getCurrentBytesPerSecond"] = undefined; 168 | mockedItemData["getPercentComplete"] = undefined; 169 | 170 | const result = calculateDownloadMetrics(mockedItemData); 171 | 172 | expect(result.percentCompleted).toBe(0); 173 | }); 174 | 175 | describe("with getCurrentBytesPerSecond and getPercentComplete", () => { 176 | it("calculates the download metrics correctly for positive elapsed time", () => { 177 | mockedItemData.getCurrentBytesPerSecond.mockReturnValue(999); 178 | mockedItemData.getPercentComplete.mockReturnValue(99); 179 | 180 | const result = calculateDownloadMetrics(mockedItemData); 181 | 182 | expect(result).toEqual({ 183 | percentCompleted: 99, 184 | downloadRateBytesPerSecond: 999, 185 | estimatedTimeRemainingSeconds: expect.any(Number), 186 | }); 187 | }); 188 | }); 189 | }); 190 | -------------------------------------------------------------------------------- /src/ElectronDownloadManager.ts: -------------------------------------------------------------------------------- 1 | import type { DownloadData, RestoreDownloadData } from "./DownloadData"; 2 | import { DownloadInitiator } from "./DownloadInitiator"; 3 | import type { 4 | DebugLoggerFn, 5 | DownloadConfig, 6 | DownloadManagerConstructorParams, 7 | IElectronDownloadManager, 8 | RestoreDownloadConfig, 9 | } from "./types"; 10 | import { truncateUrl } from "./utils"; 11 | 12 | /** 13 | * This is used to solve an issue where multiple downloads are started at the same time. 14 | * For example, Promise.all([download1, download2, ...]) will start both downloads at the same 15 | * time. This is problematic because the will-download event is not guaranteed to fire in the 16 | * order that the downloads were started. 17 | * 18 | * So we use this to make sure that will-download fires in the order that the downloads were 19 | * started by executing the downloads in a sequential fashion. 20 | * 21 | * For more information see: 22 | * https://github.com/theogravity/electron-dl-manager/issues/11 23 | */ 24 | class DownloadQueue { 25 | private promise = Promise.resolve() as unknown as Promise; 26 | 27 | add(task: () => Promise): Promise { 28 | this.promise = this.promise.then(() => task()); 29 | return this.promise; 30 | } 31 | } 32 | 33 | /** 34 | * Enables handling downloads in Electron. 35 | */ 36 | export class ElectronDownloadManager implements IElectronDownloadManager { 37 | protected downloadData: Record; 38 | protected logger: DebugLoggerFn; 39 | private downloadQueue = new DownloadQueue(); 40 | 41 | constructor(params: DownloadManagerConstructorParams = {}) { 42 | this.downloadData = {}; 43 | this.logger = params.debugLogger || (() => {}); 44 | } 45 | 46 | protected log(message: string) { 47 | this.logger(message); 48 | } 49 | 50 | /** 51 | * Returns the current download data 52 | */ 53 | getDownloadData(id: string): DownloadData { 54 | return this.downloadData[id]; 55 | } 56 | 57 | /** 58 | * Cancels a download 59 | */ 60 | cancelDownload(id: string) { 61 | const data = this.downloadData[id]; 62 | 63 | if (data?.item) { 64 | this.log(`[${id}] Cancelling download`); 65 | data.item.cancel(); 66 | } else { 67 | this.log(`[${id}] Download ${id} not found for cancellation`); 68 | } 69 | } 70 | 71 | /** 72 | * Pauses a download and returns the data necessary 73 | * to restore it later via restoreDownload() if the download exists. 74 | */ 75 | pauseDownload(id: string): RestoreDownloadData | undefined { 76 | const data = this.downloadData[id]; 77 | 78 | if (data?.item) { 79 | this.log(`[${id}] Pausing download`); 80 | data.item.pause(); 81 | return data.getRestoreDownloadData(); 82 | } 83 | 84 | this.log(`[${id}] Download ${id} not found for pausing`); 85 | } 86 | 87 | /** 88 | * Resumes a download 89 | */ 90 | resumeDownload(id: string) { 91 | const data = this.downloadData[id]; 92 | 93 | if (data?.item?.isPaused()) { 94 | this.log(`[${id}] Resuming download`); 95 | data.item.resume(); 96 | } else { 97 | this.log(`[${id}] Download ${id} not found or is not in a paused state`); 98 | } 99 | } 100 | 101 | /** 102 | * Returns the number of active downloads 103 | */ 104 | getActiveDownloadCount() { 105 | return Object.values(this.downloadData).filter((data) => data.isDownloadInProgress()).length; 106 | } 107 | 108 | /** 109 | * Restores a download that is not registered in the download manager. 110 | * If it is already registered, calls resumeDownload() instead. 111 | */ 112 | async restoreDownload(params: RestoreDownloadConfig) { 113 | if (this.getDownloadData(params.restoreData.id)) { 114 | this.resumeDownload(params.restoreData.id); 115 | return params.restoreData.id; 116 | } 117 | 118 | return this.downloadQueue.add( 119 | () => 120 | new Promise((resolve, reject) => { 121 | try { 122 | const restoreData = params.restoreData; 123 | 124 | const onWillQuit = () => { 125 | downloadInitiator.persistDownload(); 126 | }; 127 | 128 | const downloadInitiator = new DownloadInitiator({ 129 | id: restoreData.id, 130 | debugLogger: this.logger, 131 | callbacks: params.callbacks, 132 | onCleanup: (data) => { 133 | this.cleanup(data); 134 | 135 | if (params.restoreData.persistedFilePath) { 136 | params.app.removeListener("will-quit", onWillQuit); 137 | } 138 | }, 139 | onDownloadInit: (data) => { 140 | this.downloadData[data.id] = data; 141 | resolve(data.id); 142 | }, 143 | }); 144 | 145 | if (restoreData.persistedFilePath) { 146 | downloadInitiator.restorePersistedDownload(restoreData); 147 | } 148 | 149 | this.log( 150 | `[${downloadInitiator.getDownloadId()}] Restoring download for url: ${truncateUrl( 151 | params.restoreData.url, 152 | )}`, 153 | ); 154 | params.window.webContents.session.once( 155 | "will-download", 156 | downloadInitiator.generateOnWillDownload({ 157 | restoreData: params.restoreData, 158 | }), 159 | ); 160 | 161 | params.window.webContents.session.createInterruptedDownload({ 162 | path: restoreData.fileSaveAsPath, 163 | urlChain: restoreData.urlChain, 164 | mimeType: restoreData.mimeType, 165 | eTag: restoreData.eTag, 166 | offset: restoreData.receivedBytes, 167 | length: restoreData.totalBytes, 168 | startTime: restoreData.startTime, 169 | }); 170 | 171 | if (params.restoreData.persistedFilePath) { 172 | params.app.once("will-quit", onWillQuit); 173 | } 174 | } catch (e) { 175 | reject(e); 176 | } 177 | }), 178 | ); 179 | } 180 | 181 | /** 182 | * Starts a download. If saveDialogOptions has been defined in the config, 183 | * the saveAs dialog will show up first. 184 | * 185 | * Returns the id of the download. 186 | */ 187 | async download(params: DownloadConfig): Promise { 188 | if (params.persistOnAppClose && !params.app) { 189 | throw Error("You must provide the app instance to persist downloads on app close"); 190 | } 191 | 192 | return this.downloadQueue.add( 193 | () => 194 | new Promise((resolve, reject) => { 195 | try { 196 | if (params.saveAsFilename && params.saveDialogOptions) { 197 | return reject(Error("You cannot define both saveAsFilename and saveDialogOptions to start a download")); 198 | } 199 | 200 | const onWillQuit = () => { 201 | downloadInitiator.persistDownload(); 202 | }; 203 | 204 | const downloadInitiator = new DownloadInitiator({ 205 | debugLogger: this.logger, 206 | callbacks: params.callbacks, 207 | onCleanup: (data) => { 208 | this.cleanup(data); 209 | 210 | if (params.persistOnAppClose && params.app) { 211 | params.app.removeListener("will-quit", onWillQuit); 212 | } 213 | }, 214 | onDownloadInit: (data) => { 215 | this.downloadData[data.id] = data; 216 | resolve(data.id); 217 | }, 218 | }); 219 | 220 | this.log(`[${downloadInitiator.getDownloadId()}] Registering download for url: ${truncateUrl(params.url)}`); 221 | params.window.webContents.session.once( 222 | "will-download", 223 | downloadInitiator.generateOnWillDownload({ 224 | saveDialogOptions: params.saveDialogOptions, 225 | saveAsFilename: params.saveAsFilename, 226 | directory: params.directory, 227 | overwrite: params.overwrite, 228 | }), 229 | ); 230 | params.window.webContents.downloadURL(params.url, params.downloadURLOptions); 231 | 232 | if (params.persistOnAppClose && params.app) { 233 | params.app.once("will-quit", onWillQuit); 234 | } 235 | } catch (e) { 236 | reject(e); 237 | } 238 | }), 239 | ); 240 | } 241 | 242 | protected cleanup(data: DownloadData) { 243 | this.log(`[${data.id}] Removing download from manager`); 244 | delete this.downloadData[data.id]; 245 | } 246 | } 247 | -------------------------------------------------------------------------------- /test/ElectronDownloadManager.test.ts: -------------------------------------------------------------------------------- 1 | import { DownloadData, ElectronDownloadManager } from "../src"; 2 | import { createMockDownloadData } from "../src/__mocks__/DownloadData"; 3 | import { vi, describe, it, expect } from "vitest"; 4 | 5 | vi.mock("unused-filename"); 6 | vi.mock("../src/DownloadInitiator"); 7 | 8 | describe("ElectronDownloadManager", () => { 9 | it("should get download data", () => { 10 | const downloadData = new DownloadData(); 11 | const downloadManager = new ElectronDownloadManager(); 12 | downloadManager.downloadData = { [downloadData.id]: downloadData }; 13 | expect(downloadManager.getDownloadData(downloadData.id)).toBe(downloadData); 14 | }); 15 | 16 | it("should cancel download", () => { 17 | const downloadData = createMockDownloadData().downloadData; 18 | 19 | const downloadManager = new ElectronDownloadManager(); 20 | downloadManager.downloadData = { [downloadData.id]: downloadData }; 21 | downloadManager.cancelDownload(downloadData.id); 22 | expect(downloadData.item.cancel).toHaveBeenCalled(); 23 | }); 24 | 25 | it("should pause download and return restore data", () => { 26 | const { downloadData, item } = createMockDownloadData(); 27 | const mockRestoreData = { 28 | id: downloadData.id, 29 | url: "https://example.com/test.txt", 30 | fileSaveAsPath: "/path/to/save", 31 | urlChain: ["https://example.com/test.txt"], 32 | mimeType: "text/plain", 33 | eTag: "etag123", 34 | receivedBytes: 500, 35 | totalBytes: 1000, 36 | }; 37 | 38 | downloadData.getRestoreDownloadData.mockReturnValue(mockRestoreData); 39 | 40 | const downloadManager = new ElectronDownloadManager(); 41 | downloadManager.downloadData = { [downloadData.id]: downloadData }; 42 | 43 | const result = downloadManager.pauseDownload(downloadData.id); 44 | 45 | expect(downloadData.item.pause).toHaveBeenCalled(); 46 | expect(result).toEqual(mockRestoreData); 47 | }); 48 | 49 | it("should pause download and return undefined when download not found", () => { 50 | const downloadManager = new ElectronDownloadManager(); 51 | 52 | const result = downloadManager.pauseDownload("non-existent-id"); 53 | 54 | expect(result).toBeUndefined(); 55 | }); 56 | 57 | it("should resume download", () => { 58 | const { downloadData, item } = createMockDownloadData(); 59 | 60 | item.isPaused.mockReturnValue(true); 61 | 62 | const downloadManager = new ElectronDownloadManager(); 63 | 64 | downloadManager.downloadData = { [downloadData.id]: downloadData }; 65 | downloadManager.resumeDownload(downloadData.id); 66 | 67 | expect(downloadData.item.resume).toHaveBeenCalled(); 68 | }); 69 | 70 | it("should get active download count", () => { 71 | const { downloadData: downloadData1 } = createMockDownloadData(); 72 | 73 | downloadData1.isDownloadInProgress.mockReturnValue(true); 74 | 75 | const { downloadData: downloadData2 } = createMockDownloadData(); 76 | 77 | downloadData2.isDownloadInProgress.mockReturnValue(false); 78 | 79 | const { downloadData: downloadData3 } = createMockDownloadData(); 80 | 81 | downloadData3.isDownloadInProgress.mockReturnValue(true); 82 | 83 | const downloadManager = new ElectronDownloadManager(); 84 | 85 | downloadManager.downloadData = { 86 | [downloadData1.id]: downloadData1, 87 | [downloadData2.id]: downloadData2, 88 | [downloadData3.id]: downloadData3, 89 | }; 90 | 91 | expect(downloadManager.getActiveDownloadCount()).toBe(2); 92 | }); 93 | 94 | it("should download a file", async () => { 95 | const downloadManager = new ElectronDownloadManager(); 96 | const { item } = createMockDownloadData(); 97 | 98 | const params = { 99 | url: "https://example.com/test.txt", 100 | saveAsFilename: "test.txt", 101 | window: { 102 | webContents: { 103 | session: { 104 | once: vi.fn().mockImplementation((event, handler) => { 105 | // Trigger the event handler manually with mock data 106 | const mockWebContents = {}; 107 | handler(null, item, mockWebContents); 108 | }), 109 | }, 110 | downloadURL: vi.fn(), 111 | }, 112 | } as any, 113 | callbacks: {} as any, 114 | }; 115 | 116 | // Call download which registers the event and triggers downloadURL 117 | const downloadPromise = downloadManager.download(params); 118 | 119 | // Vitest tick to make sure all Promises have a chance to resolve 120 | await new Promise(process.nextTick); 121 | 122 | // Assert that the event listener for "will-download" has been added 123 | expect(params.window.webContents.session.once).toBeCalledWith("will-download", expect.any(Function)); 124 | 125 | // Assert that downloadURL was called with the correct parameters 126 | expect(params.window.webContents.downloadURL).toBeCalledWith(params.url, undefined); 127 | 128 | // Assert that the downloadId will be a string once the promise resolves 129 | await expect(downloadPromise).resolves.toEqual(expect.any(String)); 130 | }); 131 | 132 | it("should restore download when download is not registered", async () => { 133 | const downloadManager = new ElectronDownloadManager(); 134 | const { item } = createMockDownloadData(); 135 | const mockRestoreData = { 136 | id: "restore-id", 137 | url: "https://example.com/test.txt", 138 | fileSaveAsPath: "/path/to/save", 139 | urlChain: ["https://example.com/test.txt"], 140 | mimeType: "text/plain", 141 | eTag: "etag123", 142 | receivedBytes: 500, 143 | totalBytes: 1000, 144 | }; 145 | 146 | const params = { 147 | window: { 148 | webContents: { 149 | session: { 150 | once: vi.fn().mockImplementation((event, handler) => { 151 | // Trigger the event handler manually with mock data 152 | const mockWebContents = {}; 153 | handler(null, item, mockWebContents); 154 | }), 155 | createInterruptedDownload: vi.fn(), 156 | }, 157 | }, 158 | } as any, 159 | restoreData: mockRestoreData, 160 | callbacks: {} as any, 161 | }; 162 | 163 | // Call restoreDownload which registers the event and triggers createInterruptedDownload 164 | const restorePromise = downloadManager.restoreDownload(params); 165 | 166 | // Vitest tick to make sure all Promises have a chance to resolve 167 | await new Promise(process.nextTick); 168 | 169 | // Assert that the event listener for "will-download" has been added 170 | expect(params.window.webContents.session.once).toBeCalledWith("will-download", expect.any(Function)); 171 | 172 | // Assert that createInterruptedDownload was called with the correct parameters 173 | expect(params.window.webContents.session.createInterruptedDownload).toBeCalledWith({ 174 | path: mockRestoreData.fileSaveAsPath, 175 | urlChain: mockRestoreData.urlChain, 176 | mimeType: mockRestoreData.mimeType, 177 | eTag: mockRestoreData.eTag, 178 | offset: mockRestoreData.receivedBytes, 179 | length: mockRestoreData.totalBytes, 180 | }); 181 | 182 | // Assert that the downloadId will be a string once the promise resolves 183 | await expect(restorePromise).resolves.toEqual(expect.any(String)); 184 | }); 185 | 186 | it("should call resumeDownload when download is already registered", async () => { 187 | const { downloadData, item } = createMockDownloadData(); 188 | const downloadManager = new ElectronDownloadManager(); 189 | 190 | // Add the download to the manager 191 | downloadManager.downloadData = { [downloadData.id]: downloadData }; 192 | 193 | const mockRestoreData = { 194 | id: downloadData.id, // Use the same ID as the registered download 195 | url: "https://example.com/test.txt", 196 | fileSaveAsPath: "/path/to/save", 197 | urlChain: ["https://example.com/test.txt"], 198 | mimeType: "text/plain", 199 | eTag: "etag123", 200 | receivedBytes: 500, 201 | totalBytes: 1000, 202 | }; 203 | 204 | const params = { 205 | window: {} as any, 206 | restoreData: mockRestoreData, 207 | callbacks: {} as any, 208 | }; 209 | 210 | // Mock the resumeDownload method to verify it's called 211 | const resumeSpy = vi.spyOn(downloadManager, "resumeDownload"); 212 | 213 | // Call restoreDownload which should call resumeDownload since download is already registered 214 | const result = downloadManager.restoreDownload(params); 215 | 216 | // Assert that resumeDownload was called with the correct ID 217 | expect(resumeSpy).toHaveBeenCalledWith(downloadData.id); 218 | 219 | // Since restoreDownload is async, it returns a Promise that resolves to the download ID 220 | // when it calls resumeDownload and returns early 221 | await expect(result).resolves.toBe(downloadData.id); 222 | }); 223 | }); 224 | -------------------------------------------------------------------------------- /test/DownloadInitiator.test.ts: -------------------------------------------------------------------------------- 1 | import { DownloadInitiator, getFilenameFromMime } from "../src"; 2 | import { createMockDownloadData } from "../src/__mocks__/DownloadData"; 3 | import { determineFilePath } from "../src/utils"; 4 | import path from "node:path"; 5 | import UnusedFilename from "unused-filename"; 6 | import { vi, describe, it, expect, beforeEach } from "vitest"; 7 | 8 | vi.mock("../src/utils"); 9 | vi.mock("../src/CallbackDispatcher"); 10 | vi.mock("unused-filename"); 11 | vi.mock("electron"); 12 | vi.useFakeTimers(); 13 | 14 | describe("DownloadInitiator", () => { 15 | let callbacks; 16 | let mockItem; 17 | let mockDownloadData; 18 | let mockWebContents; 19 | let mockEvent; 20 | let mockEmitter; 21 | 22 | beforeEach(() => { 23 | vi.clearAllMocks(); 24 | 25 | // use the callbackDispatcher instead for evaluating the callbacks 26 | callbacks = {}; 27 | mockWebContents = {}; 28 | mockEvent = {}; 29 | 30 | const mockedItemData = createMockDownloadData(); 31 | 32 | mockItem = mockedItemData.item; 33 | mockDownloadData = mockedItemData.downloadData; 34 | mockEmitter = mockedItemData.itemEmitter; 35 | }); 36 | 37 | describe("generateOnWillDownload", () => { 38 | it("should initiate an interactive download", () => { 39 | const downloadInitiator = new DownloadInitiator({ 40 | callbacks, 41 | }); 42 | 43 | downloadInitiator.initSaveAsInteractiveDownload = vi.fn(); 44 | 45 | downloadInitiator.generateOnWillDownload({ 46 | saveDialogOptions: { 47 | title: "Save File", 48 | }, 49 | })(mockEvent, mockItem, mockWebContents); 50 | 51 | expect(downloadInitiator.initSaveAsInteractiveDownload).toHaveBeenCalled(); 52 | }); 53 | 54 | it("should initiate an non-interactive download", () => { 55 | const downloadInitiator = new DownloadInitiator({ 56 | callbacks, 57 | }); 58 | 59 | downloadInitiator.initNonInteractiveDownload = vi.fn(); 60 | 61 | downloadInitiator.generateOnWillDownload({})(mockEvent, mockItem, mockWebContents); 62 | 63 | // @ts-ignore TS2445 64 | expect(downloadInitiator.initNonInteractiveDownload).toHaveBeenCalled(); 65 | }); 66 | }); 67 | 68 | describe("initSaveAsInteractiveDownload", () => { 69 | it("handle if the download was cancelled by the user", async () => { 70 | const downloadInitiator = new DownloadInitiator({ 71 | callbacks, 72 | }); 73 | // @ts-ignore - accessing private property for testing 74 | downloadInitiator.downloadData = mockDownloadData; 75 | 76 | mockItem.getSavePath.mockReturnValueOnce(""); 77 | mockDownloadData.isDownloadCancelled.mockReturnValueOnce(true); 78 | 79 | await downloadInitiator.generateOnWillDownload({ 80 | saveDialogOptions: {}, 81 | })(mockEvent, mockItem, mockWebContents); 82 | 83 | await vi.runAllTimersAsync(); 84 | 85 | // @ts-ignore - accessing private property for testing 86 | expect(downloadInitiator.callbackDispatcher.onDownloadCancelled).toHaveBeenCalled(); 87 | expect(mockDownloadData.cancelledFromSaveAsDialog).toBe(true); 88 | }); 89 | 90 | describe("user initiated pause", () => { 91 | it("should not resume the download if the user paused it before init", async () => { 92 | const downloadInitiator = new DownloadInitiator({ 93 | callbacks, 94 | }); 95 | // @ts-ignore - accessing private property for testing 96 | downloadInitiator.downloadData = mockDownloadData; 97 | 98 | mockItem["_userInitiatedPause"] = true; 99 | mockItem.getSavePath.mockReturnValueOnce(""); 100 | mockDownloadData.isDownloadCancelled.mockReturnValueOnce(true); 101 | 102 | await downloadInitiator.generateOnWillDownload({ 103 | saveDialogOptions: {}, 104 | })(mockEvent, mockItem, mockWebContents); 105 | 106 | await vi.runAllTimersAsync(); 107 | 108 | expect(mockItem.resume).not.toHaveBeenCalled(); 109 | }); 110 | 111 | it("should resume the download if the user *did not* pause before init", async () => { 112 | const downloadInitiator = new DownloadInitiator({ 113 | callbacks, 114 | }); 115 | // @ts-ignore - accessing private property for testing 116 | downloadInitiator.downloadData = mockDownloadData; 117 | 118 | determineFilePath.mockReturnValueOnce("/some/path"); 119 | 120 | mockItem["_userInitiatedPause"] = false; 121 | mockItem.getSavePath.mockReturnValueOnce("/some/path"); 122 | 123 | const resumeSpy = vi.spyOn(mockItem, "resume"); 124 | 125 | await downloadInitiator.generateOnWillDownload({ 126 | saveDialogOptions: {}, 127 | })(mockEvent, mockItem, mockWebContents); 128 | 129 | await vi.runAllTimersAsync(); 130 | 131 | expect(resumeSpy).toHaveBeenCalled(); 132 | }); 133 | }); 134 | 135 | describe("path was set", () => { 136 | it("should call onDownloadStarted", async () => { 137 | const downloadInitiator = new DownloadInitiator({ 138 | callbacks, 139 | }); 140 | // @ts-ignore - accessing private property for testing 141 | downloadInitiator.downloadData = mockDownloadData; 142 | 143 | mockItem.getSavePath.mockReturnValueOnce("/some/path"); 144 | 145 | await downloadInitiator.generateOnWillDownload({ 146 | saveDialogOptions: {}, 147 | })(mockEvent, mockItem, mockWebContents); 148 | 149 | await vi.runAllTimersAsync(); 150 | 151 | // @ts-ignore - accessing private property for testing 152 | expect(downloadInitiator.callbackDispatcher.onDownloadStarted).toHaveBeenCalled(); 153 | }); 154 | 155 | it("should handle if the download was completed too quickly", async () => { 156 | const downloadInitiator = new DownloadInitiator({ 157 | callbacks, 158 | }); 159 | // @ts-ignore - accessing private property for testing 160 | downloadInitiator.downloadData = mockDownloadData; 161 | 162 | mockItem.getSavePath.mockReturnValueOnce("/some/path"); 163 | 164 | mockDownloadData.isDownloadCompleted.mockReturnValueOnce(true); 165 | 166 | await downloadInitiator.generateOnWillDownload({ 167 | saveDialogOptions: {}, 168 | })(mockEvent, mockItem, mockWebContents); 169 | 170 | await vi.runAllTimersAsync(); 171 | 172 | // @ts-ignore - accessing private property for testing 173 | expect(downloadInitiator.callbackDispatcher.onDownloadCompleted).toHaveBeenCalled(); 174 | }); 175 | }); 176 | }); 177 | 178 | describe("initNonInteractiveDownload", () => { 179 | it("should call onDownloadStarted", async () => { 180 | const downloadInitiator = new DownloadInitiator({ 181 | callbacks, 182 | }); 183 | // @ts-ignore - accessing private property for testing 184 | downloadInitiator.downloadData = mockDownloadData; 185 | 186 | determineFilePath.mockReturnValueOnce("/some/path/test.txt"); 187 | 188 | await downloadInitiator.generateOnWillDownload({ 189 | saveAsFilename: "test.txt", 190 | })(mockEvent, mockItem, mockWebContents); 191 | 192 | expect(downloadInitiator.getDownloadData().resolvedFilename).toBe("test.txt"); 193 | // @ts-ignore - accessing private property for testing 194 | expect(downloadInitiator.callbackDispatcher.onDownloadStarted).toHaveBeenCalled(); 195 | }); 196 | 197 | describe("user initiated pause", () => { 198 | it("should not resume the download if the user paused it before init", async () => { 199 | const downloadInitiator = new DownloadInitiator({ 200 | callbacks, 201 | }); 202 | // @ts-ignore - accessing private property for testing 203 | downloadInitiator.downloadData = mockDownloadData; 204 | mockItem["_userInitiatedPause"] = true; 205 | 206 | determineFilePath.mockReturnValueOnce("/some/path/test.txt"); 207 | 208 | await downloadInitiator.generateOnWillDownload({})(mockEvent, mockItem, mockWebContents); 209 | 210 | const resumeSpy = vi.spyOn(mockItem, "resume"); 211 | 212 | await vi.runAllTimersAsync(); 213 | 214 | expect(resumeSpy).not.toHaveBeenCalled(); 215 | }); 216 | 217 | it("should resume the download if the *did not* pause before init", async () => { 218 | const downloadInitiator = new DownloadInitiator({ 219 | callbacks, 220 | }); 221 | // @ts-ignore - accessing private property for testing 222 | downloadInitiator.downloadData = mockDownloadData; 223 | mockItem["_userInitiatedPause"] = true; 224 | 225 | determineFilePath.mockReturnValueOnce("/some/path/test.txt"); 226 | const resumeSpy = vi.spyOn(mockItem, "resume"); 227 | 228 | await downloadInitiator.generateOnWillDownload({ 229 | directory: "/some/path", 230 | saveAsFilename: "test.txt", 231 | })(mockEvent, mockItem, mockWebContents); 232 | 233 | await vi.runAllTimersAsync(); 234 | 235 | expect(resumeSpy).toHaveBeenCalled(); 236 | }); 237 | }); 238 | }); 239 | 240 | describe("event handlers", () => { 241 | describe("itemOnUpdated", () => { 242 | it("should handle progressing state", async () => { 243 | const downloadInitiator = new DownloadInitiator({ 244 | callbacks, 245 | }); 246 | // @ts-ignore - accessing private property for testing 247 | downloadInitiator.downloadData = mockDownloadData; 248 | // @ts-ignore - accessing private property for testing 249 | downloadInitiator.callbackDispatcher.onDownloadProgress = vi.fn(); 250 | downloadInitiator.updateProgress = vi.fn(); 251 | 252 | const itemOnUpdated = downloadInitiator.generateItemOnUpdated(); 253 | 254 | await itemOnUpdated(mockEvent, "progressing"); 255 | 256 | expect(downloadInitiator.updateProgress).toHaveBeenCalled(); 257 | // @ts-ignore - accessing private property for testing 258 | expect(downloadInitiator.callbackDispatcher.onDownloadProgress).toHaveBeenCalledWith(mockDownloadData); 259 | }); 260 | 261 | it("should handle interrupted state", async () => { 262 | const downloadInitiator = new DownloadInitiator({ 263 | callbacks, 264 | }); 265 | // @ts-ignore - accessing private property for testing 266 | downloadInitiator.downloadData = mockDownloadData; 267 | // @ts-ignore - accessing private property for testing 268 | downloadInitiator.callbackDispatcher.onDownloadInterrupted = vi.fn(); 269 | 270 | const itemOnUpdated = downloadInitiator.generateItemOnUpdated(); 271 | 272 | await itemOnUpdated(mockEvent, "interrupted"); 273 | 274 | expect(mockDownloadData.interruptedVia).toBe("in-progress"); 275 | // @ts-ignore - accessing private property for testing 276 | expect(downloadInitiator.callbackDispatcher.onDownloadInterrupted).toHaveBeenCalledWith(mockDownloadData); 277 | }); 278 | }); 279 | 280 | describe("itemOnDone", () => { 281 | it("should handle completed state", async () => { 282 | const downloadInitiator = new DownloadInitiator({ 283 | callbacks, 284 | }); 285 | // @ts-ignore - accessing private property for testing 286 | downloadInitiator.downloadData = mockDownloadData; 287 | // @ts-ignore - accessing private property for testing 288 | downloadInitiator.callbackDispatcher.onDownloadCompleted = vi.fn(); 289 | downloadInitiator.cleanup = vi.fn(); 290 | 291 | const itemOnDone = downloadInitiator.generateItemOnDone(); 292 | 293 | await itemOnDone(mockEvent, "completed"); 294 | 295 | // @ts-ignore - accessing private property for testing 296 | expect(downloadInitiator.callbackDispatcher.onDownloadCompleted).toHaveBeenCalledWith(mockDownloadData); 297 | expect(downloadInitiator.cleanup).toHaveBeenCalled(); 298 | }); 299 | }); 300 | 301 | it("should handle cancelled state", async () => { 302 | const downloadInitiator = new DownloadInitiator({ 303 | callbacks, 304 | }); 305 | // @ts-ignore - accessing private property for testing 306 | downloadInitiator.downloadData = mockDownloadData; 307 | // @ts-ignore - accessing private property for testing 308 | downloadInitiator.callbackDispatcher.onDownloadCancelled = vi.fn(); 309 | downloadInitiator.cleanup = vi.fn(); 310 | 311 | const itemOnDone = downloadInitiator.generateItemOnDone(); 312 | 313 | await itemOnDone(mockEvent, "cancelled"); 314 | 315 | // @ts-ignore - accessing private property for testing 316 | expect(downloadInitiator.callbackDispatcher.onDownloadCancelled).toHaveBeenCalledWith(mockDownloadData); 317 | expect(downloadInitiator.cleanup).toHaveBeenCalled(); 318 | }); 319 | 320 | it("should handle interrupted state", async () => { 321 | const downloadInitiator = new DownloadInitiator({ 322 | callbacks, 323 | }); 324 | // @ts-ignore - accessing private property for testing 325 | downloadInitiator.downloadData = mockDownloadData; 326 | // @ts-ignore - accessing private property for testing 327 | downloadInitiator.callbackDispatcher.onDownloadInterrupted = vi.fn(); 328 | downloadInitiator.cleanup = vi.fn(); 329 | 330 | const itemOnDone = downloadInitiator.generateItemOnDone(); 331 | 332 | await itemOnDone(mockEvent, "interrupted"); 333 | 334 | expect(mockDownloadData.interruptedVia).toBe("completed"); 335 | // @ts-ignore - accessing private property for testing 336 | expect(downloadInitiator.callbackDispatcher.onDownloadInterrupted).toHaveBeenCalledWith(mockDownloadData); 337 | }); 338 | 339 | it("should call the item updated event if the download was paused and resumed", async () => { 340 | const downloadInitiator = new DownloadInitiator({ 341 | callbacks, 342 | }); 343 | // @ts-ignore - accessing private property for testing 344 | downloadInitiator.downloadData = mockDownloadData; 345 | downloadInitiator.updateProgress = vi.fn(); 346 | 347 | determineFilePath.mockReturnValueOnce("/some/path/test.txt"); 348 | 349 | await downloadInitiator.generateOnWillDownload({})(mockEvent, mockItem, mockWebContents); 350 | 351 | await vi.runAllTimersAsync(); 352 | 353 | mockItem.pause(); 354 | mockEmitter.emit("updated", "", "progressing"); 355 | // @ts-ignore - accessing private property for testing 356 | expect(downloadInitiator.callbackDispatcher.onDownloadProgress).not.toHaveBeenCalled(); 357 | 358 | mockItem.resume(); 359 | mockEmitter.emit("updated", "", "progressing"); 360 | // @ts-ignore - accessing private property for testing 361 | expect(downloadInitiator.callbackDispatcher.onDownloadProgress).toHaveBeenCalled(); 362 | }); 363 | }); 364 | }); 365 | -------------------------------------------------------------------------------- /src/DownloadInitiator.ts: -------------------------------------------------------------------------------- 1 | import { copyFileSync, renameSync } from "node:fs"; 2 | import * as path from "node:path"; 3 | import type { DownloadItem, Event, SaveDialogOptions, WebContents } from "electron"; 4 | import { CallbackDispatcher } from "./CallbackDispatcher"; 5 | import { DownloadData, type RestoreDownloadData } from "./DownloadData"; 6 | import type { DownloadConfig, DownloadManagerCallbacks } from "./types"; 7 | import { calculateDownloadMetrics, determineFilePath } from "./utils"; 8 | 9 | interface DownloadInitiatorConstructorParams { 10 | /** 11 | * The id for the download. If not provided, a random id will be generated. 12 | */ 13 | id?: string; 14 | /** 15 | * A debug logger function to log messages. 16 | * If not provided, no logging will occur. 17 | */ 18 | debugLogger?: (message: string) => void; 19 | /** 20 | * Called when the download is cleaned up. 21 | * This is called after the download has completed or been cancelled. 22 | * @param id The download data 23 | */ 24 | onCleanup?: (id: DownloadData) => void; 25 | /** 26 | * Called when the download is initialized. 27 | * This is called before any download events are fired. 28 | * @param id The download data 29 | */ 30 | onDownloadInit?: (id: DownloadData) => void; 31 | /** 32 | * The user callbacks to define to listen for download events 33 | */ 34 | callbacks: DownloadManagerCallbacks; 35 | } 36 | 37 | interface WillOnDownloadParams { 38 | /** 39 | * If defined, will show a save dialog when the user 40 | * downloads a file. 41 | * 42 | * @see https://www.electronjs.org/docs/latest/api/dialog#dialogshowsavedialogbrowserwindow-options 43 | */ 44 | saveDialogOptions?: SaveDialogOptions; 45 | /** 46 | * The filename to save the file as. If not defined, the filename 47 | * from the server will be used. 48 | * 49 | * Only applies if saveDialogOptions is not defined. 50 | */ 51 | saveAsFilename?: string; 52 | /** 53 | * The directory to save the file to. Must be an absolute path. 54 | * @default The user's downloads directory 55 | */ 56 | directory?: string; 57 | /** 58 | * If true, will overwrite the file if it already exists 59 | * @default false 60 | */ 61 | overwrite?: boolean; 62 | /** 63 | * Data for restoring a download. 64 | */ 65 | restoreData?: RestoreDownloadData; 66 | } 67 | 68 | export class DownloadInitiator { 69 | protected logger: (message: string) => void; 70 | /** 71 | * When the download is initiated 72 | */ 73 | private onDownloadInit: (data: DownloadData) => void; 74 | /** 75 | * When cleanup is called 76 | */ 77 | private onCleanup: (data: DownloadData) => void; 78 | /** 79 | * The callback dispatcher for handling download events back to the user 80 | */ 81 | private callbackDispatcher: CallbackDispatcher; 82 | /** 83 | * The data for the download. 84 | */ 85 | private downloadData: DownloadData; 86 | private config: Omit; 87 | /** 88 | * The handler for the DownloadItem's `updated` event. 89 | */ 90 | private onUpdateHandler?: (_event: Event, state: "progressing" | "interrupted") => void; 91 | /** 92 | * The handler for the DownloadItem's `done` event. 93 | */ 94 | private onDoneHandler?: (_event: Event, state: "completed" | "cancelled" | "interrupted") => void; 95 | 96 | constructor(config: DownloadInitiatorConstructorParams) { 97 | this.downloadData = new DownloadData({ 98 | id: config.id, 99 | }); 100 | 101 | this.logger = config.debugLogger || (() => {}); 102 | this.onCleanup = config.onCleanup || (() => {}); 103 | this.onDownloadInit = config.onDownloadInit || (() => {}); 104 | this.config = {} as DownloadConfig; 105 | this.callbackDispatcher = new CallbackDispatcher(this.downloadData.id, config.callbacks, this.logger); 106 | } 107 | 108 | protected log(message: string) { 109 | this.logger(`[${this.downloadData.id}] ${message}`); 110 | } 111 | 112 | /** 113 | * Returns the download id 114 | */ 115 | getDownloadId(): string { 116 | return this.downloadData.id; 117 | } 118 | 119 | /** 120 | * Returns the current download data 121 | */ 122 | getDownloadData(): DownloadData { 123 | return this.downloadData; 124 | } 125 | 126 | /** 127 | * Generates the handler that attaches to the session `will-download` event, 128 | * which will execute the workflows for handling a download. 129 | */ 130 | generateOnWillDownload(downloadParams: WillOnDownloadParams) { 131 | this.config = downloadParams; 132 | 133 | this.downloadData.percentCompleted = this.config.restoreData?.percentCompleted || 0; 134 | 135 | return async (event: Event, item: DownloadItem, webContents: WebContents): Promise => { 136 | item.pause(); 137 | this.downloadData.item = item; 138 | this.downloadData.webContents = webContents; 139 | this.downloadData.event = event; 140 | 141 | if (this.onDownloadInit) { 142 | this.onDownloadInit(this.downloadData); 143 | } 144 | 145 | if (this.config.saveDialogOptions) { 146 | this.initSaveAsInteractiveDownload(); 147 | return; 148 | } 149 | 150 | await this.initNonInteractiveDownload(!!this.config.restoreData); 151 | }; 152 | } 153 | 154 | /** 155 | * Flow for handling a download that requires user interaction via a "Save as" dialog. 156 | */ 157 | protected initSaveAsInteractiveDownload() { 158 | this.log("Prompting save as dialog"); 159 | const { directory, overwrite, saveDialogOptions } = this.config; 160 | const { item } = this.downloadData; 161 | 162 | const filePath = determineFilePath({ directory, item, overwrite }); 163 | 164 | // This actually isn't what shows the save dialog 165 | // If item.setSavePath() isn't called at all after some tiny period of time, 166 | // then the save dialog will show up, and it will use the options we set it to here 167 | item.setSaveDialogOptions({ ...saveDialogOptions, defaultPath: filePath }); 168 | 169 | // Because the download happens concurrently as the user is choosing a save location 170 | // we need to wait for the save location to be chosen before we can start to fire out events 171 | // there's no good way to listen for this, so we need to poll 172 | const interval = setInterval(async () => { 173 | // It seems to unpause sometimes in the dialog situation ??? 174 | // item.getState() value becomes 'completed' for small files 175 | // before item.resume() is called 176 | item.pause(); 177 | 178 | if (item.getSavePath()) { 179 | clearInterval(interval); 180 | 181 | this.log(`User selected save path to ${item.getSavePath()}`); 182 | this.log("Initiating download item handlers"); 183 | 184 | this.downloadData.resolvedFilename = path.basename(item.getSavePath()); 185 | 186 | this.augmentDownloadItem(item); 187 | await this.callbackDispatcher.onDownloadStarted(this.downloadData); 188 | // If for some reason the above pause didn't work... 189 | // We'll manually call the completed handler 190 | if (this.downloadData.isDownloadCompleted()) { 191 | await this.callbackDispatcher.onDownloadCompleted(this.downloadData); 192 | } else { 193 | this.onUpdateHandler = this.generateItemOnUpdated(); 194 | this.onDoneHandler = this.generateItemOnDone(); 195 | item.on("updated", this.onUpdateHandler); 196 | item.once("done", this.onDoneHandler); 197 | } 198 | 199 | if (!item["_userInitiatedPause"]) { 200 | item.resume(); 201 | } 202 | } else if (this.downloadData.isDownloadCancelled()) { 203 | clearInterval(interval); 204 | this.log("Download was cancelled"); 205 | this.downloadData.cancelledFromSaveAsDialog = true; 206 | await this.callbackDispatcher.onDownloadCancelled(this.downloadData); 207 | } else { 208 | this.log("Waiting for save path to be chosen by user"); 209 | } 210 | }, 1000); 211 | } 212 | 213 | private augmentDownloadItem(item: DownloadItem) { 214 | // This covers if the user manually pauses the download 215 | // before we have set up the event listeners on the item 216 | item["_userInitiatedPause"] = false; 217 | 218 | const oldPause = item.pause.bind(item); 219 | item.pause = () => { 220 | item["_userInitiatedPause"] = true; 221 | 222 | if (this.onUpdateHandler) { 223 | // Don't fire progress updates in a paused state 224 | item.off("updated", this.onUpdateHandler); 225 | this.onUpdateHandler = undefined; 226 | } 227 | 228 | oldPause(); 229 | }; 230 | 231 | const oldResume = item.resume.bind(item); 232 | 233 | item.resume = () => { 234 | if (!this.onUpdateHandler) { 235 | this.onUpdateHandler = this.generateItemOnUpdated(); 236 | item.on("updated", this.onUpdateHandler); 237 | } 238 | 239 | oldResume(); 240 | }; 241 | } 242 | 243 | /** 244 | * Flow for handling a download that doesn't require user interaction. 245 | */ 246 | protected async initNonInteractiveDownload(isRestoring?: boolean) { 247 | const { directory, saveAsFilename, overwrite } = this.config; 248 | const { item } = this.downloadData; 249 | 250 | const filePath = determineFilePath({ directory, saveAsFilename, item, overwrite }); 251 | 252 | if (!isRestoring) { 253 | this.log(`Setting save path to ${filePath}`); 254 | item.setSavePath(filePath); 255 | } 256 | 257 | this.log("Initiating download item handlers"); 258 | 259 | this.downloadData.resolvedFilename = path.basename(filePath); 260 | 261 | this.augmentDownloadItem(item); 262 | await this.callbackDispatcher.onDownloadStarted(this.downloadData); 263 | this.onUpdateHandler = this.generateItemOnUpdated(); 264 | this.onDoneHandler = this.generateItemOnDone(); 265 | item.on("updated", this.onUpdateHandler); 266 | item.once("done", this.onDoneHandler); 267 | 268 | if (!item["_userInitiatedPause"]) { 269 | item.resume(); 270 | } 271 | } 272 | 273 | protected updateProgress() { 274 | const { item } = this.downloadData; 275 | 276 | const metrics = calculateDownloadMetrics(item); 277 | 278 | const downloadedBytes = item.getReceivedBytes(); 279 | const totalBytes = item.getTotalBytes(); 280 | 281 | if (downloadedBytes > item.getTotalBytes()) { 282 | // Note: This situation will happen when using data: URIs 283 | this.log(`Downloaded bytes (${downloadedBytes}) is greater than total bytes (${totalBytes})`); 284 | } 285 | 286 | this.downloadData.downloadRateBytesPerSecond = metrics.downloadRateBytesPerSecond; 287 | this.downloadData.estimatedTimeRemainingSeconds = metrics.estimatedTimeRemainingSeconds; 288 | this.downloadData.percentCompleted = metrics.percentCompleted; 289 | } 290 | 291 | /** 292 | * Generates the handler for hooking into the DownloadItem's `updated` event. 293 | */ 294 | protected generateItemOnUpdated() { 295 | return async (_event: Event, state: "progressing" | "interrupted") => { 296 | switch (state) { 297 | case "progressing": { 298 | this.updateProgress(); 299 | await this.callbackDispatcher.onDownloadProgress(this.downloadData); 300 | break; 301 | } 302 | case "interrupted": { 303 | this.downloadData.interruptedVia = "in-progress"; 304 | await this.callbackDispatcher.onDownloadInterrupted(this.downloadData); 305 | break; 306 | } 307 | default: 308 | this.log(`Unexpected itemOnUpdated state: ${state}`); 309 | } 310 | }; 311 | } 312 | 313 | /** 314 | * Generates the handler for hooking into the DownloadItem's `done` event. 315 | */ 316 | protected generateItemOnDone() { 317 | return async (_event: Event, state: "completed" | "cancelled" | "interrupted") => { 318 | switch (state) { 319 | case "completed": { 320 | this.log(`Download completed. Total bytes: ${this.downloadData.item.getTotalBytes()}`); 321 | await this.callbackDispatcher.onDownloadCompleted(this.downloadData); 322 | break; 323 | } 324 | case "cancelled": 325 | this.log( 326 | `Download cancelled. Total bytes: ${this.downloadData.item.getReceivedBytes()} / ${this.downloadData.item.getTotalBytes()}`, 327 | ); 328 | await this.callbackDispatcher.onDownloadCancelled(this.downloadData); 329 | break; 330 | case "interrupted": 331 | this.log( 332 | `Download interrupted. Total bytes: ${this.downloadData.item.getReceivedBytes()} / ${this.downloadData.item.getTotalBytes()}`, 333 | ); 334 | this.downloadData.interruptedVia = "completed"; 335 | await this.callbackDispatcher.onDownloadInterrupted(this.downloadData); 336 | break; 337 | default: 338 | this.log(`Unexpected itemOnDone state: ${state}`); 339 | } 340 | 341 | this.cleanup(); 342 | }; 343 | } 344 | 345 | protected cleanup() { 346 | const { item } = this.downloadData; 347 | 348 | if (item) { 349 | this.log("Cleaning up download item event listeners"); 350 | if (this.onUpdateHandler) { 351 | item.removeListener("updated", this.onUpdateHandler); 352 | } 353 | if (this.onDoneHandler) { 354 | item.removeListener("done", this.onDoneHandler); 355 | } 356 | } 357 | 358 | if (this.onCleanup) { 359 | this.onCleanup(this.downloadData); 360 | } 361 | 362 | this.onUpdateHandler = undefined; 363 | this.onDoneHandler = undefined; 364 | } 365 | 366 | private getPersistDownloadFilename() { 367 | // Append .download extension to the filename 368 | return `${this.downloadData.resolvedFilename}.download`; 369 | } 370 | 371 | /** 372 | * Persists the download to an alternative location. 373 | * This is useful for saving the download state when the app is about to close. 374 | * It copies the current download file to a new location with a `.download` extension. 375 | * If the download is already completed or cancelled, it does nothing. 376 | */ 377 | persistDownload() { 378 | if ( 379 | !this.downloadData.item || 380 | this.downloadData.item.getState() === "completed" || 381 | this.downloadData.item.getState() === "cancelled" 382 | ) { 383 | this.log( 384 | `Download ${this.downloadData.resolvedFilename} is already completed, cancelled or does not exist; no need to persist.`, 385 | ); 386 | return; 387 | } 388 | 389 | this.downloadData.item.pause(); 390 | 391 | const originalPath = this.downloadData.item.getSavePath(); 392 | const persistPath = path.join(path.dirname(originalPath), this.getPersistDownloadFilename()); 393 | 394 | this.log(`Persisting download to ${persistPath}`); 395 | 396 | try { 397 | copyFileSync(originalPath, persistPath); 398 | this.downloadData.persistedFilePath = persistPath; 399 | this.log(`Download persisted successfully to ${persistPath}`); 400 | this.callbackDispatcher.onDownloadPersisted(this.downloadData); 401 | } catch (error) { 402 | this.callbackDispatcher.handleError(new Error(`Failed to persist download: ${error}`)); 403 | } 404 | } 405 | 406 | /** 407 | * Restores a download from a persisted state. 408 | */ 409 | restorePersistedDownload(restoreData: RestoreDownloadData) { 410 | if (!restoreData.persistedFilePath) { 411 | this.log("No persisted file path found for download, cannot restore."); 412 | return; 413 | } 414 | 415 | const originalPath = restoreData.fileSaveAsPath; 416 | const restorePath = restoreData.persistedFilePath; 417 | 418 | this.log(`Restoring download from ${restorePath} to ${originalPath}`); 419 | 420 | try { 421 | renameSync(restorePath, originalPath); 422 | this.log(`Download restored successfully from ${restorePath} to ${originalPath}`); 423 | } catch (error) { 424 | this.callbackDispatcher.handleError(new Error(`Failed to restore download: ${error}`)); 425 | } 426 | } 427 | } 428 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Electron File Download Manager 2 | 3 | [![NPM version](https://img.shields.io/npm/v/electron-dl-manager.svg?style=flat-square)](https://www.npmjs.com/package/electron-dl-manager) [![TypeScript](https://img.shields.io/badge/%3C%2F%3E-TypeScript-%230074c1.svg)](http://www.typescriptlang.org/) 4 | 5 | A simple and easy to use file download manager for Electron applications. 6 | Designed in response to the many issues around `electron-dl` and provides 7 | a more robust and reliable solution for downloading files in Electron. 8 | 9 | Use cases: 10 | 11 | - Download files from a URL 12 | - Get an id associated with the download to track it 13 | - Optionally show a "Save As" dialog 14 | - Get progress updates on the download 15 | - Be able to cancel / pause / resume downloads 16 | - Support multiple downloads at once 17 | - Persist downloads when the app closes, allowing them to be restored / resumed later 18 | 19 | Electron 26.0.0 or later is required. 20 | 21 | ```typescript 22 | // In main process 23 | // Not a working example, just a demonstration of the API 24 | import { ElectronDownloadManager } from 'electron-dl-manager'; 25 | 26 | const manager = new ElectronDownloadManager(); 27 | 28 | // Start a download 29 | const id = await manager.download({ 30 | window: browserWindowInstance, 31 | url: 'https://example.com/file.zip', 32 | saveDialogOptions: { 33 | title: 'Save File', 34 | }, 35 | callbacks: { 36 | onDownloadStarted: async ({ id, item, webContents }) => { 37 | // Do something with the download id 38 | }, 39 | onDownloadProgress: async (...) => {}, 40 | onDownloadCompleted: async (...) => {}, 41 | onDownloadCancelled: async (...) => {}, 42 | onDownloadInterrupted: async (...) => {}, 43 | onError: (err, data) => {}, 44 | } 45 | }); 46 | 47 | manager.cancelDownload(id); 48 | manager.pauseDownload(id); 49 | manager.resumeDownload(id); 50 | ``` 51 | 52 | # Table of contents 53 | 54 | - [Electron File Download Manager](#electron-file-download-manager) 55 | - [Installation](#installation) 56 | - [Getting started](#getting-started) 57 | - [Download Restoration & Persistence](#download-restoration--persistence) 58 | - [Basic Download Restoration](#basic-download-restoration) 59 | - [Download Persistence](#download-persistence) 60 | - [API](#api) 61 | - [Class: `ElectronDownloadManager`](#class-ElectronDownloadManager) 62 | - [`constructor()`](#constructor) 63 | - [`download()`](#download) 64 | - [Interface: `DownloadParams`](#interface-downloadparams) 65 | - [Interface: `DownloadManagerCallbacks`](#interface-downloadmanagercallbacks) 66 | - [`cancelDownload()`](#canceldownload) 67 | - [`pauseDownload()`](#pausedownload) 68 | - [`resumeDownload()`](#resumedownload) 69 | - [`restoreDownload()`](#restoredownload) 70 | - [`getActiveDownloadCount()`](#getactivedownloadcount) 71 | - [`getDownloadData()`](#getdownloaddata) 72 | - [Class: `DownloadData`](#class-downloaddata) 73 | - [Properties](#properties) 74 | - [Formatting download progress](#formatting-download-progress) 75 | - [`isDownloadInProgress()`](#isdownloadinprogress) 76 | - [`isDownloadPaused()`](#isdownloadpaused) 77 | - [`isDownloadResumable()`](#isdownloadresumable) 78 | - [`isDownloadCancelled()`](#isdownloadcancelled) 79 | - [`isDownloadInterrupted()`](#isdownloadinterrupted) 80 | - [`isDownloadCompleted()`](#isdownloadcompleted) 81 | - [`getRestoreDownloadData()`](#getrestoredownloaddata) 82 | - [Mock class](#mock-class) 83 | - [FAQ](#faq) 84 | - [Acknowledgments](#acknowledgments) 85 | 86 | # Installation 87 | 88 | ```bash 89 | $ npm install electron-dl-manager 90 | ``` 91 | 92 | # Getting started 93 | 94 | You'll want to use `electron-dl-manager` in the main process of your 95 | Electron application where you will be handling the file downloads. 96 | 97 | In this example, we use [IPC handlers / invokers](https://www.electronjs.org/docs/latest/tutorial/ipc#pattern-2-renderer-to-main-two-way) 98 | to communicate between the main and renderer processes, but you can 99 | use any IPC strategy you want. 100 | 101 | ```typescript 102 | // MainIpcHandlers.ts 103 | 104 | import { ElectronDownloadManager } from 'electron-dl-manager'; 105 | import { ipcMain } from 'electron'; 106 | 107 | const manager = new ElectronDownloadManager(); 108 | 109 | // Renderer would invoke this handler to start a download 110 | ipcMain.handle('download-file', async (event, args) => { 111 | const { url } = args; 112 | 113 | let downloadId 114 | const browserWindow = BrowserWindow.fromId(event.sender.id) 115 | 116 | // You *must* call manager.download() with await or 117 | // you may get unexpected behavior 118 | downloadId = await manager.download({ 119 | window: browserWindow, 120 | url, 121 | // If you want to download without a save as dialog 122 | saveAsFilename: 'file.zip', 123 | directory: '/directory/where/to/save', 124 | // If you want to download with a save as dialog 125 | saveDialogOptions: { 126 | title: 'Save File', 127 | }, 128 | callbacks: { 129 | // item is an instance of Electron.DownloadItem 130 | onDownloadStarted: async ({ id, item, resolvedFilename }) => { 131 | // Send the download id back to the renderer along 132 | // with some other data 133 | browserWindow.webContents.invoke('download-started', { 134 | id, 135 | // The filename that the file will be saved as 136 | filename: resolvedFilename, 137 | // Get the file size to be downloaded in bytes 138 | totalBytes: item.getTotalBytes(), 139 | }); 140 | }, 141 | onDownloadProgress: async ({ id, item, percentCompleted }) => { 142 | // Send the download progress back to the renderer 143 | browserWindow.webContents.invoke('download-progress', { 144 | id, 145 | percentCompleted, 146 | // Get the number of bytes received so far 147 | bytesReceived: item.getReceivedBytes(), 148 | }); 149 | }, 150 | onDownloadCompleted: async ({ id, item }) => { 151 | // Send the download completion back to the renderer 152 | browserWindow.webContents.invoke('download-completed', { 153 | id, 154 | // Get the path to the file that was downloaded 155 | filePath: item.getSavePath(), 156 | }); 157 | }, 158 | onError: (err, data) => { 159 | // ... handle any errors 160 | } 161 | } 162 | }); 163 | 164 | // Pause the download 165 | manager.pauseDownload(downloadId); 166 | }); 167 | ``` 168 | 169 | # Download Restoration & Persistence 170 | 171 | This section covers advanced download management features that go beyond simple pause/resume functionality. These features are essential for applications that need to handle downloads across different browser windows, app restarts, or when downloads are interrupted by external factors. 172 | 173 | ## When to Use These Features 174 | 175 | ### Regular Pause/Resume vs. Restoration 176 | - 177 | - **Pause/Resume**: Use `pauseDownload()` and `resumeDownload()` when you want to temporarily stop and restart a download within the same browser window and session. 178 | - **Restoration**: Use `restoreDownload()` when you need to resume a download in a different browser window, after the original window has been closed, or when the download manager instance has been destroyed. 179 | - **Persistence**: Use the `persistOnAppClose` option in `download()` when you want downloads to automatically survive app restarts, crashes, or when the user closes the application. 180 | 181 | ## Basic Download Restoration 182 | 183 | ### Interface: `RestoreDownloadConfig` 184 | 185 | ```typescript 186 | interface RestoreDownloadConfig { 187 | /** 188 | * The Electron.App instance 189 | */ 190 | app: Electron.App 191 | /** 192 | * The Electron.BrowserWindow instance where the download should be restored 193 | */ 194 | window: BrowserWindow 195 | /** 196 | * Data required for resuming the download, returned from pauseDownload() 197 | */ 198 | restoreData: RestoreDownloadData 199 | /** 200 | * The callbacks to define to listen for download events 201 | */ 202 | callbacks: DownloadManagerCallbacks 203 | /** 204 | * Electron.DownloadURLOptions to pass to the downloadURL method 205 | * 206 | * @see https://www.electronjs.org/docs/latest/api/session#sesdownloadurlurl-options 207 | */ 208 | downloadURLOptions?: Electron.DownloadURLOptions 209 | } 210 | ``` 211 | 212 | ### Interface: `RestoreDownloadData` 213 | 214 | ```typescript 215 | interface RestoreDownloadData { 216 | /** 217 | * Download id 218 | */ 219 | id: string 220 | /** 221 | * The URL of the download 222 | */ 223 | url: string 224 | /** 225 | * The path and filename where the download will be saved 226 | */ 227 | fileSaveAsPath: string 228 | /** 229 | * The chain of URLs that led to this download 230 | */ 231 | urlChain: string[] 232 | /** 233 | * The MIME type of the file being downloaded 234 | */ 235 | mimeType: string 236 | /** 237 | * The ETag of the download, if available. This is used to resume downloads 238 | */ 239 | eTag: string 240 | /** 241 | * The number of bytes already received 242 | */ 243 | receivedBytes: number 244 | /** 245 | * The total number of bytes to download 246 | */ 247 | totalBytes: number 248 | /** 249 | * The timestamp when the download started 250 | */ 251 | startTime: number 252 | /** 253 | * The percentage of the download that has been completed 254 | */ 255 | percentCompleted: number 256 | /** 257 | * If persistOnAppClose is true, this is the path where the download 258 | * is persisted to. This is used to restore the download later. 259 | */ 260 | persistedFilePath?: string 261 | } 262 | ``` 263 | 264 | ### Example: Basic Download Restoration 265 | 266 | ```typescript 267 | // Pause a download and get restore data 268 | const restoreData = manager.pauseDownload(downloadId); 269 | 270 | if (restoreData) { 271 | // Later, in a different browser window 272 | const newDownloadId = await manager.restoreDownload({ 273 | app, 274 | window: newBrowserWindow, 275 | restoreData, 276 | callbacks: { 277 | onDownloadStarted: async ({ id, item, resolvedFilename }) => { 278 | console.log(`Restored download ${id} started`); 279 | }, 280 | onDownloadProgress: async ({ id, percentCompleted }) => { 281 | console.log(`Restored download ${id} progress: ${percentCompleted}%`); 282 | }, 283 | onDownloadCompleted: async ({ id, item }) => { 284 | console.log(`Restored download ${id} completed`); 285 | }, 286 | onError: (err, data) => { 287 | console.error('Error in restored download:', err); 288 | } 289 | } 290 | }); 291 | } 292 | ``` 293 | 294 | ## Download Persistence 295 | 296 | Version 4.2.0 introduces the ability to automatically persist downloads when the application closes, allowing them to be restored later. This feature is fundamentally different from manual pause/resume because it: 297 | 298 | - **Automatically triggers** when the app is about to close (listens to the `will-quit` event) 299 | - **Preserves download state** including progress, file paths, and metadata 300 | - **Survives app crashes** and unexpected shutdowns 301 | - **Works across app restarts** without requiring user intervention 302 | - **Handles file management** by creating temporary `.download` files that are automatically restored 303 | 304 | ### Enabling Download Persistence 305 | 306 | To enable download persistence, set `persistOnAppClose: true` and provide the `app` instance: 307 | 308 | ```typescript 309 | const id = await manager.download({ 310 | app, // Electron.App instance 311 | window: mainWindow, 312 | url: 'https://example.com/large-file.zip', 313 | saveAsFilename: 'large-file.zip', 314 | persistOnAppClose: true, 315 | callbacks: { 316 | onDownloadPersisted: async (data, restoreData) => { 317 | console.log('Download persisted:', restoreData.persistedFilePath); 318 | // Save restoreData to a file or database for later restoration 319 | writeFileSync('download-metadata.json', JSON.stringify(restoreData)); 320 | }, 321 | onDownloadCompleted: async (data) => { 322 | console.log('Download completed'); 323 | }, 324 | onError: (err, data) => { 325 | console.error('Download error:', err); 326 | } 327 | } 328 | }); 329 | ``` 330 | 331 | ### Restoring Persisted Downloads 332 | 333 | When the app restarts, you can restore persisted downloads using the saved metadata: 334 | 335 | ```typescript 336 | // Read the saved metadata 337 | const metadata = JSON.parse(readFileSync('download-metadata.json', 'utf-8')); 338 | 339 | // Restore the download 340 | await manager.restoreDownload({ 341 | app, 342 | window: mainWindow, 343 | restoreData: metadata, 344 | callbacks: { 345 | onDownloadStarted: async (data) => { 346 | console.log('Persisted download restored and started'); 347 | }, 348 | onDownloadProgress: async (data) => { 349 | console.log(`Progress: ${data.percentCompleted}%`); 350 | }, 351 | onDownloadCompleted: async (data) => { 352 | console.log('Persisted download completed'); 353 | }, 354 | onError: (err, data) => { 355 | console.error('Error in restored download:', err); 356 | } 357 | } 358 | }); 359 | ``` 360 | 361 | **Note:** The `persistedFilePath` in the restore data points to a temporary file with a `.download` extension. The library automatically handles moving this file to the correct location when restoring. 362 | 363 | # API 364 | 365 | ## Class: `ElectronDownloadManager` 366 | 367 | Manages file downloads in an Electron application. 368 | 369 | ### `constructor()` 370 | 371 | ```typescript 372 | constructor(params: DownloadManagerConstructorParams) 373 | ``` 374 | 375 | ```typescript 376 | interface DownloadManagerConstructorParams { 377 | /** 378 | * If defined, will log out internal debug messages. Useful for 379 | * troubleshooting downloads. Does not log out progress due to 380 | * how frequent it can be. 381 | */ 382 | debugLogger?: (message: string) => void 383 | } 384 | ``` 385 | 386 | ### `download()` 387 | 388 | Starts a file download. Returns the `id` of the download. 389 | 390 | ```typescript 391 | download(params: DownloadParams): Promise 392 | ``` 393 | 394 | #### Interface: `DownloadParams` 395 | 396 | ```typescript 397 | interface DownloadParams { 398 | /** 399 | * The Electron.BrowserWindow instance 400 | */ 401 | window: BrowserWindow 402 | /** 403 | * The URL to download 404 | */ 405 | url: string 406 | /** 407 | * The callbacks to define to listen for download events 408 | */ 409 | callbacks: DownloadManagerCallbacks 410 | /** 411 | * Electron.DownloadURLOptions to pass to the downloadURL method 412 | * 413 | * @see https://www.electronjs.org/docs/latest/api/session#sesdownloadurlurl-options 414 | */ 415 | downloadURLOptions?: Electron.DownloadURLOptions 416 | /** 417 | * If defined, will show a save dialog when the user 418 | * downloads a file. 419 | * 420 | * @see https://www.electronjs.org/docs/latest/api/dialog#dialogshowsavedialogbrowserwindow-options 421 | */ 422 | saveDialogOptions?: SaveDialogOptions 423 | /** 424 | * The filename to save the file as. If not defined, the filename 425 | * from the server will be used. 426 | * 427 | * Only applies if saveDialogOptions is not defined. 428 | */ 429 | saveAsFilename?: string 430 | /** 431 | * The directory to save the file to. Must be an absolute path. 432 | * @default The user's downloads directory 433 | */ 434 | directory?: string 435 | /** 436 | * If true, will overwrite the file if it already exists 437 | * @default false 438 | */ 439 | overwrite?: boolean 440 | /** 441 | * If true, will persist the download when the app closes, allowing it to be restored later. 442 | * Requires the `app` parameter to be provided. 443 | * @default false 444 | */ 445 | persistOnAppClose?: boolean 446 | /** 447 | * The Electron.App instance. Required if persistOnAppClose is enabled. 448 | */ 449 | app?: Electron.App 450 | } 451 | ``` 452 | 453 | #### Interface: `DownloadManagerCallbacks` 454 | 455 | ```typescript 456 | interface DownloadManagerCallbacks { 457 | /** 458 | * When the download has started. When using a "save as" dialog, 459 | * this will be called after the user has selected a location. 460 | * 461 | * This will always be called first before the progress and completed events. 462 | */ 463 | onDownloadStarted: (data: DownloadData) => void 464 | /** 465 | * When there is a progress update on a download. Note: This 466 | * may be skipped entirely in some cases, where the download 467 | * completes immediately. In that case, onDownloadCompleted 468 | * will be called instead. 469 | */ 470 | onDownloadProgress: (data: DownloadData) => void 471 | /** 472 | * When the download has completed 473 | */ 474 | onDownloadCompleted: (data: DownloadData) => void 475 | /** 476 | * When the download has been cancelled. Also called if the user cancels 477 | * from the save as dialog. 478 | */ 479 | onDownloadCancelled: (data: DownloadData) => void 480 | /** 481 | * When the download has been interrupted. This could be due to a bad 482 | * connection, the server going down, etc. 483 | */ 484 | onDownloadInterrupted: (data: DownloadData) => void 485 | /** 486 | * When the download has been persisted for later restoration. 487 | * This callback is called when persistOnAppClose is enabled and the app is about to close. 488 | */ 489 | onDownloadPersisted?: (data: DownloadData, restoreDownloadData: RestoreDownloadData) => void 490 | /** 491 | * When an error has been encountered. 492 | * Note: The signature is (error, ). 493 | */ 494 | onError: (error: Error, data?: DownloadData) => void 495 | } 496 | ``` 497 | 498 | ### `cancelDownload()` 499 | 500 | Cancels a download. 501 | 502 | ```typescript 503 | cancelDownload(id: string): void 504 | ``` 505 | 506 | ### `pauseDownload()` 507 | 508 | Pauses a download and returns the data necessary to restore it later via `restoreDownload()`. 509 | 510 | ```typescript 511 | pauseDownload(id: string): RestoreDownloadData | undefined 512 | ``` 513 | 514 | **Returns:** `RestoreDownloadData` if the download exists and can be paused, `undefined` if the download is not found. 515 | 516 | **Note:** Use the returned data with `restoreDownload()` to restore a download. 517 | 518 | ### `resumeDownload()` 519 | 520 | Resumes a download. 521 | 522 | ```typescript 523 | resumeDownload(id: string): void 524 | ``` 525 | 526 | ### `restoreDownload()` 527 | 528 | Restores a download that is not registered in the download manager using data returned from `pauseDownload()`. This is useful when you need to restore a download in a different browser window or after the original window has been closed. 529 | 530 | If the download is already registered in the current download manager, this method will call `resumeDownload()` instead. 531 | 532 | ```typescript 533 | restoreDownload(params: RestoreDownloadConfig): Promise 534 | ``` 535 | 536 | **Note:** See the [Download Restoration & Persistence](#download-restoration--persistence) section for detailed information about restoring downloads and using the persistence feature. 537 | 538 | ### `getActiveDownloadCount()` 539 | 540 | Returns the number of active downloads. 541 | 542 | ```typescript 543 | getActiveDownloadCount(): number 544 | ``` 545 | 546 | ### `getDownloadData()` 547 | 548 | Returns the download data for a download. 549 | 550 | ```typescript 551 | getDownloadData(id: string): DownloadData 552 | ``` 553 | 554 | ## Class: `DownloadData` 555 | 556 | Data returned in the callbacks for a download. 557 | 558 | ### Properties 559 | 560 | ```typescript 561 | class DownloadData { 562 | /** 563 | * Generated id for the download 564 | */ 565 | id: string 566 | /** 567 | * The Electron.DownloadItem. Use this to grab the filename, path, etc. 568 | * @see https://www.electronjs.org/docs/latest/api/download-item 569 | */ 570 | item: DownloadItem 571 | /** 572 | * The Electron.WebContents 573 | * @see https://www.electronjs.org/docs/latest/api/web-contents 574 | */ 575 | webContents: WebContents 576 | /** 577 | * The Electron.Event 578 | * @see https://www.electronjs.org/docs/latest/api/event 579 | */ 580 | event: Event 581 | /** 582 | * The name of the file that is being saved to the user's computer. 583 | * Recommended over Item.getFilename() as it may be inaccurate when using the save as dialog. 584 | */ 585 | resolvedFilename: string 586 | /** 587 | * If true, the download was cancelled from the save as dialog. This flag 588 | * will also be true if the download was cancelled by the application when 589 | * using the save as dialog. 590 | */ 591 | cancelledFromSaveAsDialog?: boolean 592 | /** 593 | * The percentage of the download that has been completed 594 | */ 595 | percentCompleted: number 596 | /** 597 | * The download rate in bytes per second. 598 | */ 599 | downloadRateBytesPerSecond: number 600 | /** 601 | * The estimated time remaining in seconds. 602 | */ 603 | estimatedTimeRemainingSeconds: number 604 | /** 605 | * If the download was interrupted, the state in which it was interrupted from 606 | */ 607 | interruptedVia?: 'in-progress' | 'completed' 608 | /** 609 | * If defined, this is the path where the download is persisted to. 610 | * This is set when persistOnAppClose is enabled and the download is persisted. 611 | */ 612 | persistedFilePath?: string 613 | } 614 | ``` 615 | 616 | #### Formatting download progress 617 | 618 | You can use the libraries [`bytes`](https://www.npmjs.com/package/bytes) and [`dayjs`](https://www.npmjs.com/package/dayjs) to format the download progress. 619 | 620 | ```bash 621 | $ pnpm add bytes dayjs 622 | $ pnpm add -D @types/bytes 623 | ``` 624 | 625 | ```typescript 626 | import bytes from 'bytes' 627 | import dayjs from 'dayjs' 628 | import relativeTime from 'dayjs/plugin/relativeTime'; 629 | import duration from 'dayjs/plugin/duration'; 630 | 631 | dayjs.extend(relativeTime); 632 | dayjs.extend(duration); 633 | 634 | const downloadData = manager.getDownloadData(id); // or DataItem from the callbacks 635 | 636 | // Will return something like 1.2 MB/s 637 | const formattedDownloadRate = bytes(downloadData.downloadRateBytesPerSecond, { unitSeparator: ' ' }) + '/s' 638 | 639 | // Will return something like "in a few seconds" 640 | const formattedEstimatedTimeRemaining = dayjs.duration(downloadData.estimatedTimeRemainingSeconds, 'seconds').humanize(true) 641 | ``` 642 | 643 | ### `isDownloadInProgress()` 644 | 645 | Returns true if the download is in progress. 646 | 647 | ```typescript 648 | isDownloadInProgress(): boolean 649 | ``` 650 | 651 | ### `isDownloadPaused()` 652 | 653 | Returns true if the download is paused. 654 | 655 | ```typescript 656 | isDownloadPaused(): boolean 657 | ``` 658 | 659 | ### `isDownloadResumable()` 660 | 661 | Returns true if the download is resumable. 662 | 663 | ```typescript 664 | isDownloadResumable(): boolean 665 | ``` 666 | 667 | ### `isDownloadCancelled()` 668 | 669 | Returns true if the download is cancelled. 670 | 671 | ```typescript 672 | isDownloadCancelled(): boolean 673 | ``` 674 | 675 | ### `isDownloadInterrupted()` 676 | 677 | Returns true if the download is interrupted. 678 | 679 | ```typescript 680 | isDownloadInterrupted(): boolean 681 | ``` 682 | 683 | ### `isDownloadCompleted()` 684 | 685 | Returns true if the download is completed. 686 | 687 | ```typescript 688 | isDownloadCompleted(): boolean 689 | ``` 690 | 691 | ### `getRestoreDownloadData()` 692 | 693 | Returns the data necessary to restore this download later via `restoreDownload()`. This method is typically called after pausing a download to get the data needed for restoration. 694 | 695 | ```typescript 696 | getRestoreDownloadData(): RestoreDownloadData 697 | ``` 698 | 699 | **Returns:** `RestoreDownloadData` containing all the information needed to restore the download, including the file path, URL, MIME type, ETag, and byte information. 700 | 701 | # Mock class 702 | 703 | If you need to mock out `ElectronDownloadManager` in your tests, you can use the `ElectronDownloadManagerMock` class. 704 | 705 | `import { ElectronDownloadManagerMock } from 'electron-dl-manager'` 706 | 707 | # FAQ 708 | 709 | ## How do I capture if the download is invalid? `onError()` is not being called. 710 | 711 | Electron `DownloadItem` doesn't provide an explicit way to capture errors for downloads in general: 712 | 713 | https://www.electronjs.org/docs/latest/api/download-item#class-downloaditem 714 | 715 | (It only has `on('updated')` and `on('done')` events, which this library uses for defining the callback handlers.) 716 | 717 | What it does for invalid URLs, it will trigger the `onDownloadCancelled()` callback. 718 | 719 | ```typescript 720 | const id = await manager.download({ 721 | window: mainWindow, 722 | url: 'https://alkjsdflksjdflk.com/file.zip', 723 | callbacks: { 724 | onDownloadCancelled: async (...) => { 725 | // Invalid download; this callback will be called 726 | }, 727 | } 728 | }); 729 | ``` 730 | 731 | A better way to handle this is to check if the URL exists prior to the download yourself. 732 | I couldn't find a library that I felt was reliable to include into this package, 733 | so it's best you find a library that works for you: 734 | 735 | - https://www.npmjs.com/search?q=url%20exists&ranking=maintenance 736 | 737 | GPT also suggests the following code (untested): 738 | 739 | ```typescript 740 | async function urlExists(url: string): Promise { 741 | try { 742 | const response = await fetch(url, { method: 'HEAD' }); 743 | return response.ok; 744 | } catch (error) { 745 | return false; 746 | } 747 | } 748 | 749 | const exists = await urlExists('https://example.com/file.jpg'); 750 | ``` 751 | 752 | # Acknowledgments 753 | 754 | This code uses small portions from [`electron-dl`](https://github.com/sindresorhus/electron-dl) and is noted in the 755 | code where it is used. 756 | 757 | `electron-dl` is licensed under the MIT License and is maintained by Sindre Sorhus (https://sindresorhus.com). --------------------------------------------------------------------------------