fix(client): keep downloads from stranding an iOS home-screen web app

A web app installed to the Home Screen has no browser chrome, so navigating it at an
attachment URL swapped the app for WebKit's document preview with no way back. Neither the
download attribute nor target="_blank" is honoured there, so hand that one case a blob and
keep the streaming link everywhere the browser owns a window it can leave.

Closes #1158
This commit is contained in:
neon
2026-08-27 16:11:00 +00:00
parent 399129e447
commit 84c3cfc118
8 changed files with 251 additions and 92 deletions
@@ -6,6 +6,7 @@ import { useRouter } from 'vue-router'
import { toast } from 'vue-sonner'
import { ANNOTATION_HIGHLIGHT_COLORS, type AnnotationHubItem, type AnnotationItem } from '@bookorbit/types'
import { formatDate } from '@/i18n/formatters'
import { downloadFromUrl } from '@/lib/download'
import AnnotationStream from '../components/shared/AnnotationStream.vue'
import AnnotationEntry from '../components/shared/AnnotationEntry.vue'
import AnnotationEntryDetail from '../components/shared/AnnotationEntryDetail.vue'
@@ -190,7 +191,7 @@ async function handleBulkRestore() {
}
function handleExport(format: 'md' | 'csv' | 'json') {
window.open(hub.exportUrl(format), '_blank')
void downloadFromUrl(hub.exportUrl(format), `annotations.${format}`)
}
watch(
@@ -6,6 +6,7 @@ import { useI18n } from 'vue-i18n'
import { toast } from 'vue-sonner'
import { ANNOTATION_HIGHLIGHT_COLORS, READER_OPENABLE_FORMATS, type AnnotationItem, type BookDetail } from '@bookorbit/types'
import { formatDate } from '@/i18n/formatters'
import { downloadFromUrl } from '@/lib/download'
import { useDeferredLoading } from '@/composables/useDeferredLoading'
import AnnotationStream from '@/features/annotations/components/shared/AnnotationStream.vue'
import AnnotationEntry from '@/features/annotations/components/shared/AnnotationEntry.vue'
@@ -167,7 +168,7 @@ async function handleBulkTrash() {
}
function handleExport(format: 'md' | 'csv' | 'json') {
window.open(`/api/v1/annotations/export?format=${format}&bookId=${props.book.id}`, '_blank')
void downloadFromUrl(`/api/v1/annotations/export?format=${format}&bookId=${props.book.id}`, `annotations.${format}`)
}
function isTypingTarget(target: EventTarget | null): boolean {
@@ -6,91 +6,110 @@ const toastMock = vi.hoisted(() => ({
error: vi.fn<(message: string) => void>(),
}))
const downloadMock = vi.hoisted(() => ({
downloadFromUrl: vi.fn<(url: string, fallbackFileName: string) => Promise<void>>().mockResolvedValue(undefined),
}))
vi.mock('vue-sonner', () => ({
toast: toastMock,
}))
vi.mock('@/lib/download', () => downloadMock)
describe('useBookDownload', () => {
beforeEach(() => {
vi.resetModules()
toastMock.loading.mockClear()
toastMock.dismiss.mockClear()
toastMock.error.mockClear()
downloadMock.downloadFromUrl.mockClear()
downloadMock.downloadFromUrl.mockResolvedValue(undefined)
})
it('triggers browser-native single-file download endpoint', async () => {
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const appendSpy = vi.spyOn(document.body, 'append')
const removeSpy = vi.spyOn(HTMLElement.prototype, 'remove')
it('downloads a single file from its download endpoint', async () => {
const { useBookDownload } = await import('../useBookDownload')
const { downloadFile } = useBookDownload()
const { downloadFile, isDownloading } = useBookDownload()
await downloadFile(42)
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(anchor.href).toContain('/api/v1/books/files/42/download')
expect(clickSpy).toHaveBeenCalledTimes(1)
expect(removeSpy).toHaveBeenCalledTimes(1)
expect(downloadMock.downloadFromUrl).toHaveBeenCalledWith('/api/v1/books/files/42/download', 'book')
expect(toastMock.error).not.toHaveBeenCalled()
expect(isDownloading.value).toBe(false)
})
clickSpy.mockRestore()
appendSpy.mockRestore()
removeSpy.mockRestore()
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 { downloadFile, isDownloading } = useBookDownload()
await downloadFile(42)
expect(toastMock.error).toHaveBeenCalledWith('Download failed')
expect(isDownloading.value).toBe(false)
})
it('uses primary export scope by default', async () => {
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const appendSpy = vi.spyOn(document.body, 'append')
const { useBookDownload } = await import('../useBookDownload')
const { exportBooks } = useBookDownload()
await exportBooks([1, 2], false)
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(anchor.href).toContain('/api/v1/books/export/download?')
expect(anchor.href).toContain('bookIds=1%2C2')
expect(anchor.href).toContain('scope=primary')
expect(anchor.download).toBe('')
expect(clickSpy).toHaveBeenCalledTimes(1)
clickSpy.mockRestore()
appendSpy.mockRestore()
const [url, fallback] = downloadMock.downloadFromUrl.mock.calls[0] ?? []
expect(url).toContain('/api/v1/books/export/download?')
expect(url).toContain('bookIds=1%2C2')
expect(url).toContain('scope=primary')
expect(fallback).toBe('bookorbit-export.zip')
})
it('uses all-formats scope when requested', async () => {
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const appendSpy = vi.spyOn(document.body, 'append')
const { useBookDownload } = await import('../useBookDownload')
const { exportBooks } = useBookDownload()
await exportBooks([5], true)
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(anchor.href).toContain('scope=all')
expect(anchor.download).toBe('')
expect(clickSpy).toHaveBeenCalledTimes(1)
clickSpy.mockRestore()
appendSpy.mockRestore()
expect(downloadMock.downloadFromUrl.mock.calls[0]?.[0]).toContain('scope=all')
})
it('supports audio-only export scope override', async () => {
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const appendSpy = vi.spyOn(document.body, 'append')
const { useBookDownload } = await import('../useBookDownload')
const { exportBooks } = useBookDownload()
await exportBooks([9], false, 'audio')
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(anchor.href).toContain('scope=audio')
expect(anchor.download).toBe('')
expect(clickSpy).toHaveBeenCalledTimes(1)
expect(downloadMock.downloadFromUrl.mock.calls[0]?.[0]).toContain('scope=audio')
})
clickSpy.mockRestore()
appendSpy.mockRestore()
it('keeps the preparing toast up until the export has been handed over', async () => {
let release: (() => void) | undefined
downloadMock.downloadFromUrl.mockReturnValue(
new Promise<void>((resolve) => {
release = resolve
}),
)
const { useBookDownload } = await import('../useBookDownload')
const { exportBooks } = useBookDownload()
const pending = exportBooks([1], false)
await Promise.resolve()
expect(toastMock.dismiss).not.toHaveBeenCalled()
release?.()
await pending
expect(toastMock.dismiss).toHaveBeenCalledWith('toast-id')
})
it('reports a failed export', async () => {
downloadMock.downloadFromUrl.mockRejectedValue(new Error('Download failed with status 500'))
const { useBookDownload } = await import('../useBookDownload')
const { exportBooks, isDownloading } = useBookDownload()
await exportBooks([1], false)
expect(toastMock.error).toHaveBeenCalledWith('Export failed')
expect(toastMock.dismiss).toHaveBeenCalledWith('toast-id')
expect(isDownloading.value).toBe(false)
})
})
@@ -175,7 +175,6 @@ describe('useBookMetadataExport', () => {
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(anchor.download).toBe('bookorbit-library-selected-✅.csv')
expect(createObjectUrlSpy).toHaveBeenCalledTimes(1)
expect(revokeObjectUrlSpy).toHaveBeenCalledWith('blob:test-url')
expect(clickSpy).toHaveBeenCalledTimes(1)
expect(removeSpy).toHaveBeenCalledTimes(1)
expect(result.fileName).toBe('bookorbit-library-selected-✅.csv')
@@ -1,21 +1,9 @@
import { ref } from 'vue'
import { toast } from 'vue-sonner'
type ExportScope = 'primary' | 'all' | 'audio'
import { downloadFromUrl } from '@/lib/download'
async function triggerBrowserDownload(url: string, filename?: string): Promise<void> {
const response = await fetch(url, { credentials: 'same-origin' })
const blob = await response.blob()
const objectUrl = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = objectUrl
anchor.download = filename ?? 'download'
anchor.rel = 'noopener'
document.body.append(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(objectUrl), 30_000)
}
type ExportScope = 'primary' | 'all' | 'audio'
export function useBookDownload() {
const isDownloading = ref(false)
@@ -23,7 +11,7 @@ export function useBookDownload() {
async function downloadFile(fileId: number): Promise<void> {
isDownloading.value = true
try {
await triggerBrowserDownload(`/api/v1/books/files/${fileId}/download`)
await downloadFromUrl(`/api/v1/books/files/${fileId}/download`, 'book')
} catch {
toast.error('Download failed')
} finally {
@@ -42,12 +30,11 @@ export function useBookDownload() {
bookIds: bookIds.join(','),
scope,
})
toast.dismiss(toastId)
await triggerBrowserDownload(`/api/v1/books/export/download?${params.toString()}`)
await downloadFromUrl(`/api/v1/books/export/download?${params.toString()}`, 'bookorbit-export.zip')
} catch {
toast.dismiss(toastId)
toast.error('Export failed')
} finally {
toast.dismiss(toastId)
isDownloading.value = false
}
}
@@ -1,6 +1,7 @@
import { ref } from 'vue'
import type { GroupRule, SortSpec } from '@bookorbit/types'
import { api } from '@/lib/api'
import { fileNameFromContentDisposition, triggerBlobDownload } from '@/lib/download'
export type MetadataExportFormat = 'csv' | 'json'
export type MetadataExportScope = 'selected' | 'all-matching'
@@ -65,32 +66,6 @@ async function readErrorMessage(response: Response, fallback: string): Promise<s
return raw
}
function triggerBlobDownload(blob: Blob, filename: string) {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.rel = 'noopener'
document.body.append(anchor)
anchor.click()
anchor.remove()
URL.revokeObjectURL(url)
}
function extractFileName(disposition: string | null, fallback: string): string {
if (!disposition) return fallback
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1])
} catch {
// Fall through to regular filename parser.
}
}
const asciiMatch = disposition.match(/filename="([^"]+)"/i)
return asciiMatch?.[1] || fallback
}
function toPayload(request: MetadataExportRequest): MetadataExportPayload {
if (request.scope === 'all-matching') {
if (!request.allMatchingQuery) {
@@ -144,7 +119,7 @@ export function useBookMetadataExport() {
}
const blob = await response.blob()
const defaultName = `bookorbit-${request.viewType}-${request.scope}.${request.format}`
const fileName = extractFileName(response.headers.get('Content-Disposition'), defaultName)
const fileName = fileNameFromContentDisposition(response.headers.get('Content-Disposition'), defaultName)
triggerBlobDownload(blob, fileName)
return {
+113
View File
@@ -0,0 +1,113 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const mocks = vi.hoisted(() => ({ api: vi.fn<(input: RequestInfo | URL, init?: RequestInit) => Promise<Response>>() }))
vi.mock('@/lib/api', () => ({ api: mocks.api }))
function setStandalone(value: boolean | undefined) {
Object.defineProperty(navigator, 'standalone', { value, configurable: true, writable: true })
}
describe('downloadFromUrl', () => {
beforeEach(() => {
vi.resetModules()
mocks.api.mockReset()
setStandalone(undefined)
})
afterEach(() => {
vi.restoreAllMocks()
vi.useRealTimers()
})
it('links straight at the endpoint so the response streams from the server', async () => {
const appendSpy = vi.spyOn(document.body, 'append')
const clickSpy = vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const removeSpy = vi.spyOn(HTMLElement.prototype, 'remove')
const { downloadFromUrl } = await import('../download')
await downloadFromUrl('/api/v1/books/files/42/download', 'book')
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(anchor.href).toContain('/api/v1/books/files/42/download')
expect(anchor.hasAttribute('download')).toBe(false)
expect(mocks.api).not.toHaveBeenCalled()
expect(clickSpy).toHaveBeenCalledTimes(1)
expect(removeSpy).toHaveBeenCalledTimes(1)
})
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')
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-url')
mocks.api.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers({ 'Content-Disposition': `attachment; filename*=UTF-8''Winter%27s%20Orbit.epub` }),
blob: async () => new Blob(['epub'], { type: 'application/epub+zip' }),
} as unknown as Response)
const { downloadFromUrl } = await import('../download')
await downloadFromUrl('/api/v1/books/files/42/download', 'book')
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(mocks.api).toHaveBeenCalledWith('/api/v1/books/files/42/download')
expect(anchor.href).toBe('blob:test-url')
expect(anchor.download).toBe("Winter's Orbit.epub")
})
it('names the blob from the fallback when the response carries no disposition', async () => {
setStandalone(true)
const appendSpy = vi.spyOn(document.body, 'append')
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-url')
mocks.api.mockResolvedValue({
ok: true,
status: 200,
headers: new Headers(),
blob: async () => new Blob(['zip'], { type: 'application/zip' }),
} as unknown as Response)
const { downloadFromUrl } = await import('../download')
await downloadFromUrl('/api/v1/books/export/download?scope=all', 'bookorbit-export.zip')
const anchor = appendSpy.mock.calls[0]?.[0] as HTMLAnchorElement
expect(anchor.download).toBe('bookorbit-export.zip')
})
it('rejects on a failed blob download so callers can report it', async () => {
setStandalone(true)
mocks.api.mockResolvedValue({ ok: false, status: 403, headers: new Headers() } as unknown as Response)
const { downloadFromUrl } = await import('../download')
await expect(downloadFromUrl('/api/v1/books/files/42/download', 'book')).rejects.toThrow('403')
})
it('outlives the click before revoking the object URL', async () => {
vi.useFakeTimers()
const revokeSpy = vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {})
vi.spyOn(URL, 'createObjectURL').mockReturnValue('blob:test-url')
vi.spyOn(HTMLAnchorElement.prototype, 'click').mockImplementation(() => {})
const { triggerBlobDownload } = await import('../download')
triggerBlobDownload(new Blob(['x']), 'x.txt')
expect(revokeSpy).not.toHaveBeenCalled()
vi.advanceTimersByTime(60_000)
expect(revokeSpy).toHaveBeenCalledWith('blob:test-url')
})
})
describe('fileNameFromContentDisposition', () => {
it('prefers the UTF-8 filename, falls back to ASCII, then to the caller fallback', async () => {
const { fileNameFromContentDisposition } = await import('../download')
expect(fileNameFromContentDisposition(`attachment; filename="plain.epub"`, 'fallback')).toBe('plain.epub')
expect(fileNameFromContentDisposition(`attachment; filename="ascii.epub"; filename*=UTF-8''caf%C3%A9.epub`, 'fallback')).toBe('café.epub')
expect(fileNameFromContentDisposition(null, 'fallback')).toBe('fallback')
expect(fileNameFromContentDisposition('attachment', 'fallback')).toBe('fallback')
})
})
+64
View File
@@ -0,0 +1,64 @@
import { api } from '@/lib/api'
/**
* An iOS home-screen web app runs without any browser chrome, so sending it to a
* `Content-Disposition: attachment` URL replaces the app with WebKit's document preview and
* strands the user there: no back button, and the back-swipe is inert. Neither the `download`
* attribute nor `target="_blank"` is honoured in that context, so the only way to keep the app
* on screen is to fetch the response ourselves and hand the browser a blob, which WebKit shows
* in a sheet the user can dismiss.
*/
function isIosStandaloneWebApp(): boolean {
return (navigator as Navigator & { standalone?: boolean }).standalone === true
}
/** Revoking while the browser is still reading the blob cancels the download, so outlive the click. */
const OBJECT_URL_TTL_MS = 60_000
export function fileNameFromContentDisposition(disposition: string | null, fallback: string): string {
if (!disposition) return fallback
const utf8Match = disposition.match(/filename\*=UTF-8''([^;]+)/i)
if (utf8Match?.[1]) {
try {
return decodeURIComponent(utf8Match[1])
} catch {
// Fall through to regular filename parser.
}
}
const asciiMatch = disposition.match(/filename="([^"]+)"/i)
return asciiMatch?.[1] || fallback
}
export function triggerBlobDownload(blob: Blob, filename: string): void {
const url = URL.createObjectURL(blob)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = filename
anchor.rel = 'noopener'
document.body.append(anchor)
anchor.click()
anchor.remove()
setTimeout(() => URL.revokeObjectURL(url), OBJECT_URL_TTL_MS)
}
/**
* 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.
*/
export async function downloadFromUrl(url: string, fallbackFileName: string): Promise<void> {
if (!isIosStandaloneWebApp()) {
const anchor = document.createElement('a')
anchor.href = url
anchor.rel = 'noopener'
document.body.append(anchor)
anchor.click()
anchor.remove()
return
}
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))
}