162 行
4.4 KiB
TypeScript
162 行
4.4 KiB
TypeScript
import { Platform } from 'react-native'
|
|
import RNFetchBlob from 'react-native-blob-util'
|
|
|
|
import type {
|
|
XWebViewDownloadProgress,
|
|
XWebViewDownloadRequest,
|
|
XWebViewDownloadResult,
|
|
} from '@xuqm/rn-common'
|
|
|
|
function parseContentDispositionFilename(header: string): string | null {
|
|
const rfc5987 = header.match(/filename\*=(?:[^']*'[^']*')?([^;\s]+)/i)
|
|
if (rfc5987?.[1]) {
|
|
try {
|
|
return decodeURIComponent(rfc5987[1].replace(/['"]/g, ''))
|
|
} catch {}
|
|
}
|
|
|
|
const quoted = header.match(/filename="([^"]+)"/i)
|
|
if (quoted?.[1]) return quoted[1].trim()
|
|
|
|
const plain = header.match(/filename=([^;\s"]+)/i)
|
|
if (plain?.[1]) return plain[1].trim()
|
|
|
|
return null
|
|
}
|
|
|
|
function filenameFromUrl(url: string): string {
|
|
try {
|
|
const { pathname } = new URL(url)
|
|
const parts = pathname.split('/').filter(Boolean)
|
|
return parts[parts.length - 1] ? decodeURIComponent(parts[parts.length - 1]) : 'download'
|
|
} catch {
|
|
const path = url.split('?')[0]
|
|
const parts = path.split('/').filter(Boolean)
|
|
return parts[parts.length - 1] || 'download'
|
|
}
|
|
}
|
|
|
|
export async function fetchDownloadInfo(
|
|
url: string,
|
|
hintFilename?: string,
|
|
): Promise<XWebViewDownloadRequest> {
|
|
try {
|
|
const res = await fetch(url, { method: 'HEAD' })
|
|
const disposition = res.headers.get('content-disposition') ?? ''
|
|
const mimeType = res.headers.get('content-type')?.split(';')[0].trim() || undefined
|
|
const length = res.headers.get('content-length')
|
|
const fileSize = length ? parseInt(length, 10) : undefined
|
|
|
|
const suggestedFilename =
|
|
(hintFilename && hintFilename.trim()) ||
|
|
parseContentDispositionFilename(disposition) ||
|
|
filenameFromUrl(url)
|
|
|
|
return { url, suggestedFilename, mimeType, fileSize }
|
|
} catch {
|
|
return {
|
|
url,
|
|
suggestedFilename: (hintFilename && hintFilename.trim()) || filenameFromUrl(url),
|
|
}
|
|
}
|
|
}
|
|
|
|
async function resolveFilePath(
|
|
dir: string,
|
|
filename: string,
|
|
conflict: 'rename' | 'overwrite',
|
|
): Promise<string> {
|
|
const full = `${dir}/${filename}`
|
|
if (conflict === 'overwrite') return full
|
|
|
|
const exists = await RNFetchBlob.fs.exists(full)
|
|
if (!exists) return full
|
|
|
|
const dot = filename.lastIndexOf('.')
|
|
const base = dot >= 0 ? filename.slice(0, dot) : filename
|
|
const ext = dot >= 0 ? filename.slice(dot) : ''
|
|
|
|
let n = 1
|
|
let candidate: string
|
|
do {
|
|
candidate = `${dir}/${base}(${n})${ext}`
|
|
n++
|
|
} while (await RNFetchBlob.fs.exists(candidate))
|
|
|
|
return candidate
|
|
}
|
|
|
|
export async function saveBase64File(
|
|
base64: string,
|
|
filename: string,
|
|
savePath: string | undefined,
|
|
conflict: 'rename' | 'overwrite',
|
|
): Promise<string> {
|
|
const { dirs } = RNFetchBlob.fs
|
|
const dir =
|
|
savePath ??
|
|
(Platform.OS === 'android'
|
|
? (dirs.DownloadDir ?? dirs.DocumentDir)
|
|
: dirs.DocumentDir)
|
|
const filePath = await resolveFilePath(dir, filename, conflict)
|
|
await RNFetchBlob.fs.writeFile(filePath, base64, 'base64')
|
|
return filePath
|
|
}
|
|
|
|
export type DownloadHandle = { cancel: () => void }
|
|
|
|
export function startDownload(
|
|
url: string,
|
|
filename: string,
|
|
savePath: string | undefined,
|
|
conflict: 'rename' | 'overwrite',
|
|
onProgress: (p: XWebViewDownloadProgress) => void,
|
|
onComplete: (r: XWebViewDownloadResult) => void,
|
|
onError: (error: string) => void,
|
|
): DownloadHandle {
|
|
const { dirs } = RNFetchBlob.fs
|
|
const dir =
|
|
savePath ??
|
|
(Platform.OS === 'android'
|
|
? (dirs.DownloadDir ?? dirs.DocumentDir)
|
|
: dirs.DocumentDir)
|
|
|
|
let cancelled = false
|
|
let cancelFn = () => {
|
|
cancelled = true
|
|
}
|
|
|
|
resolveFilePath(dir, filename, conflict)
|
|
.then(filePath => {
|
|
if (cancelled) return
|
|
|
|
const task = RNFetchBlob.config({ path: filePath }).fetch('GET', url)
|
|
cancelFn = () => task.cancel()
|
|
|
|
task.progress({ interval: 300 }, (received: number, total: number) => {
|
|
const recv = Number(received)
|
|
const tot = Number(total)
|
|
onProgress({
|
|
url,
|
|
filename,
|
|
received: recv,
|
|
total: tot,
|
|
percentage: tot > 0 ? Math.round((recv / tot) * 100) : -1,
|
|
})
|
|
})
|
|
|
|
task
|
|
.then((res: { path: () => string }) => {
|
|
onComplete({ url, filename, filePath: res.path(), fileSize: 0 })
|
|
})
|
|
.catch((err: Error) => {
|
|
if (!String(err?.message ?? '').toLowerCase().includes('cancel')) {
|
|
onError(err?.message ?? String(err))
|
|
}
|
|
})
|
|
})
|
|
.catch(err => onError(String(err)))
|
|
|
|
return { cancel: () => cancelFn() }
|
|
}
|