'use strict'; const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const BOOTSTRAP_MARKER = '/* hot-update-builder:bootstrap */'; const NATIVE_PLATFORMS = new Set([ 'android', 'ios', 'mac', 'windows', 'linux', 'native', 'openharmony', 'harmonyos-next', ]); const BOOTSTRAP_SCRIPT = `${BOOTSTRAP_MARKER} (function () { if (typeof window.jsb === 'object') { var hotUpdateSearchPaths = localStorage.getItem('HotUpdateSearchPaths'); if (hotUpdateSearchPaths) { var paths = JSON.parse(hotUpdateSearchPaths); jsb.fileUtils.setSearchPaths(paths); var fileList = []; var storagePath = paths[0] || ''; var tempPath = storagePath + '_temp/'; var baseOffset = tempPath.length; if (jsb.fileUtils.isDirectoryExist(tempPath) && !jsb.fileUtils.isFileExist(tempPath + 'project.manifest.temp')) { jsb.fileUtils.listFilesRecursively(tempPath, fileList); fileList.forEach(srcPath => { var relativePath = srcPath.substr(baseOffset); var dstPath = storagePath + relativePath; if (srcPath[srcPath.length] == '/') { jsb.fileUtils.createDirectory(dstPath) } else { if (jsb.fileUtils.isFileExist(dstPath)) { jsb.fileUtils.removeFile(dstPath) } jsb.fileUtils.renameFile(srcPath, dstPath); } }) jsb.fileUtils.removeDirectory(tempPath); } } } })(); `; async function packageHotUpdate({ buildOptions, buildResult, remoteUrl, version, projectPath, }) { if (!buildResult?.dest) { throw new Error('[热更新打包] 构建结果缺少 dest 路径。'); } const platform = String(buildOptions?.platform || '').toLowerCase(); if (!NATIVE_PLATFORMS.has(platform)) { throw new Error(`[热更新打包] 平台 ${platform || ''} 不是支持热更新的原生平台。`); } if (buildOptions?.md5Cache) { throw new Error('[热更新打包] 请关闭构建发布面板中的 MD5 Cache;AssetsManager Manifest 已负责文件版本校验。'); } const normalizedUrl = normalizeRemoteUrl(remoteUrl); const normalizedVersion = normalizeVersion(version); const buildRoot = path.resolve(buildResult.dest); const dataRoot = await findNativeDataRoot(buildRoot); const mainScriptPath = path.join(dataRoot, 'main.js'); await injectBootstrap(mainScriptPath); const projectInfo = await readProjectInfo(projectPath); const assetFiles = await collectPayloadFiles(dataRoot); const assets = {}; let totalBytes = 0; for (const file of assetFiles) { const stat = await fs.promises.stat(file.absolutePath); const descriptor = { size: stat.size, md5: await calculateMd5(file.absolutePath), }; if (path.extname(file.absolutePath).toLowerCase() === '.zip') { descriptor.compressed = true; } assets[encodeURI(file.relativePath)] = descriptor; totalBytes += stat.size; } const manifest = { packageUrl: normalizedUrl, remoteManifestUrl: new URL('project.manifest', normalizedUrl).toString(), remoteVersionUrl: new URL('version.manifest', normalizedUrl).toString(), version: normalizedVersion, engineVersion: projectInfo.creator?.version || '', assets, searchPaths: [], }; const versionManifest = { packageUrl: manifest.packageUrl, remoteManifestUrl: manifest.remoteManifestUrl, remoteVersionUrl: manifest.remoteVersionUrl, version: manifest.version, engineVersion: manifest.engineVersion, }; // Manifest 放入 data 根目录,Make 阶段会随原生包一起发布。 const localManifestPath = path.join(dataRoot, 'project.manifest'); await writeJsonAtomic(localManifestPath, manifest); await writeJsonAtomic(path.join(dataRoot, 'version.manifest'), versionManifest); // 每个版本独立输出,目录可以直接上传到面板中配置的 URL。 const remotePackagePath = path.join(buildRoot, 'hot-update', normalizedVersion); await createRemotePackage({ buildRoot, dataRoot, remotePackagePath, manifest, versionManifest, assetFiles, }); return { version: normalizedVersion, localManifestPath, remotePackagePath, fileCount: assetFiles.length, totalBytes, }; } function normalizeRemoteUrl(value) { const input = String(value || '').trim(); let parsed; try { parsed = new URL(input); } catch { throw new Error(`[热更新打包] 热更新 URL 无效:${input || ''}`); } if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { throw new Error('[热更新打包] 热更新 URL 仅支持 http:// 或 https://。'); } parsed.hash = ''; parsed.search = ''; if (!parsed.pathname.endsWith('/')) { parsed.pathname += '/'; } return parsed.toString(); } function normalizeVersion(value) { const normalized = String(value || '').trim(); if (!/^\d+(?:\.\d+){1,3}$/.test(normalized)) { throw new Error(`[热更新打包] 版本号无效:${normalized || ''}`); } return normalized; } async function findNativeDataRoot(buildRoot) { const candidates = [path.join(buildRoot, 'data'), path.join(buildRoot, 'assets')]; for (const candidate of candidates) { if (await isDirectory(path.join(candidate, 'src')) && await isDirectory(path.join(candidate, 'assets'))) { return candidate; } } throw new Error(`[热更新打包] 在 ${buildRoot} 下找不到原生构建资源目录(data 或 assets)。`); } async function injectBootstrap(mainScriptPath) { let source; try { source = await fs.promises.readFile(mainScriptPath, 'utf8'); } catch (error) { throw new Error(`[热更新打包] 无法读取 ${mainScriptPath}:${error.message}`); } if (source.includes(BOOTSTRAP_MARKER)) { return; } await writeTextAtomic(mainScriptPath, BOOTSTRAP_SCRIPT + source); } async function collectPayloadFiles(dataRoot) { const relativeFiles = []; for (const directory of ['src', 'assets', 'jsb-adapter']) { const absoluteDirectory = path.join(dataRoot, directory); if (await isDirectory(absoluteDirectory)) { await walkFiles(absoluteDirectory, dataRoot, relativeFiles); } } // Creator 3.8 将 application.js 放在 data 根目录,旧版生成器会漏掉它。 const applicationScript = path.join(dataRoot, 'application.js'); if (await isFile(applicationScript)) { relativeFiles.push({ absolutePath: applicationScript, relativePath: 'application.js', }); } relativeFiles.sort((a, b) => a.relativePath.localeCompare(b.relativePath)); return relativeFiles; } async function walkFiles(directory, root, output) { const entries = await fs.promises.readdir(directory, { withFileTypes: true }); entries.sort((a, b) => a.name.localeCompare(b.name)); for (const entry of entries) { if (entry.name.startsWith('.')) { continue; } const absolutePath = path.join(directory, entry.name); if (entry.isDirectory()) { await walkFiles(absolutePath, root, output); } else if (entry.isFile()) { output.push({ absolutePath, relativePath: path.relative(root, absolutePath).replace(/\\/g, '/'), }); } } } async function createRemotePackage({ buildRoot, dataRoot, remotePackagePath, manifest, versionManifest, assetFiles, }) { assertPathInside(buildRoot, remotePackagePath); const stagingPath = path.join(buildRoot, 'hot-update', `.staging-${process.pid}-${Date.now()}`); assertPathInside(buildRoot, stagingPath); await fs.promises.mkdir(stagingPath, { recursive: true }); try { for (const file of assetFiles) { const destination = path.join(stagingPath, ...file.relativePath.split('/')); await fs.promises.mkdir(path.dirname(destination), { recursive: true }); await fs.promises.copyFile(file.absolutePath, destination); } await writeJsonAtomic(path.join(stagingPath, 'project.manifest'), manifest); await writeJsonAtomic(path.join(stagingPath, 'version.manifest'), versionManifest); await fs.promises.mkdir(path.dirname(remotePackagePath), { recursive: true }); await fs.promises.rm(remotePackagePath, { recursive: true, force: true }); await fs.promises.rename(stagingPath, remotePackagePath); } catch (error) { await fs.promises.rm(stagingPath, { recursive: true, force: true }); throw error; } } function assertPathInside(parent, child) { const relative = path.relative(path.resolve(parent), path.resolve(child)); if (!relative || relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error(`[热更新打包] 拒绝操作构建目录外的路径:${child}`); } } function calculateMd5(filePath) { return new Promise((resolve, reject) => { const hash = crypto.createHash('md5'); const stream = fs.createReadStream(filePath); stream.on('error', reject); stream.on('data', (chunk) => hash.update(chunk)); stream.on('end', () => resolve(hash.digest('hex'))); }); } async function readProjectInfo(projectPath) { const packagePath = path.join(projectPath, 'package.json'); try { return JSON.parse(await fs.promises.readFile(packagePath, 'utf8')); } catch { return {}; } } async function writeJsonAtomic(filePath, value) { await writeTextAtomic(filePath, `${JSON.stringify(value, null, 2)}\n`); } async function writeTextAtomic(filePath, content) { await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`; await fs.promises.writeFile(tempPath, content, 'utf8'); await fs.promises.rename(tempPath, filePath); } async function isDirectory(target) { try { return (await fs.promises.stat(target)).isDirectory(); } catch { return false; } } async function isFile(target) { try { return (await fs.promises.stat(target)).isFile(); } catch { return false; } } module.exports = { BOOTSTRAP_MARKER, collectPayloadFiles, normalizeRemoteUrl, normalizeVersion, packageHotUpdate, };