Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fix DownloadItem not being properly initialized #6

Merged
merged 1 commit into from
Apr 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
# 3.0.0 (2024-04-04)

Adds fixes around `DownloadData` population.

**Breaking changes**

`DownloadManager.download()` is now `async`.

This change is necessary to fix a race condition where `download()` is called, but if you immediately try to perform an
operation against the returned id, such as `pauseDownload()`, the `DownloadItem` has not been properly initialized
since the event that populates `DownloadItem` is out-of-band.

So the new `download()` implementation waits until `DownloadItem` is properly initialized before returning the id.

# 2.4.1 (2024-04-03)

- Fix issue where pausing a download won't pause it
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ import { ElectronDownloadManager } from 'electron-dl-manager';
const manager = new ElectronDownloadManager();

// Start a download
manager.download({
const id = await manager.download({
window: browserWindowInstance,
url: 'https://example.com/file.zip',
saveDialogOptions: {
Expand Down Expand Up @@ -106,7 +106,7 @@ ipcMain.handle('download-file', async (event, args) => {
let downloadId
const browserWindow = BrowserWindow.fromId(event.sender.id)

downloadId = manager.download({
downloadId = await manager.download({
window: browserWindow,
url,
// If you want to download without a save as dialog
Expand Down Expand Up @@ -183,7 +183,7 @@ interface DownloadManagerConstructorParams {
Starts a file download. Returns the `id` of the download.

```typescript
download(params: DownloadParams): string
download(params: DownloadParams): Promise<string>
```

#### Interface: `DownloadParams`
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "electron-dl-manager",
"version": "2.4.1",
"version": "3.0.0",
"description": "A library for implementing file downloads in Electron with 'save as' dialog and id support.",
"main": "dist/cjs/index.js",
"module": "dist/esm/index.js",
Expand Down
22 changes: 16 additions & 6 deletions src/DownloadInitiator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { calculateDownloadMetrics, determineFilePath } from "./utils";
interface DownloadInitiatorConstructorParams {
debugLogger?: (message: string) => void;
onCleanup?: (id: DownloadData) => void;
onDownloadInit?: (id: DownloadData) => void;
}

interface WillOnDownloadParams {
Expand Down Expand Up @@ -51,6 +52,10 @@ export class DownloadInitiator {
* The handler for the DownloadItem's `done` event.
*/
private onItemDone: (event: Event, state: "completed" | "cancelled" | "interrupted") => Promise<void>;
/**
* When the download is initiated
*/
private onDownloadInit: (data: DownloadData) => void;
/**
* When cleanup is called
*/
Expand All @@ -71,6 +76,7 @@ export class DownloadInitiator {
this.onItemUpdated = () => Promise.resolve();
this.onItemDone = () => Promise.resolve();
this.onCleanup = config.onCleanup || (() => {});
this.onDownloadInit = config.onDownloadInit || (() => {});
this.config = {} as DownloadConfig;
this.callbackDispatcher = {} as CallbackDispatcher;
}
Expand Down Expand Up @@ -107,6 +113,10 @@ export class DownloadInitiator {
this.downloadData.webContents = webContents;
this.downloadData.event = event;

if (this.onDownloadInit) {
this.onDownloadInit(this.downloadData);
}

if (this.config.saveDialogOptions) {
this.initSaveAsInteractiveDownload();
return;
Expand Down Expand Up @@ -143,7 +153,7 @@ export class DownloadInitiator {
if (item.getSavePath()) {
clearInterval(interval);

this.log(`User selected save path to ${this.downloadData.item.getSavePath()}`);
this.log(`User selected save path to ${item.getSavePath()}`);
this.log("Initiating download item handlers");

this.downloadData.resolvedFilename = path.basename(item.getSavePath());
Expand All @@ -159,7 +169,7 @@ export class DownloadInitiator {
item.once("done", this.generateItemOnDone());
}

if (!item['_userInitiatedPause']) {
if (!item["_userInitiatedPause"]) {
item.resume();
}
} else if (this.downloadData.isDownloadCancelled()) {
Expand All @@ -176,11 +186,11 @@ export class DownloadInitiator {
private augmentDownloadItem(item: DownloadItem) {
// This covers if the user manually pauses the download
// before we have set up the event listeners on the item
item['_userInitiatedPause'] = false;
item["_userInitiatedPause"] = false;

const oldPause = item.pause;
const oldPause = item.pause.bind(item);
item.pause = () => {
item['_userInitiatedPause'] = true;
item["_userInitiatedPause"] = true;
oldPause();
};
}
Expand All @@ -205,7 +215,7 @@ export class DownloadInitiator {
item.on("updated", this.generateItemOnUpdated());
item.once("done", this.generateItemOnDone());

if (!item['_userInitiatedPause']) {
if (!item["_userInitiatedPause"]) {
item.resume();
}
}
Expand Down
46 changes: 25 additions & 21 deletions src/ElectronDownloadManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ export class ElectronDownloadManager implements IElectronDownloadManager {
resumeDownload(id: string) {
const data = this.downloadData[id];

if (data?.item.isPaused()) {
if (data?.item?.isPaused()) {
this.log(`[${id}] Resuming download`);
data.item.resume();
} else {
Expand All @@ -87,27 +87,31 @@ export class ElectronDownloadManager implements IElectronDownloadManager {
*
* Returns the id of the download.
*/
download(params: DownloadConfig) {
if (params.saveAsFilename && params.saveDialogOptions) {
throw new Error("You cannot define both saveAsFilename and saveDialogOptions to start a download");
}

const downloadInitiator = new DownloadInitiator({
debugLogger: this.logger,
onCleanup: (data) => {
this.cleanup(data);
},
async download(params: DownloadConfig): Promise<string> {
return new Promise((resolve, reject) => {
try {
if (params.saveAsFilename && params.saveDialogOptions) {
return reject(Error("You cannot define both saveAsFilename and saveDialogOptions to start a download"));
}

const downloadInitiator = new DownloadInitiator({
debugLogger: this.logger,
onCleanup: (data) => {
this.cleanup(data);
},
onDownloadInit: (data) => {
this.downloadData[data.id] = data;
resolve(data.id);
},
});

this.log(`[${downloadInitiator.getDownloadId()}] Registering download for url: ${truncateUrl(params.url)}`);
params.window.webContents.session.once("will-download", downloadInitiator.generateOnWillDownload(params));
params.window.webContents.downloadURL(params.url, params.downloadURLOptions);
} catch (e) {
reject(e);
}
});

this.log(`[${downloadInitiator.getDownloadId()}] Registering download for url: ${truncateUrl(params.url)}`);
params.window.webContents.session.once("will-download", downloadInitiator.generateOnWillDownload(params));
params.window.webContents.downloadURL(params.url, params.downloadURLOptions);

const downloadId = downloadInitiator.getDownloadId();

this.downloadData[downloadId] = downloadInitiator.getDownloadData();

return downloadId;
}

protected cleanup(data: DownloadData) {
Expand Down
2 changes: 1 addition & 1 deletion src/ElectronDownloadManagerMock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import type { DownloadConfig, IElectronDownloadManager } from "./types";
* that can be used for testing purposes
*/
export class ElectronDownloadManagerMock implements IElectronDownloadManager {
download(_params: DownloadConfig): string {
async download(_params: DownloadConfig): Promise<string> {
return "mock-download-id";
}

Expand Down
11 changes: 8 additions & 3 deletions src/__mocks__/DownloadInitiator.ts
Original file line number Diff line number Diff line change
@@ -1,24 +1,29 @@
import { CallbackDispatcher } from "./CallbackDispatcher";
import { DownloadData } from "./DownloadData";

export const DownloadInitiator = jest.fn().mockImplementation(() => {
return {
export const DownloadInitiator = jest.fn().mockImplementation((config) => {
const initator = {
logger: jest.fn(),
onItemUpdated: jest.fn(),
onItemDone: jest.fn(),
onDownloadInit: jest.fn(),
onCleanup: jest.fn(),
callbackDispatcher: new CallbackDispatcher(),
downloadData: new DownloadData(),
config: { callbacks: {} },
log: jest.fn(),
getDownloadId: jest.fn(),
getDownloadData: jest.fn(),
generateOnWillDownload: jest.fn(() => jest.fn()),
generateOnWillDownload: jest.fn(() => async () => {
config.onDownloadInit(new DownloadData());
}),
initSaveAsInteractiveDownload: jest.fn(),
initNonInteractiveDownload: jest.fn(),
generateItemOnUpdated: jest.fn(),
generateItemOnDone: jest.fn(),
cleanup: jest.fn(),
updateProgress: jest.fn(),
};

return initator;
});
2 changes: 1 addition & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ export interface IElectronDownloadManager {
*
* Returns the id of the download.
*/
download(params: DownloadConfig): string;
download(params: DownloadConfig): Promise<string>;
/**
* Cancels a download
*/
Expand Down
9 changes: 9 additions & 0 deletions test-app/.editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true
4 changes: 4 additions & 0 deletions test-app/.eslintignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
node_modules
dist
out
.gitignore
7 changes: 7 additions & 0 deletions test-app/.eslintrc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
module.exports = {
extends: [
'eslint:recommended',
'@electron-toolkit/eslint-config-ts/recommended',
'@electron-toolkit/eslint-config-prettier'
]
}
5 changes: 5 additions & 0 deletions test-app/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
node_modules
dist
out
.DS_Store
*.log*
6 changes: 6 additions & 0 deletions test-app/.prettierignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
out
dist
pnpm-lock.yaml
LICENSE.md
tsconfig.json
tsconfig.*.json
4 changes: 4 additions & 0 deletions test-app/.prettierrc.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
singleQuote: true
semi: false
printWidth: 100
trailingComma: none
3 changes: 3 additions & 0 deletions test-app/.vscode/extensions.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
{
"recommendations": ["dbaeumer.vscode-eslint"]
}
39 changes: 39 additions & 0 deletions test-app/.vscode/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Debug Main Process",
"type": "node",
"request": "launch",
"cwd": "${workspaceRoot}",
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite",
"windows": {
"runtimeExecutable": "${workspaceRoot}/node_modules/.bin/electron-vite.cmd"
},
"runtimeArgs": ["--sourcemap"],
"env": {
"REMOTE_DEBUGGING_PORT": "9222"
}
},
{
"name": "Debug Renderer Process",
"port": 9222,
"request": "attach",
"type": "chrome",
"webRoot": "${workspaceFolder}/src/renderer",
"timeout": 60000,
"presentation": {
"hidden": true
}
}
],
"compounds": [
{
"name": "Debug All",
"configurations": ["Debug Main Process", "Debug Renderer Process"],
"presentation": {
"order": 1
}
}
]
}
11 changes: 11 additions & 0 deletions test-app/.vscode/settings.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"[typescript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[javascript]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
},
"[json]": {
"editor.defaultFormatter": "esbenp.prettier-vscode"
}
}
34 changes: 34 additions & 0 deletions test-app/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# test-app

A minimal Electron application with TypeScript

## Recommended IDE Setup

- [VSCode](https://code.visualstudio.com/) + [ESLint](https://marketplace.visualstudio.com/items?itemName=dbaeumer.vscode-eslint) + [Prettier](https://marketplace.visualstudio.com/items?itemName=esbenp.prettier-vscode)

## Project Setup

### Install

```bash
$ yarn
```

### Development

```bash
$ yarn dev
```

### Build

```bash
# For windows
$ yarn build:win

# For macOS
$ yarn build:mac

# For Linux
$ yarn build:linux
```
12 changes: 12 additions & 0 deletions test-app/build/entitlements.mac.plist
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.allow-dyld-environment-variables</key>
<true/>
</dict>
</plist>
Binary file added test-app/build/icon.icns
Binary file not shown.
Binary file added test-app/build/icon.ico
Binary file not shown.
Binary file added test-app/build/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Loading