Skip to content
Open
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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,6 +205,15 @@ async function setupAndStartDownload() {
}
```

Alternatively, pass `{ autoUnlisten: true }` to automatically remove the listener
when the download reaches a terminal state (`Completed` or `Canceled`):

```ts
await download.listen((updated) => {
console.debug(`'${updated.path}': ${updated.progress}%`);
}, { autoUnlisten: true });
```

### Examples

Check out the [examples/tauri-app](examples/tauri-app) directory for a working example of
Expand Down
40 changes: 36 additions & 4 deletions guest-js/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { addPluginListener, invoke } from '@tauri-apps/api/core';
import {
AllDownloadActions, allowedActions, Download, DownloadAction, DownloadActionResponse, DownloadState,
DownloadStatus, DownloadWithAnyStatus,
DownloadStatus, DownloadWithAnyStatus, ListenOptions,
} from './types';

/**
Expand Down Expand Up @@ -79,7 +79,7 @@ class DownloadEventManager {

if (listeners) {
// eslint-disable-next-line @typescript-eslint/no-use-before-define
listeners.forEach((listener) => { return listener(attachDownload(event)); });
[ ...listeners ].forEach((listener) => { return listener(attachDownload(event)); });
}
}

Expand All @@ -100,6 +100,24 @@ class DownloadEventManager {
}
}

export function wrapListenerWithAutoUnlisten(
listener: (download: DownloadWithAnyStatus) => void,
unlisten: () => void
): (download: DownloadWithAnyStatus) => void {
return (download: DownloadWithAnyStatus): void => {
try {
listener(download);
} finally {
const isTerminal = download.status === DownloadStatus.Completed
|| download.status === DownloadStatus.Canceled;

if (isTerminal) {
unlisten();
}
}
};
}

async function sendAction<A extends DownloadAction>(action: A, args: Record<string, unknown>): Promise<DownloadActionResponse<A>> {
const response = await invoke<DownloadActionResponse<A>>('plugin:download|' + action, args);

Expand All @@ -109,8 +127,22 @@ async function sendAction<A extends DownloadAction>(action: A, args: Record<stri
}

const actions = {
listen(listener: (download: DownloadWithAnyStatus) => void): Promise<UnlistenFn> {
return DownloadEventManager.shared.addListener(this.path, listener);
async listen(
listener: (download: DownloadWithAnyStatus) => void,
options?: ListenOptions
): Promise<UnlistenFn> {
if (!options?.autoUnlisten) {
return DownloadEventManager.shared.addListener(this.path, listener);
}

const unlisten = await DownloadEventManager.shared.addListener(
this.path,
wrapListenerWithAutoUnlisten(listener, () => {
return unlisten();
})
);

return unlisten;
},

async create(url: string) {
Expand Down
81 changes: 79 additions & 2 deletions guest-js/index.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Sanity checks to test the bridge between TypeScript and the Tauri commands.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mockIPC, clearMocks } from '@tauri-apps/api/mocks';
import { list, get } from './index';
import {
Expand All @@ -10,7 +10,7 @@ import {
hasAction,
hasAnyAction,
} from './types';
import { attachDownload } from './actions';
import { attachDownload, wrapListenerWithAutoUnlisten } from './actions';

let lastCmd = '',
lastArgs: Record<string, unknown> = {};
Expand Down Expand Up @@ -315,3 +315,80 @@ describe('state machine — action availability', () => {
expect(download.status).toBe(DownloadStatus.InProgress);
});
});

describe('wrapListenerWithAutoUnlisten', () => {
it('calls the listener and does not unlisten for non-terminal states', () => {
const listener = vi.fn();

const unlisten = vi.fn();

const wrappedListener = wrapListenerWithAutoUnlisten(listener, unlisten);

wrappedListener(attachDownload({
...IDLE_STATE,
status: DownloadStatus.InProgress,
}));
wrappedListener(attachDownload({
...IDLE_STATE,
status: DownloadStatus.Paused,
}));

expect(listener).toHaveBeenCalledTimes(2);
expect(unlisten).toHaveBeenCalledTimes(0);
});

it('calls unlisten on Completed', () => {
const listener = vi.fn();

const unlisten = vi.fn();

const wrappedListener = wrapListenerWithAutoUnlisten(listener, unlisten);

wrappedListener(attachDownload({
...IDLE_STATE,
status: DownloadStatus.Completed,
}));

expect(listener).toHaveBeenCalledTimes(1);
expect(unlisten).toHaveBeenCalledTimes(1);
});

it('calls unlisten on Canceled', () => {
const listener = vi.fn();

const unlisten = vi.fn();

const wrappedListener = wrapListenerWithAutoUnlisten(listener, unlisten);

wrappedListener(attachDownload({
...IDLE_STATE,
status: DownloadStatus.Canceled,
}));

expect(listener).toHaveBeenCalledTimes(1);
expect(unlisten).toHaveBeenCalledTimes(1);
});

it('calls unlisten on Completed when the callback throws', () => {
const unlisten = vi.fn();

const wrappedListener = wrapListenerWithAutoUnlisten(() => {
throw new Error('listener error');
}, unlisten);

let thrownError: unknown;

try {
wrappedListener(attachDownload({
...IDLE_STATE,
status: DownloadStatus.Completed,
}));
} catch(error) {
thrownError = error;
}

expect(thrownError).toBeInstanceOf(Error);
expect((thrownError as Error).message).toBe('listener error');
expect(unlisten).toHaveBeenCalledTimes(1);
});
});
26 changes: 21 additions & 5 deletions guest-js/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,29 +61,45 @@ export interface DownloadActionResponse<A extends DownloadAction = DownloadActio
isExpectedStatus: boolean;
}

export interface ListenOptions {

/**
* Automatically remove the listener when download reaches a terminal state
* (`Completed` or `Canceled`). Default: false.
*/
autoUnlisten?: boolean;
}

export interface AllDownloadActions {

/**
* Listen for changes to the download state. To avoid memory leaks, the `unlisten`
* function returned by the promise should be called when no longer required.
* function returned by the promise should be called when no longer required, or
* use `{ autoUnlisten: true }` to automatically remove the listener on completion
* or cancellation.
*
* @param onChanged Callback function invoked when the download has changed.
* @param options Optional settings for the listener.
* @returns A promise with a function to remove the download listener.
*
* @example
* ```ts
* // Manual unlisten:
* const unlisten = await download.listen((updatedDownload) => {
* console.log('Download:', updatedDownload);
* if (updatedDownload.status === DownloadStatus.Paused) {
* updatedDownload.resume(); // TypeScript knows this is valid
* }
* });
*
* // To stop listening
* unlisten();
*
* // Auto-unlisten when the download is completed or canceled:
* await download.listen((updatedDownload) => {
* console.log('Download:', updatedDownload);
* }, { autoUnlisten: true });
* ```
*/
[DownloadAction.Listen]: (listener: (download: DownloadWithAnyStatus) => void) => Promise<UnlistenFn>;
[DownloadAction.Listen]: (listener: (download: DownloadWithAnyStatus) => void, options?: ListenOptions) => Promise<UnlistenFn>;
[DownloadAction.Create]: (url: string) => Promise<DownloadActionResponse<DownloadAction.Create>>;
[DownloadAction.Start]: () => Promise<DownloadActionResponse<DownloadAction.Start>>;
[DownloadAction.Resume]: () => Promise<DownloadActionResponse<DownloadAction.Resume>>;
Expand Down Expand Up @@ -137,7 +153,7 @@ export const expectedStatusesForAction = {
],
} as const satisfies Record<DownloadAction, DownloadStatus[] | []>;

type ActionsFns<S extends DownloadStatus> = Pick<AllDownloadActions, typeof allowedActions[S][number]>;
type ActionsFns<S extends DownloadStatus> = Pick<AllDownloadActions, (typeof allowedActions)[S][number]>;
type AllowedActionsForStatus<S extends DownloadStatus> = ActionsFns<S> extends never ? object : ActionsFns<S>;

export type Download<S extends DownloadStatus> = DownloadState<S> & AllowedActionsForStatus<S>;
Expand Down
Loading