静态 import 会在模块评估时触发 fs.js:14 调用 ReactNativeBlobUtil.getConstants(), 在 Bridgeless 模式下 TurboModule 未注册时返回 null 导致崩溃。 改为在函数体内 require() 延迟至实际下载时才初始化。 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
228 行
6.7 KiB
TypeScript
228 行
6.7 KiB
TypeScript
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<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 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<string> {
|
||
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<XWebViewDownloadDecision>
|
||
onDownloadStart?: (request: XWebViewDownloadRequest) => void
|
||
onDownloadProgress?: (progress: XWebViewDownloadProgress) => void
|
||
onDownloadComplete?: (result: XWebViewDownloadResult) => void
|
||
onDownloadError?: (url: string, error: string) => void
|
||
} = {},
|
||
): Promise<DownloadHandle | null> {
|
||
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() }
|
||
}
|