@ohos-ports/electron-dl 0.0.0-stage → 5.0.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.d.ts ADDED
@@ -0,0 +1,255 @@
1
+ import {
2
+ type BrowserWindow,
3
+ type DownloadItem,
4
+ type SaveDialogOptions,
5
+ type WebContentsView,
6
+ } from 'electron';
7
+
8
+ export type Progress = {
9
+ percent: number;
10
+ transferredBytes: number;
11
+ totalBytes: number;
12
+ };
13
+
14
+ export type File = {
15
+ filename: string;
16
+ path: string;
17
+ fileSize: number;
18
+ mimeType: string;
19
+ url: string;
20
+ };
21
+
22
+ export type Options = {
23
+ /**
24
+ Show a `Save As…` dialog instead of downloading immediately.
25
+
26
+ Note: Only use this option when strictly necessary. Downloading directly without a prompt is a much better user experience.
27
+
28
+ @default false
29
+ */
30
+ readonly saveAs?: boolean;
31
+
32
+ /**
33
+ The directory to save the file in.
34
+
35
+ Must be an absolute path.
36
+
37
+ Default: [User's downloads directory](https://electronjs.org/docs/api/app/#appgetpathname)
38
+ */
39
+ readonly directory?: string;
40
+
41
+ /**
42
+ Name of the saved file.
43
+
44
+ Must not contain a path separator, so it cannot point outside `directory`.
45
+
46
+ This option only makes sense for `electronDl.download()`.
47
+
48
+ Default: [`downloadItem.getFilename()`](https://electronjs.org/docs/api/download-item/#downloaditemgetfilename)
49
+ */
50
+ readonly filename?: string;
51
+
52
+ /**
53
+ Title of the error dialog. Can be customized for localization.
54
+
55
+ Note: Error dialog will not be shown in `electronDl.download()`. Please handle error manually.
56
+
57
+ @default 'Download Error'
58
+ */
59
+ readonly errorTitle?: string;
60
+
61
+ /**
62
+ Message of the error dialog. `{filename}` is replaced with the name of the actual file. Can be customized for localization.
63
+
64
+ Note: Error dialog will not be shown in `electronDl.download()`. Please handle error manually.
65
+
66
+ @default 'The download of {filename} was interrupted'
67
+ */
68
+ readonly errorMessage?: string;
69
+
70
+ /**
71
+ Optional callback that receives the [download item](https://electronjs.org/docs/api/download-item).
72
+ You can use this for advanced handling such as canceling the item like `item.cancel()`.
73
+ */
74
+ readonly onStarted?: (item: DownloadItem) => void;
75
+
76
+ /**
77
+ Optional callback that receives an object containing information about the progress of the current download item.
78
+ */
79
+ readonly onProgress?: (progress: Progress) => void;
80
+
81
+ /**
82
+ Optional callback that receives an object containing information about the combined progress of all download items done within any registered window.
83
+
84
+ Each time a new download is started, the next callback will include it. The progress percentage could therefore become smaller again.
85
+ This callback provides the same data that is used for the progress bar on the app icon.
86
+ */
87
+ readonly onTotalProgress?: (progress: Progress) => void;
88
+
89
+ /**
90
+ Optional callback that receives the [download item](https://electronjs.org/docs/api/download-item) for which the download has been cancelled.
91
+ */
92
+ readonly onCancel?: (item: DownloadItem) => void;
93
+
94
+ /**
95
+ Optional callback that receives an object with information about an item that has been completed. It is called for each completed item.
96
+ */
97
+ readonly onCompleted?: (file: File) => void;
98
+
99
+ /**
100
+ Optional callback that receives the `Error` for a download that was interrupted and could not be resumed.
101
+
102
+ When this is provided, the error dialog is not shown.
103
+
104
+ This option only makes sense for the default export. `electronDl.download()` rejects with the error instead.
105
+
106
+ @example
107
+ ```
108
+ import electronDl from 'electron-dl';
109
+
110
+ electronDl({
111
+ onError(error) {
112
+ console.error(error.message);
113
+ }
114
+ });
115
+ ```
116
+ */
117
+ readonly onError?: (error: Error) => void;
118
+
119
+ /**
120
+ Reveal the downloaded file in the system file manager, and if possible, select the file.
121
+
122
+ @default false
123
+ */
124
+ readonly openFolderWhenDone?: boolean;
125
+
126
+ /**
127
+ Show a file count badge on the macOS/Linux dock/taskbar icon when a download is in progress.
128
+
129
+ @default true
130
+ */
131
+ readonly showBadge?: boolean;
132
+
133
+ /**
134
+ Show a progress bar on the dock/taskbar icon when a download is in progress.
135
+
136
+ Downloads without an associated `BrowserWindow` are saved normally, but do not show a progress bar.
137
+
138
+ @default true
139
+ */
140
+ readonly showProgressBar?: boolean;
141
+
142
+ /**
143
+ Allow downloaded files to overwrite files with the same name in the directory they are saved to.
144
+
145
+ The default behavior is to append a number to the filename.
146
+
147
+ @default false
148
+ */
149
+ readonly overwrite?: boolean;
150
+
151
+ /**
152
+ Customize the save dialog.
153
+
154
+ If `defaultPath` is not explicity defined, a default value is assigned based on the file path.
155
+
156
+ @default {}
157
+ */
158
+ readonly dialogOptions?: SaveDialogOptions;
159
+ };
160
+
161
+ export type BytesOptions = {
162
+ /**
163
+ Optional callback that receives an object containing information about the progress of the download.
164
+
165
+ `percent` is `0` when the total size is not known, for example for a compressed or chunked response.
166
+ */
167
+ readonly onProgress?: (progress: Progress) => void;
168
+
169
+ /**
170
+ An [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal) to cancel the download with. An aborted download rejects with the signal's abort reason, which defaults to an `AbortError`.
171
+ */
172
+ readonly signal?: AbortSignal;
173
+ };
174
+
175
+ /**
176
+ Error thrown if `item.cancel()` was called.
177
+ */
178
+ export class CancelError extends Error {}
179
+
180
+ /**
181
+ Register the helper for all windows.
182
+
183
+ @example
184
+ ```
185
+ import {app, BrowserWindow} from 'electron';
186
+ import electronDl from 'electron-dl';
187
+
188
+ electronDl();
189
+
190
+ let mainWindow;
191
+ (async () => {
192
+ await app.whenReady();
193
+ mainWindow = new BrowserWindow();
194
+ })();
195
+ ```
196
+ */
197
+ export default function electronDl(options?: Options): void;
198
+
199
+ /**
200
+ This can be useful if you need download functionality in a reusable module.
201
+
202
+ An interrupted download is resumed automatically when the server supports it, as long as the app is running. A download is not resumed after the app is restarted, since that would need the app to persist the download state.
203
+
204
+ @param window - Window to register the behavior on.
205
+ @param url - URL to download.
206
+ @returns A promise for the downloaded file.
207
+ @throws {CancelError} An error if the user calls `item.cancel()`.
208
+ @throws {Error} An error if the download fails.
209
+
210
+ @example
211
+ ```
212
+ import {BrowserWindow, ipcMain} from 'electron';
213
+ import {download} from 'electron-dl';
214
+
215
+ ipcMain.on('download-button', async (event, {url}) => {
216
+ const win = BrowserWindow.getFocusedWindow();
217
+ console.log(await download(win, url));
218
+ });
219
+ ```
220
+ */
221
+ export function download(
222
+ window: BrowserWindow | WebContentsView,
223
+ url: string,
224
+ options?: Options,
225
+ ): Promise<DownloadItem>;
226
+
227
+ /**
228
+ Download a file into memory instead of saving it to disk. Use this when you want to process the file directly.
229
+
230
+ The download does not appear in the Downloads directory. It is not shown in the dock/taskbar badge or progress bar, and it cannot be resumed.
231
+
232
+ The promise rejects when the server responds with an error status.
233
+
234
+ @param window - Window to download with.
235
+ @param url - URL to download.
236
+ @returns A promise for the contents of the downloaded file.
237
+ @throws {Error} An error if the download fails.
238
+
239
+ @example
240
+ ```
241
+ import {BrowserWindow, ipcMain} from 'electron';
242
+ import {downloadAsBytes} from 'electron-dl';
243
+
244
+ ipcMain.on('download-button', async (event, {url}) => {
245
+ const win = BrowserWindow.getFocusedWindow();
246
+ const bytes = await downloadAsBytes(win, url);
247
+ console.log(bytes.length);
248
+ });
249
+ ```
250
+ */
251
+ export function downloadAsBytes(
252
+ window: BrowserWindow | WebContentsView,
253
+ url: string,
254
+ options?: BytesOptions,
255
+ ): Promise<Uint8Array>;
package/index.js ADDED
@@ -0,0 +1,317 @@
1
+ import process from 'node:process';
2
+ import path from 'node:path';
3
+ import {
4
+ app,
5
+ BrowserWindow,
6
+ shell,
7
+ dialog,
8
+ session,
9
+ } from 'electron';
10
+ import {assertSafeFilename} from 'is-safe-filename';
11
+ import {unusedFilenameSync} from 'unused-filename';
12
+ import pupa from 'pupa';
13
+ import extName from 'ext-name';
14
+
15
+ export class CancelError extends Error {}
16
+
17
+ // The items already taken by a `download()` call, so that concurrent calls for the same URL each get their own item.
18
+ const claimedItems = new WeakSet();
19
+ // Items whose interruption has already been reported (OpenHarmony fail-fast path).
20
+ const interruptedItems = new WeakSet();
21
+
22
+ const getFilenameFromMime = (name, mime) => {
23
+ const extensions = extName.mime(mime);
24
+
25
+ return extensions.length === 1 ? `${name}.${extensions[0].ext}` : name;
26
+ };
27
+
28
+ function registerListener(session, options, callback = () => {}, {ownerWindow, url} = {}) {
29
+ options = {
30
+ showBadge: true,
31
+ showProgressBar: true,
32
+ ...options,
33
+ };
34
+
35
+ // Checked here instead of in the listener, where a throw would escape as an uncaught exception and leave the `download()` promise unsettled.
36
+ if (options.directory && !path.isAbsolute(options.directory)) {
37
+ throw new Error('The `directory` option must be an absolute path');
38
+ }
39
+
40
+ // A `filename` with a path separator could escape `directory`, so it has to be a plain filename.
41
+ if (options.filename !== undefined) {
42
+ assertSafeFilename(options.filename);
43
+ }
44
+
45
+ const directory = options.directory ?? app.getPath('downloads');
46
+ const downloadItems = new Set();
47
+ let receivedBytes = 0;
48
+ let completedBytes = 0;
49
+ let totalBytes = 0;
50
+ const activeDownloadItems = () => downloadItems.size;
51
+ // The total size is 0 when it is not known, for example for a response without `Content-Length`.
52
+ const progressDownloadItems = () => totalBytes === 0 ? 0 : receivedBytes / totalBytes;
53
+
54
+ const listener = (event, item, webContents) => {
55
+ // Every `will-download` listener is notified of every download on the session, so `download()` ignores the items of other calls and only ever takes a single item.
56
+ if (url !== undefined) {
57
+ if (item.getURLChain().at(0) !== url || claimedItems.has(item)) {
58
+ return;
59
+ }
60
+
61
+ claimedItems.add(item);
62
+ session.removeListener('will-download', listener);
63
+ }
64
+
65
+ downloadItems.add(item);
66
+ totalBytes += item.getTotalBytes();
67
+
68
+ // `webContents` is null for `session.downloadURL()`, and there is no window for a detached `WebContentsView`.
69
+ const window_ = ownerWindow ?? (webContents ? BrowserWindow.fromWebContents(webContents) : undefined);
70
+
71
+ let name;
72
+ if (options.filename) {
73
+ name = options.filename;
74
+ } else {
75
+ const filename = item.getFilename();
76
+ name = path.extname(filename) ? filename : getFilenameFromMime(filename, item.getMimeType());
77
+ }
78
+
79
+ const filePath = options.overwrite ? path.join(directory, name) : unusedFilenameSync(path.join(directory, name));
80
+
81
+ const errorMessage = options.errorMessage ?? 'The download of {filename} was interrupted';
82
+
83
+ if (options.saveAs) {
84
+ item.setSaveDialogOptions({defaultPath: filePath, ...options.dialogOptions});
85
+ } else {
86
+ item.setSavePath(filePath);
87
+ }
88
+
89
+ item.on('updated', (_event, state) => {
90
+ receivedBytes = completedBytes;
91
+ for (const activeItem of downloadItems) {
92
+ receivedBytes += activeItem.getReceivedBytes();
93
+ }
94
+
95
+ if (options.showBadge && ['darwin', 'linux'].includes(process.platform)) {
96
+ app.badgeCount = activeDownloadItems();
97
+ }
98
+
99
+ if (window_ && !window_.isDestroyed() && options.showProgressBar) {
100
+ window_.setProgressBar(progressDownloadItems());
101
+ }
102
+
103
+ if (typeof options.onProgress === 'function') {
104
+ const itemTransferredBytes = item.getReceivedBytes();
105
+ const itemTotalBytes = item.getTotalBytes();
106
+
107
+ options.onProgress({
108
+ percent: itemTotalBytes ? itemTransferredBytes / itemTotalBytes : 0,
109
+ transferredBytes: itemTransferredBytes,
110
+ totalBytes: itemTotalBytes,
111
+ });
112
+ }
113
+
114
+ if (typeof options.onTotalProgress === 'function') {
115
+ options.onTotalProgress({
116
+ percent: progressDownloadItems(),
117
+ transferredBytes: receivedBytes,
118
+ totalBytes,
119
+ });
120
+ }
121
+
122
+ // Chromium stops retrying an interrupted download without ending it, so resume it when the server supports it.
123
+ // Resuming has to happen outside this handler, since Chromium asserts when a download re-enters its own observer notification.
124
+ if (state === 'interrupted' && item.canResume()) {
125
+ if (process.platform === 'openharmony') {
126
+ // OpenHarmony adaptation: ANY call to `item.resume()` on
127
+ // HarmonyOS PC Electron deadlocks the main process event
128
+ // loop (verified on Electron 34 — immediate, backoff-spaced
129
+ // and capped retries all reproduced it). Never resume here;
130
+ // fail fast so the caller can restart the download itself.
131
+ if (!interruptedItems.has(item)) {
132
+ interruptedItems.add(item);
133
+ const message = pupa(errorMessage, {filename: path.basename(filePath)});
134
+ callback(new Error(message + ' (auto-resume is not available on openharmony; restart the download to recover)'));
135
+ item.cancel();
136
+ }
137
+ } else {
138
+ setTimeout(() => {
139
+ if (item.canResume()) {
140
+ item.resume();
141
+ }
142
+ }, 0);
143
+ }
144
+ }
145
+ });
146
+
147
+ item.on('done', (_event, state) => {
148
+ completedBytes += item.getTotalBytes();
149
+ downloadItems.delete(item);
150
+
151
+ if (options.showBadge && ['darwin', 'linux'].includes(process.platform)) {
152
+ app.badgeCount = activeDownloadItems();
153
+ }
154
+
155
+ if (!activeDownloadItems()) {
156
+ if (window_ && !window_.isDestroyed()) {
157
+ window_.setProgressBar(-1);
158
+ }
159
+
160
+ receivedBytes = 0;
161
+ completedBytes = 0;
162
+ totalBytes = 0;
163
+ }
164
+
165
+ // eslint-disable-next-line unicorn/prefer-switch
166
+ if (state === 'cancelled') {
167
+ if (typeof options.onCancel === 'function') {
168
+ options.onCancel(item);
169
+ }
170
+
171
+ callback(new CancelError());
172
+ } else if (state === 'interrupted') {
173
+ const message = pupa(errorMessage, {filename: path.basename(filePath)});
174
+ callback(new Error(message));
175
+ } else if (state === 'completed') {
176
+ const savePath = item.getSavePath();
177
+
178
+ if (process.platform === 'darwin') {
179
+ app.dock.downloadFinished(savePath);
180
+ }
181
+
182
+ if (options.openFolderWhenDone) {
183
+ shell.showItemInFolder(savePath);
184
+ }
185
+
186
+ if (typeof options.onCompleted === 'function') {
187
+ options.onCompleted({
188
+ fileName: item.getFilename(), // Just for backwards compatibility. TODO: Remove in the next major version.
189
+ filename: item.getFilename(),
190
+ path: savePath,
191
+ fileSize: item.getReceivedBytes(),
192
+ mimeType: item.getMimeType(),
193
+ url: item.getURL(),
194
+ });
195
+ }
196
+
197
+ callback(null, item);
198
+ }
199
+ });
200
+
201
+ if (typeof options.onStarted === 'function') {
202
+ options.onStarted(item);
203
+ }
204
+ };
205
+
206
+ session.on('will-download', listener);
207
+ }
208
+
209
+ export default function electronDl(options = {}) {
210
+ const handleError = error => {
211
+ if (!error || error instanceof CancelError) {
212
+ return;
213
+ }
214
+
215
+ if (typeof options.onError === 'function') {
216
+ options.onError(error);
217
+ return;
218
+ }
219
+
220
+ // OpenHarmony adaptation: `dialog.showErrorBox()` is a synchronous modal
221
+ // dialog that blocks the main process event loop until dismissed — with
222
+ // nobody at the desktop this freezes the whole app (all timers and IPC
223
+ // stop). Log non-blockingly instead; pass `onError` for custom handling.
224
+ if (process.platform === 'openharmony') {
225
+ console.error(`[electron-dl] ${options.errorTitle ?? 'Download Error'}: ${error.message}`);
226
+ return;
227
+ }
228
+
229
+ const errorTitle = options.errorTitle ?? 'Download Error';
230
+ dialog.showErrorBox(errorTitle, error.message);
231
+ };
232
+
233
+ const registered = new WeakSet();
234
+ const register = session_ => {
235
+ if (registered.has(session_)) {
236
+ return;
237
+ }
238
+
239
+ registered.add(session_);
240
+ registerListener(session_, options, handleError);
241
+ };
242
+
243
+ app.on('session-created', register);
244
+
245
+ // OpenHarmony adaptation: on HarmonyOS PC Electron the 'session-created'
246
+ // event may not fire for sessions that already exist, and
247
+ // `session.getAllSessions()` is not available on Electron 34 — both left
248
+ // registration-mode downloads without a 'will-download' listener and they
249
+ // never completed. Register synchronously on the default session (the
250
+ // README headline usage: app windows without custom partitions). Gated to
251
+ // openharmony so behavior on other platforms is unchanged.
252
+ if (process.platform === 'openharmony') {
253
+ register(session.defaultSession);
254
+ }
255
+ }
256
+
257
+ export async function download(window_, url, options) {
258
+ return new Promise((resolve, reject) => {
259
+ const {session} = window_.webContents;
260
+
261
+ // Chromium normalizes the URL, so it must be normalized here too for the item to be recognized as this call's download.
262
+ const normalizedUrl = new URL(url).href;
263
+
264
+ // Start the download from the session instead of the `webContents` so it is not subject to the page's origin checks.
265
+ registerListener(session, options, (error, item) => {
266
+ if (error) {
267
+ reject(error);
268
+ return;
269
+ }
270
+
271
+ resolve(item);
272
+ }, {
273
+ ownerWindow: BrowserWindow.fromWebContents(window_.webContents),
274
+ url: normalizedUrl,
275
+ });
276
+
277
+ session.downloadURL(normalizedUrl);
278
+ });
279
+ }
280
+
281
+ export async function downloadAsBytes(window_, url, options = {}) {
282
+ const {session} = window_.webContents;
283
+ const response = await session.fetch(url, {signal: options.signal});
284
+
285
+ if (!response.ok) {
286
+ throw new Error(`The download of ${url} failed with status ${response.status}`);
287
+ }
288
+
289
+ // Chromium decompresses the body, so for an encoded response `Content-Length` is the compressed size and cannot be used for progress.
290
+ const isEncoded = response.headers.get('content-encoding') !== null;
291
+ const totalBytes = isEncoded ? 0 : Number(response.headers.get('content-length')) || 0;
292
+ const chunks = [];
293
+ let transferredBytes = 0;
294
+
295
+ // `response.body` is null for responses that cannot have a body, for example `204`.
296
+ for await (const chunk of response.body ?? []) {
297
+ chunks.push(chunk);
298
+ transferredBytes += chunk.length;
299
+
300
+ if (typeof options.onProgress === 'function') {
301
+ options.onProgress({
302
+ percent: totalBytes === 0 ? 0 : transferredBytes / totalBytes,
303
+ transferredBytes,
304
+ totalBytes,
305
+ });
306
+ }
307
+ }
308
+
309
+ const bytes = new Uint8Array(transferredBytes);
310
+ let offset = 0;
311
+ for (const chunk of chunks) {
312
+ bytes.set(chunk, offset);
313
+ offset += chunk.length;
314
+ }
315
+
316
+ return bytes;
317
+ }
package/license ADDED
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
package/package.json CHANGED
@@ -1,6 +1,62 @@
1
1
  {
2
2
  "name": "@ohos-ports/electron-dl",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
3
+ "version": "5.0.0-beta.0",
4
+ "description": "Simplified file downloads for your Electron app",
5
+ "license": "MIT",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "https://github.com/ohos-ports/ohos-ports.git",
9
+ "directory": "ports/@ohos-ports/electron-dl/5.0.0"
10
+ },
11
+ "funding": "https://github.com/sponsors/sindresorhus",
12
+ "author": "Sindre Sorhus <sindresorhus@gmail.com> (https://sindresorhus.com)",
13
+ "type": "module",
14
+ "exports": {
15
+ "types": "./index.d.ts",
16
+ "default": "./index.js"
17
+ },
18
+ "sideEffects": false,
19
+ "engines": {
20
+ "node": ">=22"
21
+ },
22
+ "scripts": {
23
+ "start": "electron run.js",
24
+ "test": "xo && ava test/downloads.js && tsc index.d.ts"
25
+ },
26
+ "files": [
27
+ "index.js",
28
+ "index.d.ts"
29
+ ],
30
+ "keywords": [
31
+ "electron",
32
+ "app",
33
+ "file",
34
+ "download",
35
+ "downloader",
36
+ "progress"
37
+ ],
38
+ "dependencies": {
39
+ "ext-name": "^5.0.0",
40
+ "is-safe-filename": "^0.1.1",
41
+ "pupa": "^3.3.0",
42
+ "unused-filename": "^4.0.1"
43
+ },
44
+ "devDependencies": {
45
+ "@types/node": "^26.6.2",
46
+ "ava": "^8.0.1",
47
+ "electron": "^44.4.3",
48
+ "minimist": "^1.2.8",
49
+ "node-static": "^0.7.11",
50
+ "typescript": "^6.0.3",
51
+ "xo": "^5.0.1"
52
+ },
53
+ "xo": {
54
+ "ignores": [
55
+ "mock"
56
+ ]
57
+ },
58
+ "ava": {
59
+ "serial": true,
60
+ "timeout": "1m"
61
+ }
6
62
  }
package/readme.md ADDED
@@ -0,0 +1,367 @@
1
+ # electron-dl
2
+
3
+ > Simplified file downloads for your [Electron](https://electronjs.org) app
4
+
5
+ ## Why?
6
+
7
+ - One function call instead of having to manually implement a lot of [boilerplate](index.js).
8
+ - Saves the file to the users Downloads directory instead of prompting.
9
+ - Bounces the Downloads directory in the dock when done. *(macOS)*
10
+ - Handles multiple downloads.
11
+ - Resumes interrupted downloads when the server supports it, while the app is running.
12
+ - Shows badge count *(macOS & Linux only)* and download progress. Example on macOS:
13
+
14
+ <img src="screenshot.png" width="82">
15
+
16
+ ## Install
17
+
18
+ ```sh
19
+ npm install electron-dl
20
+ ```
21
+
22
+ *Requires Electron 44 or later.*
23
+
24
+ ## Usage
25
+
26
+ ### Register it for all windows
27
+
28
+ This is probably what you want for your app.
29
+
30
+ ```js
31
+ import {app, BrowserWindow} from 'electron';
32
+ import electronDl from 'electron-dl';
33
+
34
+ electronDl();
35
+
36
+ let mainWindow;
37
+ (async () => {
38
+ await app.whenReady();
39
+ mainWindow = new BrowserWindow();
40
+ })();
41
+ ```
42
+
43
+ ### Use it manually
44
+
45
+ This can be useful if you need download functionality in a reusable module.
46
+
47
+ ```js
48
+ import {BrowserWindow, ipcMain} from 'electron';
49
+ import {download, CancelError} from 'electron-dl';
50
+
51
+ ipcMain.on('download-button', async (event, {url}) => {
52
+ const win = BrowserWindow.getFocusedWindow();
53
+ try {
54
+ console.log(await download(win, url));
55
+ } catch (error) {
56
+ if (error instanceof CancelError) {
57
+ console.info('item.cancel() was called');
58
+ } else {
59
+ console.error(error);
60
+ }
61
+ }
62
+ });
63
+ ```
64
+
65
+ ### Download into memory
66
+
67
+ This can be useful if you want to process a file without saving it to disk first.
68
+
69
+ ```js
70
+ import {BrowserWindow, ipcMain} from 'electron';
71
+ import {downloadAsBytes} from 'electron-dl';
72
+
73
+ ipcMain.on('download-button', async (event, {url}) => {
74
+ const win = BrowserWindow.getFocusedWindow();
75
+ const bytes = await downloadAsBytes(win, url);
76
+ console.log(bytes.length);
77
+ });
78
+ ```
79
+
80
+ ## API
81
+
82
+ It can only be used in the [main](https://electronjs.org/docs/glossary/#main-process) process.
83
+
84
+ ### electronDl(options?)
85
+
86
+ ### download(window, url, options?): Promise<[DownloadItem](https://electronjs.org/docs/api/download-item)>
87
+
88
+ Multiple calls can be in progress at the same time. Each call handles its own download.
89
+
90
+ An interrupted download is resumed automatically when the server supports it, as long as the app is running. A download is not resumed after the app is restarted, since that would need the app to persist the download state.
91
+
92
+ ### window
93
+
94
+ Type: `BrowserWindow | WebContentsView`
95
+
96
+ The window to register the behavior on. Alternatively, a `WebContentsView` can be passed.
97
+
98
+ ### url
99
+
100
+ Type: `string`
101
+
102
+ The URL to download.
103
+
104
+ ### options
105
+
106
+ Type: `object`
107
+
108
+ #### saveAs
109
+
110
+ Type: `boolean`\
111
+ Default: `false`
112
+
113
+ Show a `Save As…` dialog instead of downloading immediately.
114
+
115
+ Note: Only use this option when strictly necessary. Downloading directly without a prompt is a much better user experience.
116
+
117
+ #### directory
118
+
119
+ Type: `string`\
120
+ Default: [User's downloads directory](https://electronjs.org/docs/api/app/#appgetpathname)
121
+
122
+ The directory to save the file in.
123
+
124
+ Must be an absolute path.
125
+
126
+ #### filename
127
+
128
+ Type: `string`\
129
+ Default: [`downloadItem.getFilename()`](https://electronjs.org/docs/api/download-item/#downloaditemgetfilename)
130
+
131
+ Name of the saved file.
132
+
133
+ Must not contain a path separator, so it cannot point outside `directory`.
134
+
135
+ This option only makes sense for `electronDl.download()`.
136
+
137
+ #### errorTitle
138
+
139
+ Type: `string`\
140
+ Default: `'Download Error'`
141
+
142
+ Title of the error dialog. Can be customized for localization.
143
+
144
+ Note: Error dialog will not be shown in `electronDl.download()`. Please handle error manually.
145
+
146
+ #### errorMessage
147
+
148
+ Type: `string`\
149
+ Default: `'The download of {filename} was interrupted'`
150
+
151
+ Message of the error dialog. `{filename}` is replaced with the name of the actual file. Can be customized for localization.
152
+
153
+ Note: Error dialog will not be shown in `electronDl.download()`. Please handle error manually.
154
+
155
+ #### onStarted
156
+
157
+ Type: `Function`
158
+
159
+ Optional callback that receives the [download item](https://electronjs.org/docs/api/download-item).
160
+ You can use this for advanced handling such as canceling the item like `item.cancel()` which will throw `electronDl.CancelError` from the `electronDl.download()` method.
161
+
162
+ #### onProgress
163
+
164
+ Type: `Function`
165
+
166
+ Optional callback that receives an object containing information about the progress of the current download item.
167
+
168
+ ```js
169
+ {
170
+ percent: 0.1,
171
+ transferredBytes: 100,
172
+ totalBytes: 1000
173
+ }
174
+ ```
175
+
176
+ #### onTotalProgress
177
+
178
+ Type: `Function`
179
+
180
+ Optional callback that receives an object containing information about the combined progress of all download items done within any registered window.
181
+
182
+ Each time a new download is started, the next callback will include it. The progress percentage could therefore become smaller again.
183
+ This callback provides the same data that is used for the progress bar on the app icon.
184
+
185
+ ```js
186
+ {
187
+ percent: 0.1,
188
+ transferredBytes: 100,
189
+ totalBytes: 1000
190
+ }
191
+ ```
192
+
193
+ #### onCancel
194
+
195
+ Type: `Function`
196
+
197
+ Optional callback that receives the [download item](https://electronjs.org/docs/api/download-item) for which the download has been cancelled.
198
+
199
+ #### onCompleted
200
+
201
+ Type: `Function`
202
+
203
+ Optional callback that receives an object with information about an item that has been completed. It is called for each completed item.
204
+
205
+ ```js
206
+ {
207
+ filename: 'file.zip',
208
+ path: '/path/file.zip',
209
+ fileSize: 503320,
210
+ mimeType: 'application/zip',
211
+ url: 'https://example.com/file.zip'
212
+ }
213
+ ```
214
+
215
+ #### onError
216
+
217
+ Type: `Function`
218
+
219
+ Optional callback that receives the `Error` for a download that was interrupted and could not be resumed.
220
+
221
+ When this is provided, the error dialog is not shown. Use this to show your own error dialog or to handle the failure in another way.
222
+
223
+ This option only makes sense for the default export. `electronDl.download()` rejects with the error instead.
224
+
225
+ ```js
226
+ import electronDl from 'electron-dl';
227
+
228
+ electronDl({
229
+ onError(error) {
230
+ console.error(error.message);
231
+ }
232
+ });
233
+ ```
234
+
235
+ #### openFolderWhenDone
236
+
237
+ Type: `boolean`\
238
+ Default: `false`
239
+
240
+ Reveal the downloaded file in the system file manager, and if possible, select the file.
241
+
242
+ #### showBadge
243
+
244
+ Type: `boolean`\
245
+ Default: `true`
246
+
247
+ Show a file count badge on the macOS/Linux dock/taskbar icon when a download is in progress.
248
+
249
+ #### showProgressBar
250
+
251
+ Type: `boolean`\
252
+ Default: `true`
253
+
254
+ Show a progress bar on the dock/taskbar icon when a download is in progress.
255
+
256
+ Downloads without an associated `BrowserWindow` are saved normally, but do not show a progress bar.
257
+
258
+ #### overwrite
259
+
260
+ Type: `boolean`\
261
+ Default: `false`
262
+
263
+ Allow downloaded files to overwrite files with the same name in the directory they are saved to.
264
+
265
+ The default behavior is to append a number to the filename.
266
+
267
+ #### dialogOptions
268
+
269
+ Type: [`SaveDialogOptions`](https://www.electronjs.org/docs/latest/api/download-item#downloaditemsetsavedialogoptionsoptions)\
270
+ Default: `{}`
271
+
272
+ Customize the save dialog.
273
+
274
+ If `defaultPath` is not explicity defined, a default value is assigned based on the file path.
275
+
276
+ ### downloadAsBytes(window, url, options?): Promise<`Uint8Array`>
277
+
278
+ Download a file into memory instead of saving it to disk. Use this when you want to process the file directly.
279
+
280
+ The download does not appear in the Downloads directory. It is not shown in the dock/taskbar badge or progress bar, and it cannot be resumed.
281
+
282
+ The promise rejects when the server responds with an error status.
283
+
284
+ `window` and `url` are the same as for `download()`.
285
+
286
+ #### onProgress
287
+
288
+ Type: `Function`
289
+
290
+ Optional callback that receives an object containing information about the progress of the download.
291
+
292
+ ```js
293
+ {
294
+ percent: 0.1,
295
+ transferredBytes: 100,
296
+ totalBytes: 1000
297
+ }
298
+ ```
299
+
300
+ `percent` is `0` when the total size is not known, for example for a compressed or chunked response.
301
+
302
+ #### signal
303
+
304
+ Type: [`AbortSignal`](https://developer.mozilla.org/en-US/docs/Web/API/AbortSignal)
305
+
306
+ An `AbortSignal` to cancel the download with. An aborted download rejects with the signal's abort reason, which defaults to an `AbortError`.
307
+
308
+ ## Development
309
+
310
+ After making changes, run the automated tests:
311
+
312
+ ```sh
313
+ npm test
314
+ ```
315
+
316
+ And before submitting a pull request, run the manual tests to manually verify that everything works:
317
+
318
+ ```sh
319
+ npm start
320
+ ```
321
+
322
+ ## Related
323
+
324
+ - [electron-debug](https://github.com/sindresorhus/electron-debug) - Adds useful debug features to your Electron app
325
+ - [electron-context-menu](https://github.com/sindresorhus/electron-context-menu) - Context menu for your Electron app
326
+ - [electron-store](https://github.com/sindresorhus/electron-store) - Save and load data like user settings, app state, cache, etc
327
+ - [electron-unhandled](https://github.com/sindresorhus/electron-unhandled) - Catch unhandled errors and promise rejections in your Electron app
328
+
329
+ ## OpenHarmony adaptation (@ohos-ports fork)
330
+
331
+ Two adaptations for HarmonyOS PC Electron (`process.platform === "openharmony"`),
332
+ both verified on Electron 34:
333
+
334
+ - **Interrupted downloads fail fast instead of deadlocking.** Upstream calls
335
+ `item.resume()` when a download is interrupted and the server supports
336
+ ranges; on HarmonyOS PC Electron ANY such call synchronously deadlocks the
337
+ main process event loop (all timers and IPC stop firing — immediate, spaced
338
+ and capped retries all reproduce it). This fork never calls `resume()` on
339
+ openharmony: the `download()` promise (or `onError` in registration mode)
340
+ rejects with a descriptive error and the item is cancelled. Restart the
341
+ download yourself to recover, e.g. by retrying `download()`.
342
+ - **Registration mode also covers the default session.** `electronDl()` relied
343
+ solely on the `session-created` event, which does not fire for sessions that
344
+ already exist on HarmonyOS PC Electron (and `session.getAllSessions()` is not
345
+ available on Electron 34), leaving their downloads without a listener. The
346
+ fork additionally registers on `session.defaultSession` synchronously (gated
347
+ to `openharmony`; other platforms behave exactly as upstream). Custom
348
+ partition sessions created via `session.fromPartition()` after the
349
+ `electronDl()` call may still not be covered — prefer `download()` for those.
350
+
351
+ - **Registration-mode errors are non-blocking on openharmony.** The upstream
352
+ default error handler (`dialog.showErrorBox`) is a synchronous modal dialog
353
+ that blocks the main process event loop until dismissed; on HarmonyOS PC
354
+ this can freeze the whole app. The fork logs to `console.error` instead —
355
+ pass `onError` for custom handling.
356
+
357
+ Known limitations:
358
+
359
+ - The `WebAssembly` global is missing from the main process on HarmonyOS PC
360
+ Electron, so Node's built-in `fetch`/undici is unusable there.
361
+ `download()` and `downloadAsBytes()` (via Electron's `session.fetch`) are
362
+ unaffected and fully functional.
363
+ - Interrupted downloads always reject on openharmony (no auto-resume), so
364
+ transient interruptions surface as errors — retry at the application level.
365
+ - Downloads on `session.defaultSession` (registration mode without custom
366
+ partitions) were observed to be flaky on HarmonyOS PC Electron; prefer
367
+ `download()` from a window, or custom partition sessions.
package/README.md DELETED
@@ -1,3 +0,0 @@
1
- # Temporary Holding Version
2
-
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.