|
import common from '@ohos.app.ability.common';
import webview from '@ohos.web.webview';
import request from '@ohos.request';
import fs from '@ohos.file.fs';
import picker from '@ohos.file.picker';
import { OnSdkEvent } from '../model/SignCallback';
import { DownloadStartEvent, DownloadFailedEvent, DownloadCompletedEvent } from '../model/SdkEvent';
/**
* 下载处理器。
*
* 通过 `@ohos.request.downloadFile` 把 http(s) 下载链接落到 app 沙箱缓存目录,
* 再调用 `DocumentViewPicker.save` 让用户选择目标位置完成另存。
*
* 不支持 `blob:` 协议,触发时 emit `DownloadFailed`,由集成方通过 JSBridge 实现。
*/
export class DownloadHandler {
/** SDK 缓存子目录名。 */
private static readonly CACHE_SUBDIR: string = 'qian-download';
/**
* 触发下载。
*
* @param controller WebviewController(预留参数,本实现未使用)
* @param context UIAbility/页面 context
* @param url 下载 URL
* @param mimeType MIME 类型
* @param contentDisposition Content-Disposition 头
* @param onEvent 可选的事件回调
*/
public static async start(
controller: webview.WebviewController,
context: common.UIAbilityContext,
url: string,
mimeType: string,
contentDisposition: string,
onEvent?: OnSdkEvent
): Promise<void> {
if (url === null || url === undefined || url.length === 0) {
return;
}
if (url.indexOf('blob:') === 0) {
DownloadHandler.emitFailed(onEvent, url,
'blob 下载暂不支持,请通过 JSBridge 自行实现');
return;
}
// 仅处理 http(s)
const lower: string = url.toLowerCase();
if (lower.indexOf('http://') !== 0 && lower.indexOf('https://') !== 0) {
DownloadHandler.emitFailed(onEvent, url, '不支持的下载协议');
return;
}
const safeMime: string = mimeType === null || mimeType === undefined ? '' : mimeType;
const safeCd: string = contentDisposition === null || contentDisposition === undefined ? '' : contentDisposition;
if (onEvent !== undefined) {
onEvent(new DownloadStartEvent(url, safeMime, safeCd));
}
const filename: string = DownloadHandler.resolveFilename(url, safeCd);
const savePath: string = DownloadHandler.ensureSavePath(context, filename);
// request.downloadFile 不支持 overwrite,目标已存在会 reject(13400002),先清理
try {
if (fs.accessSync(savePath)) {
fs.unlinkSync(savePath);
}
} catch (_e) {
}
try {
const cfg: request.DownloadConfig = {
url: url,
filePath: savePath,
enableMetered: true,
enableRoaming: true,
description: filename,
title: filename
};
const task: request.DownloadTask = await request.downloadFile(context, cfg);
task.on('complete', (): void => {
DownloadHandler.saveToUserPicked(savePath, filename, url, onEvent);
});
task.on('fail', (err: number): void => {
DownloadHandler.emitFailed(onEvent, url, '下载失败 errCode=' + err.toString());
});
} catch (e) {
const reason: string = (e instanceof Error)
? (e.name + ': ' + e.message)
: String(e);
DownloadHandler.emitFailed(onEvent, url, '下载启动失败: ' + reason);
}
}
/**
* 计算落盘文件名: Content-Disposition > URL 末段 > 时间戳兜底。
*/
private static resolveFilename(url: string, contentDisposition: string): string {
const fromCd: string = DownloadHandler.parseFilenameFromCd(contentDisposition);
if (fromCd.length > 0) {
return DownloadHandler.sanitize(fromCd);
}
const fromUrl: string = DownloadHandler.parseFilenameFromUrl(url);
if (fromUrl.length > 0) {
return DownloadHandler.sanitize(fromUrl);
}
return 'download_' + Date.now().toString();
}
private static parseFilenameFromCd(cd: string): string {
if (cd === null || cd === undefined || cd.length === 0) {
return '';
}
// RFC 5987: filename*=UTF-8''xxxx
const starMatch = cd.match(/filename\*\s*=\s*[^']*''([^;]+)/i);
if (starMatch !== null && starMatch.length > 1) {
try {
return decodeURIComponent(starMatch[1].trim());
} catch (_e) {
}
}
// filename="xxxx" 或 filename=xxxx
const plain = cd.match(/filename\s*=\s*"?([^";]+)"?/i);
if (plain !== null && plain.length > 1) {
return plain[1].trim();
}
return '';
}
private static parseFilenameFromUrl(url: string): string {
try {
const noQuery: string = url.split('?')[0].split('#')[0];
const idx: number = noQuery.lastIndexOf('/');
if (idx < 0 || idx === noQuery.length - 1) {
return '';
}
return decodeURIComponent(noQuery.substring(idx + 1));
} catch (_e) {
return '';
}
}
private static sanitize(name: string): string {
// 过滤路径分隔符/非法字符与上跳段,防路径穿越。
let n: string = name.replace(/[\\/:*?"<>|]/g, '_').replace(/\.\./g, '_').trim();
// 兜底:清洗后为空 / 纯 "." / 纯 ".." → 时间戳默认名,避免产生指向目录本身的无效路径。
if (n.length === 0 || n === '.' || n === '..') {
n = 'download_' + Date.now().toString();
}
// 限长,防超长名写入失败。
if (n.length > 200) {
n = n.substring(n.length - 200);
}
return n;
}
private static ensureSavePath(context: common.UIAbilityContext, filename: string): string {
const dir: string = context.cacheDir + '/' + DownloadHandler.CACHE_SUBDIR;
try {
if (!fs.accessSync(dir)) {
fs.mkdirSync(dir, true);
}
} catch (_e) {
}
// 落盘二次防御:即便 sanitize 被绕过,文件名仍不得含路径分隔符/上跳段,
// 否则用时间戳兜底名,确保写入路径始终落在缓存子目录内(不逃逸)。
let safe: string = filename;
if (safe.length === 0 || safe.indexOf('/') >= 0 || safe.indexOf('\\') >= 0
|| safe === '.' || safe === '..') {
safe = 'download_' + Date.now().toString();
}
return dir + '/' + safe;
}
private static emitFailed(onEvent: OnSdkEvent | undefined, url: string, reason: string): void {
if (onEvent !== undefined) {
onEvent(new DownloadFailedEvent(url, reason));
}
}
/**
* 弹 `DocumentViewPicker.save` 让用户选择目标位置,把缓存文件拷贝过去。
*
* - 用户选定: 拷贝到目标 uri,清理缓存,emit `DownloadCompleted(url, 目标 uri)`
* - 用户取消或 picker 异常: emit `DownloadCompleted(url, 缓存路径)`,缓存保留
* - 拷贝失败: emit `DownloadFailed`
*/
private static async saveToUserPicked(
cachePath: string,
filename: string,
url: string,
onEvent: OnSdkEvent | undefined
): Promise<void> {
let targetUri: string = '';
try {
const documentPicker = new picker.DocumentViewPicker();
const opts: picker.DocumentSaveOptions = new picker.DocumentSaveOptions();
opts.newFileNames = [filename];
const uris: string[] = await documentPicker.save(opts);
if (uris !== null && uris !== undefined && uris.length > 0) {
targetUri = uris[0];
}
} catch (_e) {
}
if (targetUri.length === 0) {
if (onEvent !== undefined) {
onEvent(new DownloadCompletedEvent(url, cachePath));
}
return;
}
let destFd: number = -1;
let srcFd: number = -1;
try {
const destFile = fs.openSync(targetUri, fs.OpenMode.WRITE_ONLY | fs.OpenMode.TRUNC);
destFd = destFile.fd;
const srcFile = fs.openSync(cachePath, fs.OpenMode.READ_ONLY);
srcFd = srcFile.fd;
fs.copyFileSync(srcFd, destFd);
fs.closeSync(srcFile);
srcFd = -1;
fs.closeSync(destFile);
destFd = -1;
try {
fs.unlinkSync(cachePath);
} catch (_e) {
}
if (onEvent !== undefined) {
onEvent(new DownloadCompletedEvent(url, targetUri));
}
} catch (e) {
if (srcFd >= 0) {
try { fs.closeSync(srcFd); } catch (_e) {}
}
if (destFd >= 0) {
try { fs.closeSync(destFd); } catch (_e) {}
}
const reason: string = (e instanceof Error)
? (e.name + ': ' + e.message)
: String(e);
DownloadHandler.emitFailed(onEvent, url, '保存到所选位置失败: ' + reason);
}
}
private constructor() {}
}
|