import http from '@ohos.net.http'; import fileIo from '@ohos.file.fs'; import common from '@ohos.app.ability.common'; import { zlib } from '@kit.BasicServicesKit'; import { EventHub } from './EventHub'; import { DownloadTaskParams } from './DownloadTaskParams'; import { saveFileToSandbox } from './SaveFile'; import { util } from '@kit.ArkTS'; import NativePatchCore, { ARCHIVE_PATCH_TYPE_FROM_PACKAGE, ARCHIVE_PATCH_TYPE_FROM_PPK, CopyGroupResult, } from './NativePatchCore'; export interface PatchManifestArrays { copyFroms: string[]; copyTos: string[]; deletes: string[]; // __diff.json 中对应 bundle patch 条目的 hbcTransform 元数据(原始 JSON // 字符串);为空时 native 走现状路径 hbcTransformMeta: string; } export function parseManifestToArrays( manifest: Record, normalizeResourceCopies: boolean, ): PatchManifestArrays { const copyFroms: string[] = []; const copyTos: string[] = []; const deletesValue = manifest.deletes; const deletes = Array.isArray(deletesValue) ? deletesValue.map(item => String(item)) : deletesValue && typeof deletesValue === 'object' ? Object.keys(deletesValue) : []; const copies = (manifest.copies || {}) as Record; for (const [to, rawFrom] of Object.entries(copies)) { let from = String(rawFrom || ''); if (normalizeResourceCopies) { from = from.replace('resources/rawfile/', ''); if (!from) { from = to; } } copyFroms.push(from); copyTos.push(to); } const hbcTransform = manifest.hbcTransform as | Record | undefined; const hbcTransformEntry = hbcTransform && typeof hbcTransform === 'object' ? hbcTransform[HARMONY_BUNDLE_PATCH_ENTRY] : undefined; const hbcTransformMeta = hbcTransformEntry && typeof hbcTransformEntry === 'object' ? JSON.stringify(hbcTransformEntry) : ''; return { copyFroms, copyTos, deletes, hbcTransformMeta, }; } function toArrayBufferSlice( payload: Uint8Array, offset: number, length: number, ): ArrayBuffer { return payload.buffer.slice( payload.byteOffset + offset, payload.byteOffset + offset + length, ); } const DIFF_MANIFEST_ENTRY = '__diff.json'; const HARMONY_BUNDLE_PATCH_ENTRY = 'bundle.harmony.js.patch'; const TEMP_ORIGIN_BUNDLE_ENTRY = '.origin.bundle.harmony.js'; const FILE_COPY_BUFFER_SIZE = 64 * 1024; export class DownloadTask { private context: common.Context; private hash = ''; private eventHub: EventHub; constructor(context: common.Context) { this.context = context; this.eventHub = EventHub.getInstance(); } private async removeDirectory(path: string): Promise { try { const res = fileIo.accessSync(path); if (res) { const stat = await fileIo.stat(path); if (stat.isDirectory()) { const files = await fileIo.listFile(path); const entries = files.filter(file => file !== '.' && file !== '..'); const DELETE_CONCURRENCY = 32; for (let i = 0; i < entries.length; i += DELETE_CONCURRENCY) { const batch = entries.slice(i, i + DELETE_CONCURRENCY); await Promise.all( batch.map(file => this.removeDirectory(`${path}/${file}`)), ); } await fileIo.rmdir(path); } else { await fileIo.unlink(path); } } } catch (error) { console.error('Failed to delete directory:', error); throw error; } } private async ensureDirectory(path: string): Promise { if (!path || fileIo.accessSync(path)) { return; } const parentPath = path.substring(0, path.lastIndexOf('/')); if (parentPath && parentPath !== path) { await this.ensureDirectory(parentPath); } if (!fileIo.accessSync(path)) { try { await fileIo.mkdir(path); } catch (error) { if (!fileIo.accessSync(path)) { throw error; } } } } private async ensureParentDirectory(filePath: string): Promise { const parentPath = filePath.substring(0, filePath.lastIndexOf('/')); if (!parentPath) { return; } await this.ensureDirectory(parentPath); } private async recreateDirectory(path: string): Promise { await this.removeDirectory(path); await this.ensureDirectory(path); } private async readFileContent(filePath: string): Promise { const stat = await fileIo.stat(filePath); const reader = await fileIo.open(filePath, fileIo.OpenMode.READ_ONLY); const content = new ArrayBuffer(stat.size); try { await fileIo.read(reader.fd, content); return content; } finally { await fileIo.close(reader); } } private async listEntryNames(directory: string): Promise { const files = await fileIo.listFile(directory); const validFiles = files.filter(file => file !== '.' && file !== '..'); const stats = await Promise.all( validFiles.map(file => fileIo.stat(`${directory}/${file}`)), ); return validFiles.filter((_, index) => !stats[index].isDirectory()); } private async writeFileContent( targetFile: string, content: ArrayBuffer | Uint8Array, ): Promise { const payload = content instanceof Uint8Array ? content : new Uint8Array(content); await this.ensureParentDirectory(targetFile); if (fileIo.accessSync(targetFile)) { await fileIo.unlink(targetFile); } let writer: fileIo.File | null = null; try { writer = await fileIo.open( targetFile, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY, ); const chunkSize = FILE_COPY_BUFFER_SIZE; let bytesWritten = 0; while (bytesWritten < payload.byteLength) { const chunkLength = Math.min( chunkSize, payload.byteLength - bytesWritten, ); const chunk = toArrayBufferSlice(payload, bytesWritten, chunkLength); await fileIo.write(writer.fd, chunk); bytesWritten += chunkLength; } } finally { if (writer) { await fileIo.close(writer); } } } private parseJsonEntry(content: ArrayBuffer): Record { return JSON.parse( new util.TextDecoder().decodeToString(new Uint8Array(content)), ) as Record; } private async readManifestArrays( directory: string, normalizeResourceCopies: boolean, ): Promise { const manifestPath = `${directory}/${DIFF_MANIFEST_ENTRY}`; if (!fileIo.accessSync(manifestPath)) { return { copyFroms: [], copyTos: [], deletes: [], hbcTransformMeta: '', }; } return parseManifestToArrays( this.parseJsonEntry(await this.readFileContent(manifestPath)), normalizeResourceCopies, ); } private async applyBundlePatchFromFileSource( originContent: ArrayBuffer, workingDirectory: string, bundlePatchPath: string, outputFile: string, hbcTransformMeta = '', ): Promise { const originBundlePath = `${workingDirectory}/${TEMP_ORIGIN_BUNDLE_ENTRY}`; try { await this.writeFileContent(originBundlePath, originContent); await NativePatchCore.applyPatchFromFileSource({ copyFroms: [], copyTos: [], deletes: [], sourceRoot: workingDirectory, targetRoot: workingDirectory, originBundlePath, bundlePatchPath, bundleOutputPath: outputFile, enableMerge: false, bundleHbcTransformMeta: hbcTransformMeta, }); } catch (error: any) { error.message = `Failed to process bundle patch: ${error.message}`; throw error; } finally { if (fileIo.accessSync(originBundlePath)) { await fileIo.unlink(originBundlePath); } } } private async copySandboxFile( sourceFile: string, targetFile: string, ): Promise { let reader: fileIo.File | null = null; let writer: fileIo.File | null = null; const buffer = new ArrayBuffer(FILE_COPY_BUFFER_SIZE); let offset = 0; try { reader = await fileIo.open(sourceFile, fileIo.OpenMode.READ_ONLY); await this.ensureParentDirectory(targetFile); if (fileIo.accessSync(targetFile)) { await fileIo.unlink(targetFile); } writer = await fileIo.open( targetFile, fileIo.OpenMode.CREATE | fileIo.OpenMode.WRITE_ONLY, ); while (true) { const readLength = await fileIo.read(reader.fd, buffer, { offset, length: FILE_COPY_BUFFER_SIZE, }); if (readLength <= 0) { break; } await fileIo.write(writer.fd, buffer.slice(0, readLength)); offset += readLength; if (readLength < FILE_COPY_BUFFER_SIZE) { break; } } } finally { if (reader) { await fileIo.close(reader); } if (writer) { await fileIo.close(writer); } } } private async downloadFile(params: DownloadTaskParams): Promise { const httpRequest = http.createHttp(); this.hash = params.hash; let writer: fileIo.File | null = null; let contentLength = 0; let received = 0; let writeError: Error | null = null; let writeQueue = Promise.resolve(); let lastReportedPercentage = -1; let lastReportedBytes = 0; // Emit at most one progress event per whole-percent change (or per 256KB // when the total is unknown), and only from the dataReceive handler, so the // two HarmonyOS callbacks don't each fire an event per chunk. const reportProgress = () => { if (contentLength > 0) { const percentage = Math.round((received * 100) / contentLength); if (percentage <= lastReportedPercentage) { return; } lastReportedPercentage = percentage; } else if (received - lastReportedBytes < 256 * 1024) { return; } else { lastReportedBytes = received; } this.onProgressUpdate(received, contentLength); }; const closeWriter = async () => { if (writer) { await fileIo.close(writer); writer = null; } }; // Watchdog: reject the download if no data is received for a while, so a // stalled transfer after requestInStream resolves cannot hang the download // Promise (and the JS caller) forever. const INACTIVITY_TIMEOUT_MS = 60000; let watchdogTimer: number | null = null; let inactivityReject: ((error: Error) => void) | null = null; const clearWatchdog = () => { if (watchdogTimer !== null) { clearTimeout(watchdogTimer); watchdogTimer = null; } }; const refreshWatchdog = () => { clearWatchdog(); watchdogTimer = setTimeout(() => { if (inactivityReject) { inactivityReject( Error( `Download stalled: no data received for ${INACTIVITY_TIMEOUT_MS}ms`, ), ); } }, INACTIVITY_TIMEOUT_MS); }; const inactivityPromise = new Promise((_, reject) => { inactivityReject = reject; }); const dataEndPromise = new Promise((resolve, reject) => { httpRequest.on('dataEnd', () => { clearWatchdog(); writeQueue .then(async () => { if (writeError) { throw writeError; } await closeWriter(); resolve(); }) .catch(async error => { // reject 必须先于 closeWriter:此时 watchdog 已清除,若 close // 也失败(同一磁盘故障的常见连锁)而 reject 未执行,下载 Promise // 将永久挂起——正是 HM-2 要消灭的症状。 reject(error); try { await closeWriter(); } catch (closeErr: any) { console.error('closeWriter failed after write error:', closeErr); } }); }); }); try { let exists = fileIo.accessSync(params.targetFile); if (exists) { await fileIo.unlink(params.targetFile); } else { await this.ensureParentDirectory(params.targetFile); } writer = await fileIo.open( params.targetFile, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE, ); httpRequest.on('headersReceive', (header: Object) => { if (!header) { return; } const headers = header as Record; const lengthKey = Object.keys(headers).find( key => key.toLowerCase() === 'content-length', ); if (!lengthKey) { return; } const length = parseInt(headers[lengthKey], 10); if (!Number.isNaN(length)) { contentLength = length; } }); httpRequest.on('dataReceive', (data: ArrayBuffer) => { refreshWatchdog(); if (writeError) { return; } received += data.byteLength; writeQueue = writeQueue.then(async () => { if (!writer || writeError) { return; } try { await fileIo.write(writer.fd, data); } catch (error) { writeError = error as Error; } }); reportProgress(); }); httpRequest.on( 'dataReceiveProgress', (data: http.DataReceiveProgressInfo) => { // Only refine the known total here; progress events are emitted from // the dataReceive handler to avoid double-firing. if (data.totalSize > 0) { contentLength = data.totalSize; } }, ); const responseCode = await httpRequest.requestInStream(params.url, { method: http.RequestMethod.GET, readTimeout: 60000, connectTimeout: 60000, header: { 'Content-Type': 'application/octet-stream', }, }); if (responseCode > 299) { throw Error(`Server error: ${responseCode}`); } // watchdog 到这里才首次启动:requestInStream 阶段已有 connect/readTimeout // 覆盖;若提早启动,连接超过 60s 时 inactivityPromise 会在 race 尚无订阅者 // 时 reject(unhandled rejection),且 promise 一经 reject 无法复活—— // 即使随后数据正常流入,race 也必然以 "Download stalled" 失败。 refreshWatchdog(); await Promise.race([dataEndPromise, inactivityPromise]); const stats = await fileIo.stat(params.targetFile); const fileSize = stats.size; if (contentLength > 0 && fileSize !== contentLength) { throw Error( `Download incomplete: expected ${contentLength} bytes but got ${stats.size} bytes`, ); } } catch (error) { console.error('Download failed:', error); throw error; } finally { clearWatchdog(); try { await closeWriter(); } catch (closeError) { console.error('Failed to close file:', closeError); } httpRequest.off('headersReceive'); httpRequest.off('dataReceive'); httpRequest.off('dataReceiveProgress'); httpRequest.off('dataEnd'); httpRequest.destroy(); } } private onProgressUpdate(received: number, total: number): void { this.eventHub.emit('RCTPushyDownloadProgress', { received, total, hash: this.hash, }); } private async doFullPatch(params: DownloadTaskParams): Promise { await this.downloadFile(params); await this.recreateDirectory(params.unzipDirectory); await zlib.decompressFile(params.targetFile, params.unzipDirectory); try { if (fileIo.accessSync(params.targetFile)) { await fileIo.unlink(params.targetFile); } } catch (e) { console.error('Failed to delete temporary zip file after decompression:', e); } } private async doPatchFromApp(params: DownloadTaskParams): Promise { await this.downloadFile(params); await this.recreateDirectory(params.unzipDirectory); await zlib.decompressFile(params.targetFile, params.unzipDirectory); const [entryNames, manifestArrays] = await Promise.all([ this.listEntryNames(params.unzipDirectory), this.readManifestArrays(params.unzipDirectory, true), ]); NativePatchCore.buildArchivePatchPlan( ARCHIVE_PATCH_TYPE_FROM_PACKAGE, entryNames, manifestArrays.copyFroms, manifestArrays.copyTos, manifestArrays.deletes, HARMONY_BUNDLE_PATCH_ENTRY, ); const bundlePatchPath = `${params.unzipDirectory}/${HARMONY_BUNDLE_PATCH_ENTRY}`; if (!fileIo.accessSync(bundlePatchPath)) { throw Error('bundle patch not found'); } const resourceManager = this.context.resourceManager; const originContent = await resourceManager.getRawFileContent( 'bundle.harmony.js', ); await this.applyBundlePatchFromFileSource( originContent, params.unzipDirectory, bundlePatchPath, `${params.unzipDirectory}/bundle.harmony.js`, manifestArrays.hbcTransformMeta, ); await this.copyFromResource( NativePatchCore.buildCopyGroups( manifestArrays.copyFroms, manifestArrays.copyTos, ), params.unzipDirectory, ); try { if (fileIo.accessSync(params.targetFile)) { await fileIo.unlink(params.targetFile); } } catch (e) { console.error('Failed to delete temporary zip file after patching:', e); } } private async doPatchFromPpk(params: DownloadTaskParams): Promise { await this.downloadFile(params); await this.recreateDirectory(params.unzipDirectory); await zlib.decompressFile(params.targetFile, params.unzipDirectory); const [entryNames, manifestArrays] = await Promise.all([ this.listEntryNames(params.unzipDirectory), this.readManifestArrays(params.unzipDirectory, false), ]); const plan = NativePatchCore.buildArchivePatchPlan( ARCHIVE_PATCH_TYPE_FROM_PPK, entryNames, manifestArrays.copyFroms, manifestArrays.copyTos, manifestArrays.deletes, HARMONY_BUNDLE_PATCH_ENTRY, ); await NativePatchCore.applyPatchFromFileSource({ copyFroms: manifestArrays.copyFroms, copyTos: manifestArrays.copyTos, deletes: manifestArrays.deletes, sourceRoot: params.originDirectory, targetRoot: params.unzipDirectory, originBundlePath: `${params.originDirectory}/bundle.harmony.js`, bundlePatchPath: `${params.unzipDirectory}/${HARMONY_BUNDLE_PATCH_ENTRY}`, bundleOutputPath: `${params.unzipDirectory}/bundle.harmony.js`, mergeSourceSubdir: plan.mergeSourceSubdir, enableMerge: plan.enableMerge, bundleHbcTransformMeta: manifestArrays.hbcTransformMeta, }); console.info('Patch from PPK completed'); try { if (fileIo.accessSync(params.targetFile)) { await fileIo.unlink(params.targetFile); } } catch (e) { console.error('Failed to delete temporary patch file after patching:', e); } } private async copyFromResource( copyGroups: CopyGroupResult[], targetRoot: string, ): Promise { let currentFrom = ''; try { const resourceManager = this.context.resourceManager; for (const group of copyGroups) { currentFrom = group.from; const targets = group.toPaths.map(path => `${targetRoot}/${path}`); if (targets.length === 0) { continue; } if (currentFrom.startsWith('resources/base/media/')) { // Strip only the final extension: 'icon.round.png' -> 'icon.round', // not 'icon' (getMediaByName expects the full resource name). const mediaName = currentFrom .replace('resources/base/media/', '') .replace(/\.[^.]+$/, ''); const mediaBuffer = await resourceManager.getMediaByName(mediaName); const parentDirs = [ ...new Set( targets.map(t => t.substring(0, t.lastIndexOf('/'))).filter(Boolean), ), ]; await Promise.all( parentDirs.map(dir => this.ensureDirectory(dir)), ); await Promise.all( targets.map(target => this.writeFileContent(target, mediaBuffer)), ); continue; } const fromContent = await resourceManager.getRawFd(currentFrom); try { const [firstTarget, ...restTargets] = targets; const parentDirs = [ ...new Set( targets.map(t => t.substring(0, t.lastIndexOf('/'))).filter(Boolean), ), ]; await Promise.all( parentDirs.map(dir => this.ensureDirectory(dir)) ); if (fileIo.accessSync(firstTarget)) { await fileIo.unlink(firstTarget); } saveFileToSandbox(fromContent, firstTarget); await Promise.all( restTargets.map(target => this.copySandboxFile(firstTarget, target)), ); } finally { try { await resourceManager.closeRawFd(currentFrom); } catch (closeError) { console.error(`Failed to close raw fd for ${currentFrom}:`, closeError); } } } } catch (error: any) { error.message = 'Copy from resource failed:' + currentFrom + ',' + error.code + ',' + error.message; console.error(error); throw error; } } private async doCleanUp(params: DownloadTaskParams): Promise { try { await NativePatchCore.cleanupOldEntries( params.unzipDirectory, params.hash || '', params.originHash || '', 3, ); } catch (error: any) { error.message = 'Cleanup failed:' + error.message; console.error(error); throw error; } } public async execute(params: DownloadTaskParams): Promise { try { switch (params.type) { case DownloadTaskParams.TASK_TYPE_PATCH_FULL: await this.doFullPatch(params); break; case DownloadTaskParams.TASK_TYPE_PATCH_FROM_APP: await this.doPatchFromApp(params); break; case DownloadTaskParams.TASK_TYPE_PATCH_FROM_PPK: await this.doPatchFromPpk(params); break; case DownloadTaskParams.TASK_TYPE_CLEANUP: await this.doCleanUp(params); break; case DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD: await this.downloadFile(params); break; default: throw Error(`Unknown task type: ${params.type}`); } } catch (error: any) { console.error('Task execution failed:', error.message); if (params.type !== DownloadTaskParams.TASK_TYPE_CLEANUP) { try { if (params.type === DownloadTaskParams.TASK_TYPE_PLAIN_DOWNLOAD) { await fileIo.unlink(params.targetFile); } else { await this.removeDirectory(params.unzipDirectory); } } catch (cleanupError: any) { console.error('Cleanup after error failed:', cleanupError.message); } } throw error; } } }