diff --git a/packages/web/src/stores/transferStore.download.test.ts b/packages/web/src/stores/transferStore.download.test.ts new file mode 100644 index 00000000..fa60df5c --- /dev/null +++ b/packages/web/src/stores/transferStore.download.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import 'fake-indexeddb/auto'; + +vi.mock('./authStore', () => ({ + useAuthStore: { + getState: () => ({ token: 'test-token', user: { id: 'u-9' } }), + }, +})); + +// Stub tus-js-client so importing transferStore doesn't fail +vi.mock('tus-js-client', () => ({ + Upload: vi.fn(), +})); + +import { useTransferStore } from './transferStore'; + +describe('transferStore.startDownload (blob fallback)', () => { + beforeEach(() => { + useTransferStore.setState({ transfers: new Map() }); + delete (window as unknown as { showSaveFilePicker?: unknown }).showSaveFilePicker; + if (typeof localStorage !== 'undefined') localStorage.clear(); + }); + + it('downloads via in-memory blob and triggers an anchor click', async () => { + const data = new Uint8Array([1, 2, 3]); + (globalThis.fetch as unknown) = vi.fn().mockResolvedValue(new Response(data, { + status: 200, + headers: { 'Content-Length': '3' }, + })); + (URL as unknown as { createObjectURL: () => string }).createObjectURL = vi.fn(() => 'blob:fake'); + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = vi.fn(); + + const clicked = vi.fn(); + const orig = document.createElement.bind(document); + document.createElement = ((tag: string) => { + const el = orig(tag) as HTMLElement & { click?: () => void }; + if (tag === 'a') (el as unknown as { click: () => void }).click = clicked; + return el; + }) as typeof document.createElement; + + const id = await useTransferStore.getState().startDownload('https://e.x/file.bin', { + filename: 'file.bin', size: 3, mimetype: 'application/octet-stream', tray: true, + }); + expect(useTransferStore.getState().get(id)!.state).toBe('completed'); + expect(clicked).toHaveBeenCalled(); + + document.createElement = orig as typeof document.createElement; + }); + + it('marks transfer failed on non-2xx response', async () => { + (globalThis.fetch as unknown) = vi.fn().mockResolvedValue(new Response('nope', { status: 404 })); + + const id = await useTransferStore.getState().startDownload('https://e.x/missing', { + filename: 'missing.bin', tray: true, + }); + const t = useTransferStore.getState().get(id)!; + expect(t.state).toBe('failed'); + expect(t.error?.permanent).toBe(true); // 4xx is permanent + }); + + it('updates progress as bytes arrive', async () => { + const big = new Uint8Array(1024); + (globalThis.fetch as unknown) = vi.fn().mockResolvedValue(new Response(big, { + status: 200, + headers: { 'Content-Length': '1024' }, + })); + (URL as unknown as { createObjectURL: () => string }).createObjectURL = vi.fn(() => 'blob:fake'); + (URL as unknown as { revokeObjectURL: () => void }).revokeObjectURL = vi.fn(); + + const id = await useTransferStore.getState().startDownload('https://e.x/big', { + filename: 'big.bin', size: 1024, tray: true, + }); + const t = useTransferStore.getState().get(id)!; + expect(t.progress.loaded).toBe(1024); + }); +}); + +describe('transferStore.abortDownload + pauseDownload', () => { + beforeEach(() => { + useTransferStore.setState({ transfers: new Map() }); + }); + + it('abortDownload sets state aborted', () => { + const id = useTransferStore.getState().createTransfer({ + type: 'download', + file: { name: 'a.bin', size: 100, mimetype: 'application/octet-stream' }, + tray: true, + sourceUrl: 'https://e.x/a', + }); + useTransferStore.getState().abortDownload(id); + expect(useTransferStore.getState().get(id)!.state).toBe('aborted'); + }); + + it('pauseDownload sets state paused', () => { + const id = useTransferStore.getState().createTransfer({ + type: 'download', + file: { name: 'a.bin', size: 100, mimetype: 'application/octet-stream' }, + tray: true, + sourceUrl: 'https://e.x/a', + }); + useTransferStore.getState().pauseDownload(id); + expect(useTransferStore.getState().get(id)!.state).toBe('paused'); + }); +}); + +describe('transferStore.resumeDownload', () => { + beforeEach(() => { + useTransferStore.setState({ transfers: new Map() }); + }); + + it('marks failed when no destFileHandleId stored', async () => { + const id = useTransferStore.getState().createTransfer({ + type: 'download', + file: { name: 'a.bin', size: 100, mimetype: 'application/octet-stream' }, + tray: true, + sourceUrl: 'https://e.x/a', + }); + useTransferStore.getState().setState_(id, 'paused'); + await useTransferStore.getState().resumeDownload(id); + expect(useTransferStore.getState().get(id)!.state).toBe('failed'); + expect(useTransferStore.getState().get(id)!.error?.message).toMatch(/no destination handle/i); + }); +}); diff --git a/packages/web/src/stores/transferStore.ts b/packages/web/src/stores/transferStore.ts index eccc3f02..548ee1d2 100644 --- a/packages/web/src/stores/transferStore.ts +++ b/packages/web/src/stores/transferStore.ts @@ -67,6 +67,11 @@ interface TransferStoreActions { pauseUpload: (id: string) => void; resumeUpload: (id: string) => Promise; + startDownload: (url: string, opts: { filename: string; size?: number; mimetype?: string; tray?: boolean }) => Promise; + abortDownload: (id: string) => void; + pauseDownload: (id: string) => void; + resumeDownload: (id: string) => Promise; + get: (id: string) => Transfer | undefined; listVisible: () => Transfer[]; listForChannel: (channelId: string) => Transfer[]; @@ -81,6 +86,9 @@ function uuid(): string { // Live tus Upload instances — keyed by transferId. Not serializable, never persisted. const liveUploads = new Map(); +// Live download AbortControllers — keyed by transferId. Not serializable, never persisted. +const liveDownloads = new Map(); + // Custom storage that serializes Map as an array of entries. // Only the `transfers` slice is persisted; partialize controls which entries. const mapAwareStorage: PersistStorage> = { @@ -354,6 +362,193 @@ export const useTransferStore = create()( upload.start(); }, + startDownload: async (url, opts) => { + const fileLike = { + name: opts.filename, + size: opts.size ?? 0, + mimetype: opts.mimetype ?? 'application/octet-stream', + }; + const id = get().createTransfer({ + type: 'download', + file: fileLike, + tray: opts.tray ?? true, + sourceUrl: url, + }); + + const controller = new AbortController(); + liveDownloads.set(id, controller); + get().setState_(id, 'active'); + + // FS Access path: only when the API exists. We DON'T require opts.size — + // size is unknown for some downloads and we want resume capability anyway. + const supportsFs = typeof (window as unknown as { showSaveFilePicker?: unknown }).showSaveFilePicker === 'function'; + + try { + if (supportsFs) { + const { putHandle } = await import('../utils/idbHandles'); + const showSavePicker = (window as unknown as { + showSaveFilePicker: (opts: { suggestedName: string }) => Promise; + }).showSaveFilePicker; + let handle: FileSystemFileHandle; + try { + handle = await showSavePicker({ suggestedName: opts.filename }); + } catch { + // User canceled the picker — abort cleanly. + get().setState_(id, 'aborted'); + liveDownloads.delete(id); + return id; + } + const handleId = `dl-${id}`; + await putHandle(handleId, handle); + // Persist the handle key on the transfer. + set((s) => { + const t = s.transfers.get(id); + if (!t) return s; + const next = new Map(s.transfers); + next.set(id, { ...t, destFileHandleId: handleId, bytesWrittenToDisk: 0 }); + return { transfers: next }; + }); + const writable = await handle.createWritable({ keepExistingData: false }); + const resp = await fetch(url, { signal: controller.signal }); + if (!resp.ok || !resp.body) throw new Error(`Download failed: ${resp.status}`); + const reader = resp.body.getReader(); + let written = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + await writable.write(value); + written += value.byteLength; + get().updateProgress(id, written); + set((s) => { + const t = s.transfers.get(id); + if (!t) return s; + const next = new Map(s.transfers); + next.set(id, { ...t, bytesWrittenToDisk: written }); + return { transfers: next }; + }); + } + await writable.close(); + get().setState_(id, 'completed'); + } else { + // Blob fallback — accumulate in memory, then trigger anchor click. + const resp = await fetch(url, { signal: controller.signal }); + if (!resp.ok) throw new Error(`Download failed: ${resp.status}`); + const total = Number(resp.headers.get('Content-Length') ?? opts.size ?? 0); + const chunks: Uint8Array[] = []; + const reader = resp.body!.getReader(); + let loaded = 0; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + chunks.push(value); + loaded += value.byteLength; + if (total) get().updateProgress(id, loaded); + } + const blob = new Blob(chunks as BlobPart[], { type: fileLike.mimetype }); + const objUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = objUrl; + a.download = opts.filename; + a.click(); + URL.revokeObjectURL(objUrl); + get().setState_(id, 'completed'); + } + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + // Aborted via abortDownload/pauseDownload — state already set there. + } else { + const msg = err instanceof Error ? err.message : 'Download failed'; + const permanent = /\b4\d\d\b/.test(msg); + get().setError(id, { message: msg, permanent }); + } + } finally { + liveDownloads.delete(id); + } + + return id; + }, + + abortDownload: (id) => { + const c = liveDownloads.get(id); + if (c) c.abort(); + liveDownloads.delete(id); + get().setState_(id, 'aborted'); + }, + + pauseDownload: (id) => { + const c = liveDownloads.get(id); + if (c) c.abort(); + liveDownloads.delete(id); + get().setState_(id, 'paused'); + }, + + resumeDownload: async (id) => { + const t = get().get(id); + if (!t || t.type !== 'download' || !t.sourceUrl) return; + if (!t.destFileHandleId) { + get().setError(id, { message: 'No destination handle — cannot resume', permanent: true }); + return; + } + const { getHandle, ensurePermission } = await import('../utils/idbHandles'); + const handle = await getHandle(t.destFileHandleId); + if (!handle) { + get().setError(id, { message: 'Destination handle missing', permanent: true }); + return; + } + const perm = await ensurePermission(handle, 'readwrite'); + if (perm !== 'granted') { + get().setError(id, { message: 'Permission denied', permanent: false }); + return; + } + const handleAny = handle as unknown as { + getFile?: () => Promise; + createWritable?: (opts: { keepExistingData: boolean }) => Promise; + }; + const fileNow = await handleAny.getFile!(); + const offset = fileNow.size; + const writable = await handleAny.createWritable!({ keepExistingData: true }); + await (writable as unknown as { seek: (pos: number) => Promise }).seek(offset); + + const controller = new AbortController(); + liveDownloads.set(id, controller); + get().setState_(id, 'active'); + try { + const resp = await fetch(t.sourceUrl, { + signal: controller.signal, + headers: { Range: `bytes=${offset}-` }, + }); + if (!resp.ok && resp.status !== 206) throw new Error(`Resume failed: ${resp.status}`); + const reader = resp.body!.getReader(); + let written = offset; + for (;;) { + const { value, done } = await reader.read(); + if (done) break; + await writable.write(value); + written += value.byteLength; + get().updateProgress(id, written); + set((s) => { + const cur = s.transfers.get(id); + if (!cur) return s; + const next = new Map(s.transfers); + next.set(id, { ...cur, bytesWrittenToDisk: written }); + return { transfers: next }; + }); + } + await writable.close(); + get().setState_(id, 'completed'); + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') { + get().setState_(id, 'paused'); + } else { + const msg = err instanceof Error ? err.message : 'Resume failed'; + const permanent = /\b4\d\d\b/.test(msg); + get().setError(id, { message: msg, permanent }); + } + } finally { + liveDownloads.delete(id); + } + }, + get: (id) => get().transfers.get(id), listVisible: () => Array.from(get().transfers.values()).filter((t) => t.tray), listForChannel: (channelId) =>