mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
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:
@@ -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 {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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))
|
||||
}
|
||||
Reference in New Issue
Block a user