import { Platform } from 'react-native' import type { XWebViewDownloadDecision, XWebViewDownloadProgress, XWebViewDownloadRequest, XWebViewDownloadResult, } from '@xuqm/rn-common' // Lazy import: react-native-blob-util calls getConstants() at module evaluation time, // which crashes in RN 0.85 Bridgeless mode. Defer the require until actual download use. // eslint-disable-next-line @typescript-eslint/no-require-imports const getRNFetchBlob = () => (require('react-native-blob-util') as { default: typeof import('react-native-blob-util').default }).default 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 { 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 { const full = `${dir}/${filename}` if (conflict === 'overwrite') return full const exists = await getRNFetchBlob().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 getRNFetchBlob().fs.exists(candidate)) return candidate } export async function saveBase64File( base64: string, filename: string, savePath: string | undefined, conflict: 'rename' | 'overwrite', ): Promise { const { dirs } = getRNFetchBlob().fs const dir = savePath ?? (Platform.OS === 'android' ? (dirs.DownloadDir ?? dirs.DocumentDir) : dirs.DocumentDir) const filePath = await resolveFilePath(dir, filename, conflict) await getRNFetchBlob().fs.writeFile(filePath, base64, 'base64') return filePath } export type DownloadHandle = { cancel: () => void } /** * 统一的下载请求处理函数。 * * 封装 fetchDownloadInfo → onDownloadDecide → startDownload 的完整流程, * 供 XWebViewScreen / XWebViewView 及外部调用方使用。 * * @returns DownloadHandle(可取消),若用户拒绝或无需下载则返回 null */ export async function handleDownloadRequest( url: string, hintFilename: string | undefined, options: { autoDownload?: boolean downloadConflict?: 'rename' | 'overwrite' savePath?: string onDownloadDecide?: ( request: XWebViewDownloadRequest, ) => XWebViewDownloadDecision | Promise onDownloadStart?: (request: XWebViewDownloadRequest) => void onDownloadProgress?: (progress: XWebViewDownloadProgress) => void onDownloadComplete?: (result: XWebViewDownloadResult) => void onDownloadError?: (url: string, error: string) => void } = {}, ): Promise { const { autoDownload = true, downloadConflict = 'rename', savePath, onDownloadDecide, onDownloadStart, onDownloadProgress, onDownloadComplete, onDownloadError, } = options const info = await fetchDownloadInfo(url, hintFilename) let finalFilename = info.suggestedFilename let finalSavePath = savePath // 非自动下载模式:需要宿主决定是否允许 if (!autoDownload) { if (!onDownloadDecide) return null const decision = await Promise.resolve(onDownloadDecide(info)) if (!decision.allowed) return null if (decision.filename) finalFilename = decision.filename if (decision.savePath) finalSavePath = decision.savePath } onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) return startDownload( url, finalFilename, finalSavePath, downloadConflict, p => onDownloadProgress?.(p), r => onDownloadComplete?.(r), err => onDownloadError?.(url, err), ) } 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 } = getRNFetchBlob().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 = getRNFetchBlob().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() } }