From 620098e8e15f5b34682b4f28e75c9f5c0137dbd1 Mon Sep 17 00:00:00 2001 From: Matthew Richardson Date: Tue, 9 Dec 2025 17:30:29 +0000 Subject: [PATCH] feat: add discriminated JS types --- README.md | 24 ++- examples/tauri-app/src/DownloadView.vue | 31 ++- guest-js/index.ts | 260 ++++++++++++++++++------ 3 files changed, 238 insertions(+), 77 deletions(-) diff --git a/README.md b/README.md index 7c7afd5..b1bde82 100644 --- a/README.md +++ b/README.md @@ -144,16 +144,30 @@ async function getDownload() { #### Start, pause, resume or cancel a download +The API uses discriminated unions for compile-time safety. +Only valid methods are available based on the download's state. + ```ts -import { get } from 'tauri-plugin-download'; +import { create, get, DownloadState } from 'tauri-plugin-download'; + +async function createAndStartDownload() { + const download = await create('file.zip', 'https://example.com/file.zip', '/path/to/file.zip'); + + const active = await download.start(); // Returns ActiveDownload + const paused = await active.pause(); // Returns PausedDownload + const resumed = await paused.resume(); // Returns ActiveDownload +} async function getDownloadAndUpdate() { const download = await get('file.zip'); - download.start(); - download.pause(); - download.resume(); - download.cancel(); + if (download.state === DownloadState.CREATED) { + await download.start(); // TypeScript knows start() is available + } else if (download.state === DownloadState.IN_PROGRESS) { + await download.pause(); // TypeScript knows pause() is available + } else if (download.state === DownloadState.PAUSED) { + await download.resume(); // TypeScript knows resume() is available + } } ``` diff --git a/examples/tauri-app/src/DownloadView.vue b/examples/tauri-app/src/DownloadView.vue index 4f050e5..399399d 100644 --- a/examples/tauri-app/src/DownloadView.vue +++ b/examples/tauri-app/src/DownloadView.vue @@ -2,7 +2,7 @@

{{ download?.key }}

-
+
@@ -31,13 +31,13 @@ const props = defineProps({ let unlisten: UnlistenFn; const download = ref(props.model), - isCancelled = computed(() => { return download.value.state === DownloadState.CANCELLED; }), - isCompleted = computed(() => { return download.value.state === DownloadState.COMPLETED; }), - canStart = computed(() => { return download.value?.state === DownloadState.CREATED; }), // eslint-disable-next-line max-len - canCancel = computed(() => { return [ DownloadState.CREATED, DownloadState.IN_PROGRESS, DownloadState.PAUSED ].includes(download.value.state); }), - canPause = computed(() => { return download.value?.state === DownloadState.IN_PROGRESS; }), - canResume = computed(() => { return download.value?.state === DownloadState.PAUSED; }); + isTerminal = computed(() => { return download.value.state === DownloadState.CANCELLED || download.value.state === DownloadState.COMPLETED; }), + canStart = computed(() => { return download.value.state === DownloadState.CREATED; }), + // eslint-disable-next-line max-len + canCancel = computed(() => { return download.value.state === DownloadState.CREATED || download.value.state === DownloadState.IN_PROGRESS || download.value.state === DownloadState.PAUSED; }), + canPause = computed(() => { return download.value.state === DownloadState.IN_PROGRESS; }), + canResume = computed(() => { return download.value.state === DownloadState.PAUSED; }); onMounted(async () => { unlisten = await props.model.listen((updated: Download) => { @@ -48,19 +48,28 @@ onMounted(async () => { onUnmounted(() => { return unlisten(); }); async function startDownload() { - await props.model.start(); + if (download.value.state === DownloadState.CREATED) { + await download.value.start(); + } } async function cancelDownload() { - await props.model.cancel(); + // eslint-disable-next-line max-len + if (download.value.state === DownloadState.CREATED || download.value.state === DownloadState.IN_PROGRESS || download.value.state === DownloadState.PAUSED) { + await download.value.cancel(); + } } async function pauseDownload() { - await props.model.pause(); + if (download.value.state === DownloadState.IN_PROGRESS) { + await download.value.pause(); + } } async function resumeDownload() { - await props.model.resume(); + if (download.value.state === DownloadState.PAUSED) { + await download.value.resume(); + } } diff --git a/guest-js/index.ts b/guest-js/index.ts index f03572a..7466ef2 100644 --- a/guest-js/index.ts +++ b/guest-js/index.ts @@ -1,6 +1,30 @@ import { invoke, addPluginListener } from '@tauri-apps/api/core'; import { listen, UnlistenFn } from '@tauri-apps/api/event'; +/** +* Represents the state of a download operation. +* Enum values are camel-cased to match the Rust and mobile plugin implementations. +*/ +export enum DownloadState { + UNKNOWN = 'unknown', + CREATED = 'created', + IN_PROGRESS = 'inProgress', + PAUSED = 'paused', + CANCELLED = 'cancelled', + COMPLETED = 'completed' +} + +/** + * Represents a download item. + */ +export interface DownloadItem { + key: string; + url: string; + path: string; + progress: number; + state: DownloadState; +} + /** * Manages subscriptions to download events from Rust and * mobile plugins (iOS/Android), and dispatching these events @@ -75,7 +99,7 @@ export class DownloadEventManager { if (listeners) { // eslint-disable-next-line @typescript-eslint/no-use-before-define - listeners.forEach((listener) => { return listener(new Download(event)); }); + listeners.forEach((listener) => { return listener(createDownload(event)); }); } } @@ -97,101 +121,215 @@ export class DownloadEventManager { } /** - * Represents a download item with methods to control its lifecycle. - * This class wraps a download item and provides methods to start, cancel, pause, resume, - * and listen for changes to the download. + * Base class for all download states. + * Contains common properties and the listen method. */ -export class Download implements DownloadItem { - public key: string; - public url: string; - public path: string; - public progress: number; - public state: DownloadState; +abstract class DownloadBase implements DownloadItem { + public readonly key: string; + public readonly url: string; + public readonly path: string; + public readonly progress: number; + public abstract readonly state: DownloadState; - public constructor(item: DownloadItem) { + protected constructor(item: DownloadItem) { this.key = item.key; this.url = item.url; this.path = item.path; this.progress = item.progress; - this.state = item.state; } /** - * Starts the download. + * Listen for changes to the download. + * To avoid memory leaks, the `unlisten` function returned by the promise + * should be called when no longer required. + * @param onChanged - Callback function invoked when the download has changed. + * @returns A promise with a function to remove the download listener. + * + * @example + * ```ts + * const unlisten = await download.listen((updatedDownload) => { + * console.log('Download:', updatedDownload); + * if (updatedDownload.state === DownloadState.PAUSED) { + * updatedDownload.resume(); // TypeScript knows this is valid + * } + * }); + * + * // To stop listening + * unlisten(); + * ``` + */ + public async listen(listener: (download: Download) => void): Promise { + return DownloadEventManager.shared.addListener(this.key, listener); + } +} + +/** + * A download that has been cancelled. + * Terminal state - no further actions available. + */ +export class CancelledDownload extends DownloadBase { + public readonly state = DownloadState.CANCELLED; + + public constructor(item: DownloadItem) { + super(item); + } +} + +/** + * A download that has completed successfully. + * Terminal state - no further actions available. + */ +export class CompletedDownload extends DownloadBase { + public readonly state = DownloadState.COMPLETED; + + public constructor(item: DownloadItem) { + super(item); + } +} + +/** + * A download in an unknown state. + * This may occur if the plugin returns an unrecognized state. + */ +export class UnknownDownload extends DownloadBase { + public readonly state = DownloadState.UNKNOWN; + + public constructor(item: DownloadItem) { + super(item); + } +} + +/** + * A download that has been paused. + * Can be resumed or cancelled. + */ +export class PausedDownload extends DownloadBase { + public readonly state = DownloadState.PAUSED; + + public constructor(item: DownloadItem) { + super(item); + } + + /** + * Resumes the download. * @returns A promise with the updated download. */ - public async start(): Promise { - return new Download(await invoke('plugin:download|start', { key: this.key })); + // eslint-disable-next-line @typescript-eslint/no-use-before-define + public async resume(): Promise { + // eslint-disable-next-line @typescript-eslint/no-use-before-define + return new ActiveDownload(await invoke('plugin:download|resume', { key: this.key })); } /** * Cancels the download. * @returns A promise with the updated download. */ - public async cancel(): Promise { - return new Download(await invoke('plugin:download|cancel', { key: this.key })); + public async cancel(): Promise { + return new CancelledDownload(await invoke('plugin:download|cancel', { key: this.key })); + } +} + +/** + * A download that is currently in progress. + * Can be paused or cancelled. + */ +export class ActiveDownload extends DownloadBase { + public readonly state = DownloadState.IN_PROGRESS; + + public constructor(item: DownloadItem) { + super(item); } /** * Pauses the download. * @returns A promise with the updated download. */ - public async pause(): Promise { - return new Download(await invoke('plugin:download|pause', { key: this.key })); + public async pause(): Promise { + return new PausedDownload(await invoke('plugin:download|pause', { key: this.key })); } /** - * Resumes the download. + * Cancels the download. * @returns A promise with the updated download. */ - public async resume(): Promise { - return new Download(await invoke('plugin:download|resume', { key: this.key })); + public async cancel(): Promise { + return new CancelledDownload(await invoke('plugin:download|cancel', { key: this.key })); + } +} + +/** + * A download that has been created but not yet started. + * Can be started or cancelled. + */ +export class CreatedDownload extends DownloadBase { + public readonly state = DownloadState.CREATED; + + public constructor(item: DownloadItem) { + super(item); } /** - * Listen for changes to the download. - * To avoid memory leaks, the `unlisten` function returned by the promise - * should be called when no longer required. - * @param onChanged - Callback function invoked when the download has changed. - * @returns A promise with a function to remove the download listener. - * - * @example - * ```ts - * const unlisten = await download.listen((updatedDownload) => { - * console.log('Download:', updatedDownload); - * }); - * - * // To stop listening - * unlisten(); - * ``` + * Starts the download. + * @returns A promise with the updated download. */ - public async listen(listener: (download: Download) => void): Promise { - return DownloadEventManager.shared.addListener(this.key, listener); + public async start(): Promise { + return new ActiveDownload(await invoke('plugin:download|start', { key: this.key })); + } + + /** + * Cancels the download. + * @returns A promise with the updated download. + */ + public async cancel(): Promise { + return new CancelledDownload(await invoke('plugin:download|cancel', { key: this.key })); } } /** - * Represents a download item. + * Union type representing a download in any state. + * Check the `state` property to narrow to a specific type. + * + * @example + * ```ts + * if (download.state === DownloadState.CREATED) { + * await download.start(); // TypeScript knows start() is available + * } + * ``` */ -export interface DownloadItem { - key: string; - url: string; - path: string; - progress: number; - state: DownloadState; -} +export type Download = + | CreatedDownload + | ActiveDownload + | PausedDownload + | CancelledDownload + | CompletedDownload + | UnknownDownload; /** -* Represents the state of a download operation. -* Enum values are camel-cased to match the Rust and mobile plugin implementations. -*/ -export enum DownloadState { - UNKNOWN = 'unknown', - CREATED = 'created', - IN_PROGRESS = 'inProgress', - PAUSED = 'paused', - CANCELLED = 'cancelled', - COMPLETED = 'completed' + * Creates the appropriate Download subclass based on the item's state. + * @param item - The download item from the plugin. + * @returns The typed download instance. + */ +function createDownload(item: DownloadItem): Download { + switch (item.state) { + case DownloadState.CREATED: { + return new CreatedDownload(item); + } + case DownloadState.IN_PROGRESS: { + return new ActiveDownload(item); + } + case DownloadState.PAUSED: { + return new PausedDownload(item); + } + case DownloadState.CANCELLED: { + return new CancelledDownload(item); + } + case DownloadState.COMPLETED: { + return new CompletedDownload(item); + } + default: { + return new UnknownDownload(item); + } + } } /** @@ -202,8 +340,8 @@ export enum DownloadState { * @param path - The download path on the filesystem. * @returns - The download operation. */ -export async function create(key: string, url: string, path: string): Promise { - return new Download(await invoke('plugin:download|create', { key, url, path })); +export async function create(key: string, url: string, path: string): Promise { + return new CreatedDownload(await invoke('plugin:download|create', { key, url, path })); } /** @@ -213,7 +351,7 @@ export async function create(key: string, url: string, path: string): Promise { return (await invoke('plugin:download|list')) - .map((item) => { return new Download(item); }); + .map((item) => { return createDownload(item); }); } /** @@ -223,5 +361,5 @@ export async function list(): Promise { * @returns - The download operation. */ export async function get(key: string): Promise { - return new Download(await invoke('plugin:download|get', { key })); + return createDownload(await invoke('plugin:download|get', { key })); }