diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 608cc8d..e5c4df3 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -10,8 +10,8 @@ jobs: fail-fast: false matrix: node-version: - - 14 - - 12 + - 20 + - 18 steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 diff --git a/index.d.ts b/index.d.ts index acfd279..798561a 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1,191 +1,184 @@ -import {BrowserView, BrowserWindow, DownloadItem, SaveDialogOptions} from 'electron'; - -declare namespace electronDl { - interface Progress { - percent: number; - transferredBytes: number; - totalBytes: number; - } +import { + type BrowserView, + type BrowserWindow, + type DownloadItem, + type SaveDialogOptions, +} from 'electron'; + +export type Progress = { + percent: number; + transferredBytes: number; + totalBytes: number; +}; - interface File { - filename: string; - path: string; - fileSize: number; - mimeType: string; - url: string; - } +export type File = { + filename: string; + path: string; + fileSize: number; + mimeType: string; + url: string; +}; - interface Options { - /** - Show a `Save As…` dialog instead of downloading immediately. +export type Options = { + /** + Show a `Save As…` dialog instead of downloading immediately. - Note: Only use this option when strictly necessary. Downloading directly without a prompt is a much better user experience. + Note: Only use this option when strictly necessary. Downloading directly without a prompt is a much better user experience. - @default false - */ - readonly saveAs?: boolean; + @default false + */ + readonly saveAs?: boolean; - /** - The directory to save the file in. + /** + The directory to save the file in. - Must be an absolute path. + Must be an absolute path. - Default: [User's downloads directory](https://electronjs.org/docs/api/app/#appgetpathname) - */ - readonly directory?: string; + Default: [User's downloads directory](https://electronjs.org/docs/api/app/#appgetpathname) + */ + readonly directory?: string; - /** - Name of the saved file. - This option only makes sense for `electronDl.download()`. + /** + Name of the saved file. + This option only makes sense for `electronDl.download()`. - Default: [`downloadItem.getFilename()`](https://electronjs.org/docs/api/download-item/#downloaditemgetfilename) - */ - readonly filename?: string; + Default: [`downloadItem.getFilename()`](https://electronjs.org/docs/api/download-item/#downloaditemgetfilename) + */ + readonly filename?: string; - /** - Title of the error dialog. Can be customized for localization. + /** + Title of the error dialog. Can be customized for localization. - Note: Error dialog will not be shown in `electronDl.download()`. Please handle error manually. + Note: Error dialog will not be shown in `electronDl.download()`. Please handle error manually. - @default 'Download Error' - */ - readonly errorTitle?: string; + @default 'Download Error' + */ + readonly errorTitle?: string; - /** - Message of the error dialog. `{filename}` is replaced with the name of the actual file. Can be customized for localization. + /** + Message of the error dialog. `{filename}` is replaced with the name of the actual file. Can be customized for localization. - Note: Error dialog will not be shown in `electronDl.download()`. Please handle error manually. + Note: Error dialog will not be shown in `electronDl.download()`. Please handle error manually. - @default 'The download of {filename} was interrupted' - */ - readonly errorMessage?: string; + @default 'The download of {filename} was interrupted' + */ + readonly errorMessage?: string; - /** - Optional callback that receives the [download item](https://electronjs.org/docs/api/download-item). - You can use this for advanced handling such as canceling the item like `item.cancel()`. - */ - readonly onStarted?: (item: DownloadItem) => void; + /** + Optional callback that receives the [download item](https://electronjs.org/docs/api/download-item). + You can use this for advanced handling such as canceling the item like `item.cancel()`. + */ + readonly onStarted?: (item: DownloadItem) => void; - /** - Optional callback that receives an object containing information about the progress of the current download item. - */ - readonly onProgress?: (progress: Progress) => void; + /** + Optional callback that receives an object containing information about the progress of the current download item. + */ + readonly onProgress?: (progress: Progress) => void; - /** - Optional callback that receives an object containing information about the combined progress of all download items done within any registered window. + /** + Optional callback that receives an object containing information about the combined progress of all download items done within any registered window. - Each time a new download is started, the next callback will include it. The progress percentage could therefore become smaller again. - This callback provides the same data that is used for the progress bar on the app icon. - */ - readonly onTotalProgress?: (progress: Progress) => void; + Each time a new download is started, the next callback will include it. The progress percentage could therefore become smaller again. + This callback provides the same data that is used for the progress bar on the app icon. + */ + readonly onTotalProgress?: (progress: Progress) => void; - /** - Optional callback that receives the [download item](https://electronjs.org/docs/api/download-item) for which the download has been cancelled. - */ - readonly onCancel?: (item: DownloadItem) => void; + /** + Optional callback that receives the [download item](https://electronjs.org/docs/api/download-item) for which the download has been cancelled. + */ + readonly onCancel?: (item: DownloadItem) => void; - /** - Optional callback that receives an object with information about an item that has been completed. It is called for each completed item. - */ - readonly onCompleted?: (file: File) => void; + /** + Optional callback that receives an object with information about an item that has been completed. It is called for each completed item. + */ + readonly onCompleted?: (file: File) => void; - /** - Reveal the downloaded file in the system file manager, and if possible, select the file. + /** + Reveal the downloaded file in the system file manager, and if possible, select the file. - @default false - */ - readonly openFolderWhenDone?: boolean; + @default false + */ + readonly openFolderWhenDone?: boolean; - /** - Show a file count badge on the macOS/Linux dock/taskbar icon when a download is in progress. + /** + Show a file count badge on the macOS/Linux dock/taskbar icon when a download is in progress. - @default true - */ - readonly showBadge?: boolean; + @default true + */ + readonly showBadge?: boolean; - /** - Show a progress bar on the dock/taskbar icon when a download is in progress. + /** + Show a progress bar on the dock/taskbar icon when a download is in progress. - @default true - */ - readonly showProgressBar?: boolean; + @default true + */ + readonly showProgressBar?: boolean; - /** - Allow downloaded files to overwrite files with the same name in the directory they are saved to. + /** + Allow downloaded files to overwrite files with the same name in the directory they are saved to. - The default behavior is to append a number to the filename. + The default behavior is to append a number to the filename. - @default false - */ - readonly overwrite?: boolean; + @default false + */ + readonly overwrite?: boolean; - /** - Customize the save dialog. + /** + Customize the save dialog. - If `defaultPath` is not explicity defined, a default value is assigned based on the file path. + If `defaultPath` is not explicity defined, a default value is assigned based on the file path. - @default {} - */ - readonly dialogOptions?: SaveDialogOptions; - } -} + @default {} + */ + readonly dialogOptions?: SaveDialogOptions; +}; /** Error thrown if `item.cancel()` was called. */ -declare class CancelError extends Error {} +export class CancelError extends Error {} -// eslint-disable-next-line no-redeclare -declare const electronDl: { - /** - Error thrown if `item.cancel()` was called. - */ - CancelError: typeof CancelError; - - /** - Register the helper for all windows. - - @example - ``` - import {app, BrowserWindow} from 'electron'; - import electronDl = require('electron-dl'); +/** +Register the helper for all windows. - electronDl(); +@example +``` +import {app, BrowserWindow} from 'electron'; +import electronDl from 'electron-dl'; - let win; - (async () => { - await app.whenReady(); - win = new BrowserWindow(); - })(); - ``` - */ - (options?: electronDl.Options): void; +electronDl(); - /** - This can be useful if you need download functionality in a reusable module. - - @param window - Window to register the behavior on. - @param url - URL to download. - @returns A promise for the downloaded file. - @throws {CancelError} An error if the user calls `item.cancel()`. - @throws {Error} An error if the download fails. - - @example - ``` - import {BrowserWindow, ipcMain} from 'electron'; - import electronDl = require('electron-dl'); - - ipcMain.on('download-button', async (event, {url}) => { - const win = BrowserWindow.getFocusedWindow(); - console.log(await electronDl.download(win, url)); - }); - ``` - */ - download( - window: BrowserWindow | BrowserView, - url: string, - options?: electronDl.Options - ): Promise; -}; +let mainWindow; +(async () => { + await app.whenReady(); + mainWindow = new BrowserWindow(); +})(); +``` +*/ +export default function electronDl(options?: Options): void; -export = electronDl; +/** +This can be useful if you need download functionality in a reusable module. + +@param window - Window to register the behavior on. +@param url - URL to download. +@returns A promise for the downloaded file. +@throws {CancelError} An error if the user calls `item.cancel()`. +@throws {Error} An error if the download fails. + +@example +``` +import {BrowserWindow, ipcMain} from 'electron'; +import {download} from 'electron-dl'; + +ipcMain.on('download-button', async (event, {url}) => { + const win = BrowserWindow.getFocusedWindow(); + console.log(await download(win, url)); +}); +``` +*/ +export function download( + window: BrowserWindow | BrowserView, + url: string, + options?: Options +): Promise; diff --git a/index.js b/index.js index 58c1df2..69dd135 100644 --- a/index.js +++ b/index.js @@ -1,11 +1,16 @@ -'use strict'; -const path = require('path'); -const {app, BrowserWindow, shell, dialog} = require('electron'); -const unusedFilename = require('unused-filename'); -const pupa = require('pupa'); -const extName = require('ext-name'); - -class CancelError extends Error {} +import process from 'node:process'; +import path from 'node:path'; +import { + app, + BrowserWindow, + shell, + dialog, +} from 'electron'; +import {unusedFilenameSync} from 'unused-filename'; +import pupa from 'pupa'; +import extName from 'ext-name'; + +export class CancelError extends Error {} const getFilenameFromMime = (name, mime) => { const extensions = extName.mime(mime); @@ -17,39 +22,6 @@ const getFilenameFromMime = (name, mime) => { return `${name}.${extensions[0].ext}`; }; -const majorElectronVersion = () => { - const version = process.versions.electron.split('.'); - return Number.parseInt(version[0], 10); -}; - -const getWindowFromBrowserView = webContents => { - for (const currentWindow of BrowserWindow.getAllWindows()) { - for (const currentBrowserView of currentWindow.getBrowserViews()) { - if (currentBrowserView.webContents.id === webContents.id) { - return currentWindow; - } - } - } -}; - -const getWindowFromWebContents = webContents => { - let window_; - const webContentsType = webContents.getType(); - switch (webContentsType) { - case 'webview': - window_ = BrowserWindow.fromWebContents(webContents.hostWebContents); - break; - case 'browserView': - window_ = getWindowFromBrowserView(webContents); - break; - default: - window_ = BrowserWindow.fromWebContents(webContents); - break; - } - - return window_; -}; - function registerListener(session, options, callback = () => {}) { const downloadItems = new Set(); let receivedBytes = 0; @@ -61,20 +33,23 @@ function registerListener(session, options, callback = () => {}) { options = { showBadge: true, showProgressBar: true, - ...options + ...options, }; const listener = (event, item, webContents) => { downloadItems.add(item); totalBytes += item.getTotalBytes(); - const window_ = majorElectronVersion() >= 12 ? BrowserWindow.fromWebContents(webContents) : getWindowFromWebContents(webContents); + const window_ = BrowserWindow.fromWebContents(webContents); + if (!window_) { + throw new Error('Failed to get window from web contents.'); + } if (options.directory && !path.isAbsolute(options.directory)) { throw new Error('The `directory` option must be an absolute path'); } - const directory = options.directory || app.getPath('downloads'); + const directory = options.directory ?? app.getPath('downloads'); let filePath; if (options.filename) { @@ -83,10 +58,10 @@ function registerListener(session, options, callback = () => {}) { const filename = item.getFilename(); const name = path.extname(filename) ? filename : getFilenameFromMime(filename, item.getMimeType()); - filePath = options.overwrite ? path.join(directory, name) : unusedFilename.sync(path.join(directory, name)); + filePath = options.overwrite ? path.join(directory, name) : unusedFilenameSync(path.join(directory, name)); } - const errorMessage = options.errorMessage || 'The download of {filename} was interrupted'; + const errorMessage = options.errorMessage ?? 'The download of {filename} was interrupted'; if (options.saveAs) { item.setSaveDialogOptions({defaultPath: filePath, ...options.dialogOptions}); @@ -115,7 +90,7 @@ function registerListener(session, options, callback = () => {}) { options.onProgress({ percent: itemTotalBytes ? itemTransferredBytes / itemTotalBytes : 0, transferredBytes: itemTransferredBytes, - totalBytes: itemTotalBytes + totalBytes: itemTotalBytes, }); } @@ -123,7 +98,7 @@ function registerListener(session, options, callback = () => {}) { options.onTotalProgress({ percent: progressDownloadItems(), transferredBytes: receivedBytes, - totalBytes + totalBytes, }); } }); @@ -152,6 +127,7 @@ function registerListener(session, options, callback = () => {}) { if (typeof options.onCancel === 'function') { options.onCancel(item); } + callback(new CancelError()); } else if (state === 'interrupted') { const message = pupa(errorMessage, {filename: path.basename(filePath)}); @@ -174,7 +150,7 @@ function registerListener(session, options, callback = () => {}) { path: savePath, fileSize: item.getReceivedBytes(), mimeType: item.getMimeType(), - url: item.getURL() + url: item.getURL(), }); } @@ -190,32 +166,32 @@ function registerListener(session, options, callback = () => {}) { session.on('will-download', listener); } -module.exports = (options = {}) => { +export default function electronDl(options = {}) { app.on('session-created', session => { registerListener(session, options, (error, _) => { if (error && !(error instanceof CancelError)) { - const errorTitle = options.errorTitle || 'Download Error'; + const errorTitle = options.errorTitle ?? 'Download Error'; dialog.showErrorBox(errorTitle, error.message); } }); }); -}; +} -module.exports.download = (window_, url, options) => new Promise((resolve, reject) => { - options = { - ...options, - unregisterWhenDone: true - }; +export async function download(window_, url, options) { + return new Promise((resolve, reject) => { + options = { + ...options, + unregisterWhenDone: true, + }; + + registerListener(window_.webContents.session, options, (error, item) => { + if (error) { + reject(error); + } else { + resolve(item); + } + }); - registerListener(window_.webContents.session, options, (error, item) => { - if (error) { - reject(error); - } else { - resolve(item); - } + window_.webContents.downloadURL(url); }); - - window_.webContents.downloadURL(url); -}); - -module.exports.CancelError = CancelError; +} diff --git a/index.test-d.ts b/index.test-d.ts deleted file mode 100644 index 164ccfe..0000000 --- a/index.test-d.ts +++ /dev/null @@ -1,9 +0,0 @@ -import {expectType} from 'tsd'; -import {BrowserWindow, DownloadItem} from 'electron'; -import electronDl = require('.'); -import {download} from '.'; - -expectType(electronDl()); -expectType>( - download(new BrowserWindow(), 'test', {errorTitle: 'Nope'}) -); diff --git a/lib/samples.js b/lib/samples.js index 0a4abe5..001e1f8 100644 --- a/lib/samples.js +++ b/lib/samples.js @@ -1,19 +1,21 @@ -'use strict'; -const path = require('path'); -const fs = require('fs'); -const pify = require('pify'); -const cpFile = require('cp-file'); -const {v4: uuidv4} = require('uuid'); +import path from 'node:path'; +import {randomUUID} from 'node:crypto'; +import fs from 'node:fs'; +import {fileURLToPath} from 'node:url'; +import pify from 'pify'; +import {copyFile} from 'copy-file'; -const fixtureDir = path.join(__dirname, '../mock/fixtures'); +const __dirname = path.dirname(fileURLToPath(import.meta.url)); -const setup = async numberFiles => { +const fixtureDirectory = path.join(__dirname, '../mock/fixtures'); + +export const setup = async numberFiles => { const promises = []; const files = []; while (files.length < numberFiles) { - const filename = `${uuidv4()}.zip`; - promises.push(cpFile(path.join(fixtureDir, 'electron-master.zip'), path.join(fixtureDir, filename))); + const filename = `${randomUUID()}.zip`; + promises.push(copyFile(path.join(fixtureDirectory, 'electron-master.zip'), path.join(fixtureDirectory, filename))); files.push(filename); } @@ -22,21 +24,17 @@ const setup = async numberFiles => { return files; }; -const teardown = async () => { - const files = await pify(fs.readdir)(fixtureDir); +export const teardown = async () => { + const files = await pify(fs.readdir)(fixtureDirectory); const promises = []; for (const file of files) { - console.log(path.join(fixtureDir, file)); + console.log(path.join(fixtureDirectory, file)); if (file !== 'electron-master.zip') { - promises.push(pify(fs.unlink)(path.join(fixtureDir, file))); + promises.push(pify(fs.unlink)(path.join(fixtureDirectory, file))); } } return Promise.all(promises); }; -module.exports = { - setup, - teardown -}; diff --git a/lib/server.js b/lib/server.js index 0238514..8804f9b 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1,12 +1,14 @@ -'use strict'; -const http = require('http'); -const nodeStatic = require('node-static'); +import http from 'node:http'; +import nodeStatic from 'node-static'; -module.exports = () => { +const server = () => { const fileServer = new nodeStatic.Server('./mock', {cache: false}); + http.createServer((request, response) => { request.addListener('end', () => { fileServer.serve(request, response); }).resume(); }).listen(8080); }; + +export default server; diff --git a/package.json b/package.json index be4c9cc..1942e1b 100644 --- a/package.json +++ b/package.json @@ -10,13 +10,19 @@ "email": "sindresorhus@gmail.com", "url": "https://sindresorhus.com" }, + "type": "module", + "exports": { + "types": "./index.d.ts", + "default": "./index.js" + }, "sideEffects": false, "engines": { - "node": ">=12" + "node": ">=18" }, "scripts": { "start": "electron run.js", - "test": "xo && ava && tsd" + "//test": "xo && ava && tsc index.d.ts", + "test": "xo && tsc index.d.ts" }, "files": [ "index.js", @@ -32,27 +38,28 @@ ], "dependencies": { "ext-name": "^5.0.0", - "pupa": "^2.0.1", - "unused-filename": "^2.1.0" + "pupa": "^3.1.0", + "unused-filename": "^4.0.1" }, "devDependencies": { - "@types/node": "^13.1.4", - "ava": "^2.4.0", - "cp-file": "^7.0.0", - "electron": "^7.1.7", - "minimist": "^1.2.0", + "@types/node": "^20.12.7", + "ava": "^6.1.2", + "copy-file": "^11.0.0", + "electron": "^30.0.1", + "minimist": "^1.2.8", "node-static": "^0.7.11", - "pify": "^4.0.1", - "spectron": "^9.0.0", - "tsd": "^0.17.0", - "typescript": "^4.4.3", - "uuid": "^8.3.2", - "xo": "^0.39.0" + "pify": "^6.1.0", + "spectron": "^19.0.0", + "typescript": "^5.4.5", + "xo": "^0.58.0" }, "xo": { "envs": [ "node", "browser" ] + }, + "ava": { + "serial": true } } diff --git a/readme.md b/readme.md index ab5a420..ec13cb2 100644 --- a/readme.md +++ b/readme.md @@ -19,7 +19,7 @@ npm install electron-dl ``` -Requires Electron 7 or later. +*Requires Electron 30 or later.* ## Usage @@ -28,15 +28,15 @@ Requires Electron 7 or later. This is probably what you want for your app. ```js -const {app, BrowserWindow} = require('electron'); -const electronDl = require('electron-dl'); +import {app, BrowserWindow} from 'electron'; +import electronDl from 'electron-dl'; electronDl(); -let win; +let mainWindow; (async () => { await app.whenReady(); - win = new BrowserWindow(); + mainWindow = new BrowserWindow(); })(); ``` @@ -45,20 +45,20 @@ let win; This can be useful if you need download functionality in a reusable module. ```js -const {BrowserWindow, ipcMain} = require('electron'); -const {download} = require('electron-dl'); +import {BrowserWindow, ipcMain} from 'electron'; +import {download, CancelError} from 'electron-dl'; ipcMain.on('download-button', async (event, {url}) => { - const win = BrowserWindow.getFocusedWindow(); - try { - console.log(await download(win, url)); - } catch (error) { - if (error instanceof electronDl.CancelError) { - console.info('item.cancel() was called'); - } else { - console.error(error); - } - } + const win = BrowserWindow.getFocusedWindow(); + try { + console.log(await download(win, url)); + } catch (error) { + if (error instanceof CancelError) { + console.info('item.cancel() was called'); + } else { + console.error(error); + } + } }); ``` @@ -68,19 +68,19 @@ It can only be used in the [main](https://electronjs.org/docs/glossary/#main-pro ### electronDl(options?) -### electronDl.download(window, url, options?): Promise<[DownloadItem](https://electronjs.org/docs/api/download-item)> +### download(window, url, options?): Promise<[DownloadItem](https://electronjs.org/docs/api/download-item)> ### window -Type: `BrowserWindow | BrowserView` +Type: `BrowserWindow | WebContentsView` -Window to register the behavior on. Alternatively, a `BrowserView` can be passed. +The window to register the behavior on. Alternatively, a `WebContentsView` can be passed. ### url Type: `string` -URL to download. +The URL to download. ### options @@ -234,13 +234,13 @@ If `defaultPath` is not explicity defined, a default value is assigned based on After making changes, run the automated tests: -``` -$ npm test +```sh +npm test ``` And before submitting a pull request, run the manual tests to manually verify that everything works: -``` +```sh npm start ``` diff --git a/run.js b/run.js index 0352f80..57ca88a 100644 --- a/run.js +++ b/run.js @@ -1,35 +1,51 @@ -'use strict'; -const electron = require('electron'); -const minimist = require('minimist'); -const samples = require('./lib/samples.js'); -const server = require('./lib/server.js'); -const electronDl = require('.'); +import process from 'node:process'; +import { + app, + BrowserWindow, + BaseWindow, + WebContentsView, +} from 'electron'; +import minimist from 'minimist'; +import {setup, teardown} from './lib/samples.js'; +import server from './lib/server.js'; +import electronDl, {download} from './index.js'; electronDl(); const argv = minimist(process.argv.slice(2)); +// eslint-disable-next-line unicorn/prefer-top-level-await (async () => { - await electron.app.whenReady(); + await app.whenReady(); server(); - const win = new electron.BrowserWindow({ + const win = new BrowserWindow({ webPreferences: { - nodeIntegration: true - } + nodeIntegration: true, + }, }); - win.on('closed', samples.teardown); + win.on('closed', teardown); win.webContents.session.enableNetworkEmulation({ latency: 2, - downloadThroughput: 1024 * 1024 + downloadThroughput: 1024 * 1024, }); const numberSampleFiles = 'files' in argv ? argv.files : 5; - const files = await samples.setup(numberSampleFiles); + const files = await setup(numberSampleFiles); await win.loadURL(`http://localhost:8080/index.html?files=${JSON.stringify(files)}`); + + // Test 1 + await download(BrowserWindow.getFocusedWindow(), 'https://google.com'); + + // Test 2 + const win2 = new BaseWindow({width: 800, height: 400}); + const view = new WebContentsView(); + win2.contentView.addChildView(view); + await view.webContents.loadURL('https://electronjs.org'); + await download(view, 'https://google.com'); })(); -process.on('SIGINT', samples.teardown); +process.on('SIGINT', teardown); diff --git a/test/index.js b/test/index.js index b940ea4..0944666 100644 --- a/test/index.js +++ b/test/index.js @@ -1,17 +1,19 @@ -'use strict'; -import fs from 'fs'; -import path from 'path'; -import {serial as test} from 'ava'; +import fs from 'node:fs'; +import path from 'node:path'; +import {fileURLToPath} from 'node:url'; +import test from 'ava'; import pify from 'pify'; import {Application} from 'spectron'; +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + test.beforeEach(async t => { t.context.spectron = new Application({ path: 'node_modules/.bin/electron', args: [ 'run.js', - '--files=3' - ] + '--files=3', + ], }); await t.context.spectron.start(); });