From e81848f6540f5ef5da15e296ca257b3339333139 Mon Sep 17 00:00:00 2001 From: neon <18559658+neonsolstice@users.noreply.github.com> Date: Thu, 27 Aug 2026 18:10:51 +0000 Subject: [PATCH] fix(client): report progress while an iOS web app buffers a download A home-screen web app cannot follow a streaming attachment link, so it buffers the whole file first, and buffering said nothing: a large book is about 45 seconds of a static UI. Read the body through a reader and render the byte count as a progress toast, keeping the Content-Type so WebKit still names the file. Also default the book detail cover tint to off. Refs #1159 --- .../__tests__/useDisplaySettings.spec.ts | 6 +- client/src/composables/useDisplaySettings.ts | 2 +- .../__tests__/useBookDownload.spec.ts | 177 +++++++++++++++++- .../book/composables/useBookDownload.ts | 67 ++++++- .../AppearanceBookCoverSettings.spec.ts | 2 +- client/src/lib/__tests__/download.spec.ts | 127 +++++++++++++ client/src/lib/download.ts | 51 ++++- client/src/locales/en.json | 8 +- 8 files changed, 414 insertions(+), 26 deletions(-) diff --git a/client/src/composables/__tests__/useDisplaySettings.spec.ts b/client/src/composables/__tests__/useDisplaySettings.spec.ts index 5617dc3ec..73a964441 100644 --- a/client/src/composables/__tests__/useDisplaySettings.spec.ts +++ b/client/src/composables/__tests__/useDisplaySettings.spec.ts @@ -22,7 +22,7 @@ function resetDisplaySettings() { settings.showSpineOnComics.value = false settings.bookShadowStrength.value = 'default' settings.bookCoverDisplayMode.value = 'blurred-fit' - settings.bookDetailCoverTint.value = 'single' + settings.bookDetailCoverTint.value = 'off' settings.seriesCardCoverMode.value = 'stack' settings.gridCardPrimaryLabel.value = 'hidden' settings.gridCardSecondaryLabel.value = 'hidden' @@ -207,9 +207,9 @@ describe('useDisplaySettings preferences helpers', () => { expect(getDisplayPreferencesSnapshot().bookDetailCoverTint).toBe('duotone') }) - it('defaults bookDetailCoverTint to single', () => { + it('defaults bookDetailCoverTint to off', () => { resetDisplaySettings() - expect(settings.bookDetailCoverTint.value).toBe('single') + expect(settings.bookDetailCoverTint.value).toBe('off') }) it('sanitizes bookDetailCoverTint and drops unknown values', () => { diff --git a/client/src/composables/useDisplaySettings.ts b/client/src/composables/useDisplaySettings.ts index ba03a8c61..4679e8f3f 100644 --- a/client/src/composables/useDisplaySettings.ts +++ b/client/src/composables/useDisplaySettings.ts @@ -53,7 +53,7 @@ const DEFAULT_GRID_GAP = 28 const DEFAULT_BOOK_SPINE_OVERLAY: BookSpineOverlay = 'off' const DEFAULT_BOOK_SHADOW_STRENGTH: BookShadowStrength = 'default' const DEFAULT_BOOK_COVER_DISPLAY_MODE: BookCoverDisplayMode = 'blurred-fit' -const DEFAULT_BOOK_DETAIL_COVER_TINT: BookDetailCoverTint = 'single' +const DEFAULT_BOOK_DETAIL_COVER_TINT: BookDetailCoverTint = 'off' const DEFAULT_SERIES_CARD_COVER_MODE: SeriesCardCoverMode = 'stack' const DEFAULT_BOOK_THUMBNAIL_CLICK_ACTION: BookThumbnailClickAction = 'reader' const DEFAULT_CARD_OVERLAYS: CardOverlayKey[] = ['progress-bar', 'format', 'rating', 'read-status', 'series-position'] diff --git a/client/src/features/book/composables/__tests__/useBookDownload.spec.ts b/client/src/features/book/composables/__tests__/useBookDownload.spec.ts index b63acfe39..e9c4c58a1 100644 --- a/client/src/features/book/composables/__tests__/useBookDownload.spec.ts +++ b/client/src/features/book/composables/__tests__/useBookDownload.spec.ts @@ -1,13 +1,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { DownloadProgress } from '@/lib/download' + const toastMock = vi.hoisted(() => ({ - loading: vi.fn<(message: string) => string>().mockReturnValue('toast-id'), + loading: vi.fn<(message: string, data?: { id?: string | number }) => string>().mockReturnValue('toast-id'), dismiss: vi.fn<(id: string) => void>(), error: vi.fn<(message: string) => void>(), })) const downloadMock = vi.hoisted(() => ({ - downloadFromUrl: vi.fn<(url: string, fallbackFileName: string) => Promise>().mockResolvedValue(undefined), + downloadFromUrl: vi + .fn<(url: string, fallbackFileName: string, onProgress?: (progress: DownloadProgress) => void) => Promise>() + .mockResolvedValue(undefined), })) vi.mock('vue-sonner', () => ({ @@ -16,10 +20,18 @@ vi.mock('vue-sonner', () => ({ vi.mock('@/lib/download', () => downloadMock) +/** The copy assertions below are English, so pin the locale rather than trusting the default. */ +async function loadComposable() { + const { i18n } = await import('@/i18n') + ;(i18n.global.locale as unknown as { value: string }).value = 'en' + return (await import('../useBookDownload')).useBookDownload +} + describe('useBookDownload', () => { beforeEach(() => { vi.resetModules() toastMock.loading.mockClear() + toastMock.loading.mockReturnValue('toast-id') toastMock.dismiss.mockClear() toastMock.error.mockClear() downloadMock.downloadFromUrl.mockClear() @@ -27,12 +39,14 @@ describe('useBookDownload', () => { }) it('downloads a single file from its download endpoint', async () => { - const { useBookDownload } = await import('../useBookDownload') + const useBookDownload = await loadComposable() const { downloadFile, isDownloading } = useBookDownload() await downloadFile(42) - expect(downloadMock.downloadFromUrl).toHaveBeenCalledWith('/api/v1/books/files/42/download', 'book') + const [url, fallback] = downloadMock.downloadFromUrl.mock.calls[0] ?? [] + expect(url).toBe('/api/v1/books/files/42/download') + expect(fallback).toBe('book') expect(toastMock.error).not.toHaveBeenCalled() expect(isDownloading.value).toBe(false) }) @@ -40,7 +54,7 @@ describe('useBookDownload', () => { it('reports a failed single-file download and clears the busy flag', async () => { downloadMock.downloadFromUrl.mockRejectedValue(new Error('Download failed with status 403')) - const { useBookDownload } = await import('../useBookDownload') + const useBookDownload = await loadComposable() const { downloadFile, isDownloading } = useBookDownload() await downloadFile(42) @@ -50,7 +64,7 @@ describe('useBookDownload', () => { }) it('uses primary export scope by default', async () => { - const { useBookDownload } = await import('../useBookDownload') + const useBookDownload = await loadComposable() const { exportBooks } = useBookDownload() await exportBooks([1, 2], false) @@ -63,7 +77,7 @@ describe('useBookDownload', () => { }) it('uses all-formats scope when requested', async () => { - const { useBookDownload } = await import('../useBookDownload') + const useBookDownload = await loadComposable() const { exportBooks } = useBookDownload() await exportBooks([5], true) @@ -72,7 +86,7 @@ describe('useBookDownload', () => { }) it('supports audio-only export scope override', async () => { - const { useBookDownload } = await import('../useBookDownload') + const useBookDownload = await loadComposable() const { exportBooks } = useBookDownload() await exportBooks([9], false, 'audio') @@ -88,7 +102,7 @@ describe('useBookDownload', () => { }), ) - const { useBookDownload } = await import('../useBookDownload') + const useBookDownload = await loadComposable() const { exportBooks } = useBookDownload() const pending = exportBooks([1], false) @@ -103,7 +117,7 @@ describe('useBookDownload', () => { it('reports a failed export', async () => { downloadMock.downloadFromUrl.mockRejectedValue(new Error('Download failed with status 500')) - const { useBookDownload } = await import('../useBookDownload') + const useBookDownload = await loadComposable() const { exportBooks, isDownloading } = useBookDownload() await exportBooks([1], false) @@ -113,3 +127,146 @@ describe('useBookDownload', () => { expect(isDownloading.value).toBe(false) }) }) + +describe('useBookDownload progress reporting', () => { + beforeEach(() => { + vi.resetModules() + toastMock.loading.mockClear() + toastMock.loading.mockReturnValue('toast-id') + toastMock.dismiss.mockClear() + toastMock.error.mockClear() + downloadMock.downloadFromUrl.mockClear() + downloadMock.downloadFromUrl.mockResolvedValue(undefined) + }) + + it('says nothing for a single file when no progress is reported', async () => { + const useBookDownload = await loadComposable() + const { downloadFile } = useBookDownload() + + await downloadFile(42) + + expect(toastMock.loading).not.toHaveBeenCalled() + expect(toastMock.dismiss).not.toHaveBeenCalled() + }) + + it('raises a toast as soon as a buffered single-file download starts', async () => { + downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => { + onProgress?.({ loaded: 0, total: null }) + }) + + const useBookDownload = await loadComposable() + const { downloadFile } = useBookDownload() + + await downloadFile(42) + + expect(toastMock.loading).toHaveBeenCalledWith('Preparing download...') + expect(toastMock.dismiss).toHaveBeenCalledWith('toast-id') + }) + + it('shows how far along a measurable download is', async () => { + downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => { + onProgress?.({ loaded: 0, total: null }) + onProgress?.({ loaded: 0, total: 100 * 1024 * 1024 }) + onProgress?.({ loaded: 42 * 1024 * 1024, total: 100 * 1024 * 1024 }) + }) + + const useBookDownload = await loadComposable() + const { downloadFile } = useBookDownload() + + await downloadFile(42) + + expect(toastMock.loading.mock.calls.map((call) => call[0])).toEqual([ + 'Preparing download...', + 'Downloading 0% of 100 MB', + 'Downloading 42% of 100 MB', + ]) + }) + + it('updates the one toast in place instead of stacking a new one per chunk', async () => { + downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => { + onProgress?.({ loaded: 0, total: 1000 }) + onProgress?.({ loaded: 500, total: 1000 }) + }) + + const useBookDownload = await loadComposable() + const { downloadFile } = useBookDownload() + + await downloadFile(42) + + expect(toastMock.loading.mock.calls[0]?.[1]).toBeUndefined() + expect(toastMock.loading.mock.calls[1]?.[1]).toEqual({ id: 'toast-id' }) + expect(toastMock.dismiss).toHaveBeenCalledTimes(1) + }) + + it('collapses the thousands of chunks inside one percent into a single update', async () => { + const total = 100 * 1024 * 1024 + downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => { + // Every 64KB chunk across the first two percent: ~32 reports, two distinct messages. + for (let loaded = 0; loaded <= total * 0.02; loaded += 64 * 1024) onProgress?.({ loaded, total }) + }) + + const useBookDownload = await loadComposable() + const { downloadFile } = useBookDownload() + + await downloadFile(42) + + expect(toastMock.loading.mock.calls.map((call) => call[0])).toEqual([ + 'Downloading 0% of 100 MB', + 'Downloading 1% of 100 MB', + 'Downloading 2% of 100 MB', + ]) + }) + + it('falls back to transferred bytes when the response declares no length', async () => { + downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => { + onProgress?.({ loaded: 0, total: null }) + onProgress?.({ loaded: 5 * 1024 * 1024, total: null }) + }) + + const useBookDownload = await loadComposable() + const { downloadFile } = useBookDownload() + + await downloadFile(42) + + expect(toastMock.loading.mock.calls.map((call) => call[0])).toEqual(['Preparing download...', 'Downloading 5 MB']) + }) + + it('keeps the export label until real progress replaces it', async () => { + downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => { + onProgress?.({ loaded: 0, total: null }) + onProgress?.({ loaded: 25, total: 100 }) + }) + + const useBookDownload = await loadComposable() + const { exportBooks } = useBookDownload() + + await exportBooks([1, 2, 3], false) + + expect(toastMock.loading.mock.calls.map((call) => call[0])).toEqual(['Preparing 3 books for download...', 'Downloading 25% of 100 B']) + }) + + it('pluralises the export label for a single book', async () => { + const useBookDownload = await loadComposable() + const { exportBooks } = useBookDownload() + + await exportBooks([7], false) + + expect(toastMock.loading).toHaveBeenCalledWith('Preparing 1 book for download...') + }) + + it('dismisses the progress toast when a buffered download fails', async () => { + downloadMock.downloadFromUrl.mockImplementation(async (_url, _fallback, onProgress) => { + onProgress?.({ loaded: 0, total: null }) + throw new Error('Download failed with status 500') + }) + + const useBookDownload = await loadComposable() + const { downloadFile, isDownloading } = useBookDownload() + + await downloadFile(42) + + expect(toastMock.error).toHaveBeenCalledWith('Download failed') + expect(toastMock.dismiss).toHaveBeenCalledWith('toast-id') + expect(isDownloading.value).toBe(false) + }) +}) diff --git a/client/src/features/book/composables/useBookDownload.ts b/client/src/features/book/composables/useBookDownload.ts index f00cd692a..b518ba670 100644 --- a/client/src/features/book/composables/useBookDownload.ts +++ b/client/src/features/book/composables/useBookDownload.ts @@ -1,28 +1,81 @@ import { ref } from 'vue' import { toast } from 'vue-sonner' +import { i18n } from '@/i18n' +import { formatPercent } from '@/i18n/formatters' +import type { DownloadProgress } from '@/lib/download' import { downloadFromUrl } from '@/lib/download' +import { formatBytes } from '@/lib/formatting' type ExportScope = 'primary' | 'all' | 'audio' +// Reached through the global instance rather than `useI18n()` because `useBookDownload` is also +// called from inside other composables, where there is no setup scope to bind to. +function t(key: string, named?: Record): string { + return named ? i18n.global.t(key, named) : i18n.global.t(key) +} + +/** Null while nothing more useful than "we have started" is known yet. */ +function progressMessage(progress: DownloadProgress): string | null { + if (progress.total === null) { + return progress.loaded > 0 ? t('book.download.progressBytes', { transferred: formatBytes(progress.loaded) }) : null + } + return t('book.download.progressPercent', { + percent: formatPercent(progress.loaded / progress.total), + total: formatBytes(progress.total), + }) +} + +/** + * A large file arrives as thousands of chunks, so the rendered message is the throttle: at whole + * percents it settles to at most one toast update per percent instead of one per chunk. + * + * `showImmediately` separates the two waits. An export has to be built server-side before a byte + * moves, so it announces itself up front; a single file streams at once, and only says anything + * when the download is one we are buffering ourselves. Everywhere else the browser's own download + * UI is already the indicator, and a toast would just duplicate it. + */ +function createDownloadToast(pendingMessage: string, showImmediately: boolean) { + let id: string | number | undefined + let shown: string | null = null + + function render(message: string) { + if (message === shown) return + shown = message + id = id === undefined ? toast.loading(message) : toast.loading(message, { id }) + } + + if (showImmediately) render(pendingMessage) + + return { + report(progress: DownloadProgress) { + render(progressMessage(progress) ?? pendingMessage) + }, + dismiss() { + if (id !== undefined) toast.dismiss(id) + }, + } +} + export function useBookDownload() { const isDownloading = ref(false) async function downloadFile(fileId: number): Promise { + const progress = createDownloadToast(t('book.download.preparing'), false) isDownloading.value = true try { - await downloadFromUrl(`/api/v1/books/files/${fileId}/download`, 'book') + await downloadFromUrl(`/api/v1/books/files/${fileId}/download`, 'book', progress.report) } catch { - toast.error('Download failed') + toast.error(t('book.download.failed')) } finally { + progress.dismiss() isDownloading.value = false } } async function exportBooks(bookIds: number[], allFormats: boolean, scopeOverride?: ExportScope): Promise { if (bookIds.length === 0) return - const label = `${bookIds.length} book${bookIds.length === 1 ? '' : 's'}` - const toastId = toast.loading(`Preparing ${label} for download...`) + const progress = createDownloadToast(t('book.download.preparingExport', { count: bookIds.length }), true) isDownloading.value = true try { const scope = scopeOverride ?? (allFormats ? 'all' : 'primary') @@ -30,11 +83,11 @@ export function useBookDownload() { bookIds: bookIds.join(','), scope, }) - await downloadFromUrl(`/api/v1/books/export/download?${params.toString()}`, 'bookorbit-export.zip') + await downloadFromUrl(`/api/v1/books/export/download?${params.toString()}`, 'bookorbit-export.zip', progress.report) } catch { - toast.error('Export failed') + toast.error(t('book.download.exportFailed')) } finally { - toast.dismiss(toastId) + progress.dismiss() isDownloading.value = false } } diff --git a/client/src/features/settings/__tests__/AppearanceBookCoverSettings.spec.ts b/client/src/features/settings/__tests__/AppearanceBookCoverSettings.spec.ts index 311b388f0..b917c2d59 100644 --- a/client/src/features/settings/__tests__/AppearanceBookCoverSettings.spec.ts +++ b/client/src/features/settings/__tests__/AppearanceBookCoverSettings.spec.ts @@ -47,7 +47,7 @@ beforeEach(() => { afterEach(() => { showSpineOnComics.value = false - bookDetailCoverTint.value = 'single' + bookDetailCoverTint.value = 'off' }) describe('AppearanceBookCoverSettings', () => { diff --git a/client/src/lib/__tests__/download.spec.ts b/client/src/lib/__tests__/download.spec.ts index b75d1b27c..8e73fc7a8 100644 --- a/client/src/lib/__tests__/download.spec.ts +++ b/client/src/lib/__tests__/download.spec.ts @@ -1,5 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DownloadProgress } from '../download' + const mocks = vi.hoisted(() => ({ api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise>() })) vi.mock('@/lib/api', () => ({ api: mocks.api })) @@ -8,6 +10,22 @@ function setStandalone(value: boolean | undefined) { Object.defineProperty(navigator, 'standalone', { value, configurable: true, writable: true }) } +/** A Response whose body arrives as the given chunks, one `read()` at a time. */ +function streamingResponse(chunks: Uint8Array[], headers: Record) { + let index = 0 + return { + ok: true, + status: 200, + headers: new Headers(headers), + body: { + getReader: () => ({ + read: async () => (index < chunks.length ? { done: false, value: chunks[index++] } : { done: true, value: undefined }), + }), + }, + blob: async () => new Blob(chunks as BlobPart[], { type: headers['Content-Type'] ?? '' }), + } as unknown as Response +} + describe('downloadFromUrl', () => { beforeEach(() => { vi.resetModules() @@ -36,6 +54,16 @@ describe('downloadFromUrl', () => { expect(removeSpy).toHaveBeenCalledTimes(1) }) + it('stays silent on the link path, where the browser owns the download indicator', async () => { + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + const onProgress = vi.fn<(progress: DownloadProgress) => void>() + + const { downloadFromUrl } = await import('../download') + await downloadFromUrl('/api/v1/books/files/42/download', 'book', onProgress) + + expect(onProgress).not.toHaveBeenCalled() + }) + it('hands an iOS home-screen web app a blob so the app is never navigated away', async () => { setStandalone(true) const appendSpy = vi.spyOn(document.body, 'append') @@ -101,6 +129,105 @@ describe('downloadFromUrl', () => { }) }) +describe('downloadFromUrl progress on the buffered iOS path', () => { + beforeEach(() => { + vi.resetModules() + mocks.api.mockReset() + setStandalone(true) + vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {}) + vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-url') + }) + + afterEach(() => { + vi.restoreAllMocks() + }) + + it('announces the wait before the request resolves, so a slow first byte is not silent', async () => { + let release: ((response: Response) => void) | undefined + mocks.api.mockReturnValue( + new Promise((resolve) => { + release = resolve + }), + ) + const seen: DownloadProgress[] = [] + + const { downloadFromUrl } = await import('../download') + const pending = downloadFromUrl('/api/v1/books/files/42/download', 'book', (p) => seen.push({ ...p })) + await Promise.resolve() + + expect(seen).toEqual([{ loaded: 0, total: null }]) + + release?.(streamingResponse([new Uint8Array([1])], { 'Content-Length': '1' })) + await pending + }) + + it('reports cumulative bytes against the declared length as the body arrives', async () => { + mocks.api.mockResolvedValue( + streamingResponse([new Uint8Array(40), new Uint8Array(35), new Uint8Array(25)], { + 'Content-Length': '100', + 'Content-Type': 'application/epub+zip', + }), + ) + const seen: DownloadProgress[] = [] + + const { downloadFromUrl } = await import('../download') + await downloadFromUrl('/api/v1/books/files/42/download', 'book', (p) => seen.push({ ...p })) + + expect(seen).toEqual([ + { loaded: 0, total: null }, + { loaded: 0, total: 100 }, + { loaded: 40, total: 100 }, + { loaded: 75, total: 100 }, + { loaded: 100, total: 100 }, + ]) + }) + + it('reassembles the streamed chunks into the original bytes and content type', async () => { + const chunks = [new Uint8Array([1, 2, 3]), new Uint8Array([4, 5])] + mocks.api.mockResolvedValue(streamingResponse(chunks, { 'Content-Length': '5', 'Content-Type': 'application/epub+zip' })) + let handed: Blob | undefined + vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + handed = blob as Blob + return 'blob:test-url' + }) + + const { downloadFromUrl } = await import('../download') + await downloadFromUrl('/api/v1/books/files/42/download', 'book', () => {}) + + expect(handed?.type).toBe('application/epub+zip') + expect(new Uint8Array(await handed!.arrayBuffer())).toEqual(new Uint8Array([1, 2, 3, 4, 5])) + }) + + it('leaves the total null when the response declares no usable length', async () => { + mocks.api.mockResolvedValue(streamingResponse([new Uint8Array(8)], { 'Content-Length': '0' })) + const seen: DownloadProgress[] = [] + + const { downloadFromUrl } = await import('../download') + await downloadFromUrl('/api/v1/books/files/42/download', 'book', (p) => seen.push({ ...p })) + + expect(seen.every((p) => p.total === null)).toBe(true) + expect(seen.at(-1)).toEqual({ loaded: 8, total: null }) + }) + + it('still downloads when the response exposes no readable body', async () => { + mocks.api.mockResolvedValue({ + ok: true, + status: 200, + headers: new Headers({ 'Content-Length': '4', 'Content-Type': 'application/epub+zip' }), + blob: async () => new Blob(['epub'], { type: 'application/epub+zip' }), + } as unknown as Response) + const appendSpy = vi.spyOn(document.body, 'append') + const seen: DownloadProgress[] = [] + + const { downloadFromUrl } = await import('../download') + await downloadFromUrl('/api/v1/books/files/42/download', 'book', (p) => seen.push({ ...p })) + + const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement + expect(anchor.href).toBe('blob:test-url') + expect(seen.at(-1)).toEqual({ loaded: 0, total: 4 }) + }) +}) + describe('fileNameFromContentDisposition', () => { it('prefers the UTF-8 filename, falls back to ASCII, then to the caller fallback', async () => { const { fileNameFromContentDisposition } = await import('../download') diff --git a/client/src/lib/download.ts b/client/src/lib/download.ts index f9553ed95..d697774b4 100644 --- a/client/src/lib/download.ts +++ b/client/src/lib/download.ts @@ -15,6 +15,9 @@ function isIosStandaloneWebApp(): boolean { /** Revoking while the browser is still reading the blob cancels the download, so outlive the click. */ const OBJECT_URL_TTL_MS = 60_000 +/** `total` is null when the response arrives without a usable `Content-Length`. */ +export type DownloadProgress = { loaded: number; total: number | null } + export function fileNameFromContentDisposition(disposition: string | null, fallback: string): string { if (!disposition) return fallback const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i) @@ -41,12 +44,50 @@ export function triggerBlobDownload(blob: Blob, filename: string): void { setTimeout(() => URL.revokeObjectURL(url), OBJECT_URL_TTL_MS) } +function declaredLength(response: Response): number | null { + const header = Number(response.headers.get('Content-Length')) + return Number.isFinite(header) && header > 0 ? header : null +} + +/** + * Buffers the body a chunk at a time so the caller can report how far along a large file is. + * `response.blob()` would be shorter, but it resolves only once the last byte has landed, which + * on a phone connection is a minute of the UI having nothing to say. + */ +async function readBlobWithProgress(response: Response, onProgress: (progress: DownloadProgress) => void): Promise { + const total = declaredLength(response) + const reader = response.body?.getReader?.() + // A body-less Response still has to download, it just cannot be measured. + if (!reader) { + onProgress({ loaded: 0, total }) + return response.blob() + } + + const contentType = response.headers.get('Content-Type') ?? '' + const chunks: BlobPart[] = [] + let loaded = 0 + onProgress({ loaded, total }) + for (;;) { + const { done, value } = await reader.read() + if (done) break + chunks.push(value as BlobPart) + loaded += value.byteLength + onProgress({ loaded, total }) + } + // `response.blob()` takes the type from the header, so reassembling by hand has to as well or + // WebKit loses the file kind and offers the sheet as an unnamed binary. + return new Blob(chunks, { type: contentType }) +} + /** * Downloads an attachment endpoint. Everywhere the browser owns a window it can navigate away * from, the link streams straight from the server so nothing has to be buffered; only the * trapped iOS web app pays for a blob. + * + * `onProgress` fires only on that buffered path, because it is the only one where the wait is + * ours to report: a plain link hands off to the browser's own download UI immediately. */ -export async function downloadFromUrl(url: string, fallbackFileName: string): Promise { +export async function downloadFromUrl(url: string, fallbackFileName: string, onProgress?: (progress: DownloadProgress) => void): Promise { if (!isIosStandaloneWebApp()) { const anchor = document.createElement('a') anchor.href = url @@ -57,8 +98,12 @@ export async function downloadFromUrl(url: string, fallbackFileName: string): Pr return } + // Announce the wait before the request goes out, so a slow first byte is not silent either. + onProgress?.({ loaded: 0, total: null }) + const response = await api(url) if (!response.ok) throw new Error(`Download failed with status ${response.status}`) - const blob = await response.blob() - triggerBlobDownload(blob, fileNameFromContentDisposition(response.headers.get('Content-Disposition'), fallbackFileName)) + const filename = fileNameFromContentDisposition(response.headers.get('Content-Disposition'), fallbackFileName) + const blob = onProgress ? await readBlobWithProgress(response, onProgress) : await response.blob() + triggerBlobDownload(blob, filename) } diff --git a/client/src/locales/en.json b/client/src/locales/en.json index 72997bb4b..3c180ac81 100644 --- a/client/src/locales/en.json +++ b/client/src/locales/en.json @@ -3991,7 +3991,13 @@ "action": "Download", "audiobookZip": "Audiobook (ZIP)", "allFormatsZip": "All formats (ZIP)", - "primaryOnlyZip": "Primary only (ZIP)" + "primaryOnlyZip": "Primary only (ZIP)", + "preparing": "Preparing download...", + "preparingExport": "Preparing {count, plural, one {# book} other {# books}} for download...", + "progressPercent": "Downloading {percent} of {total}", + "progressBytes": "Downloading {transferred}", + "failed": "Download failed", + "exportFailed": "Export failed" }, "sort": { "moveUp": "Move up",