#!/usr/bin/env node /** * harmony.patch.js * * 该脚本用于鸿蒙元服务的构建后处理(仅在生产构建时由 vue.config.js 触发): * 1. 拷贝 harmony-configs 配置文件到构建产物目录 * 2. 拷贝 site.js 到构建产物目录 * 3. 创建 ZIP 压缩包 * * 使用: * node harmony.patch.js # 拷贝文件并创建 ZIP(默认 mode=production) * node harmony.patch.js --no-zip # 只拷贝文件,不创建 ZIP */ const fs = require('fs'); const fsp = fs.promises; const path = require('path'); const { openFileDirectory } = require('./open-directory'); const { createZipWithSystemCommand } = require('./create-zip'); async function syncSiteToStub(mode = 'production') { try { const cwd = path.join(__dirname, '..'); const srcSitePath = path.join(cwd, 'site.js'); const stubSitePath = path.join(cwd, 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_src', 'site.js'); if (await exists(srcSitePath)) { await fsp.copyFile(srcSitePath, stubSitePath); console.log('site.js 已同步到 stub 项目源码目录'); } } catch (err) { console.error('同步 site.js 到 stub 失败:', err); process.exitCode = 1; } } /** * 同步 manifest.json 的 mp-harmony.privacy 配置到 stub 项目的 app.json * * 背景: * 鸿蒙元服务上架审核需要从 app.json 读 privacy.privacyContractUrl 等字段, * 实际生效的是编译产物里 ascf/ascf_src/app.json (ascf_build 拷贝自此)。 * 但当前 build 流程不会自动从 manifest.json 注入 privacy 字段, * 如果只改 manifest.json 而忘记改 app.json, 审核时拿到的是旧 URL。 * * 同步策略: * 1. 读取 manifest.json 的 mp-harmony.privacy, 期望里面含占位符 YOUR_DOMAIN / YOUR_UNIACID * 2. 从 site.js 解析 baseUrl (域名) + uniacid * 3. 把占位符替换成 site.js 里的真实值, 写入 stub app.json * 4. 若 manifest.json 中无 mp-harmony.privacy → 保留 app.json 现有配置 (向后兼容) * 5. 若 site.js 缺 baseUrl 或 uniacid → 跳过同步, 警告用户 * * 设计取舍: * - manifest.json 用占位符占位 (避免每个 uniacid 切换都要改一次 manifest) * - site.js 永远是真实值来源 (这是项目里已经存在的部署配置) * - 同步在构建时执行, 一份配置适配所有 uniacid * * 依赖: syncSiteToStub 必须先于本函数执行 (在 main() 中已保证) */ /** * 鲁棒的 JSONC 注释剥离 (跳过字符串字面量, 只剥真正的注释) * 原因: manifest.json 顶部有 /* ... *\/ 注释, 用正则 `/\*[\s\S]*?\*\//g` 简单剥离时, * 会跨过字符串字面量, 把"https://..."注释中的字符拼到真实 URL 后面, * 引入换行符导致 JSON.parse 失败 (Bad control character)。 * 必须逐字符扫描, 跳过字符串内的 /*、//。 */ function stripJsonComments(input) { let out = ''; let i = 0; const n = input.length; while (i < n) { const c = input[i]; const next = i + 1 < n ? input[i + 1] : ''; if (c === '/' && next === '*') { i += 2; while (i < n && !(input[i] === '*' && input[i + 1] === '/')) i++; i += 2; continue; } if (c === '/' && next === '/') { i += 2; while (i < n && input[i] !== '\n') i++; continue; } if (c === '"') { out += c; i++; while (i < n) { const sc = input[i]; if (sc === '\\') { out += sc + (input[i + 1] || ''); i += 2; continue; } out += sc; i++; if (sc === '"') break; } continue; } out += c; i++; } return out; } /** * 从 site.js 提取 baseUrl + uniacid * * site.js 形如: * module.exports = { * baseUrl: "https://xcx.aigc-quickapp.com/",//修改域名 * uniacid: 2482,//后台对应uniacid * }; * * 不直接 require, 因为: * 1. site.js 在前端构建产物中用, 可能依赖一些运行时变量 (uni/Config/...) * 2. 同步逻辑要在 vue.config.js 触发, 不希望引入额外依赖 * 3. regex 提取足够安全, 避免执行任意 JS * * @returns {{baseUrl: string, uniacid: string}|null} 解析失败返回 null */ function parseSiteConfig(siteJsSource) { // baseUrl: 单/双引号字符串, 可带尾斜杠 const baseUrlMatch = siteJsSource.match(/baseUrl\s*:\s*['"]([^'"]+)['"]/) // uniacid: 数字或字符串 const uniacidMatch = siteJsSource.match(/uniacid\s*:\s*['"]?(\d+)['"]?/) if (!baseUrlMatch || !uniacidMatch) { return null } return { baseUrl: baseUrlMatch[1], uniacid: uniacidMatch[1], } } /** * 把 baseUrl 拆成 protocol + host (无尾斜杠) * 因为 site.js 的 baseUrl 通常带尾斜杠, 占位符 YOUR_DOMAIN * 在 URL 中间出现 (例: https://YOUR_DOMAIN/api/xxx), 直接拼接 * 会产生双斜杠. 这里规范化为"协议://域名(无尾斜杠)". * * @param {string} baseUrl 例: "https://xcx.aigc-quickapp.com/" * @returns {string} 例: "https://xcx.aigc-quickapp.com" */ function normalizeBaseUrlForPlaceholder(baseUrl) { // 去掉尾斜杠 let url = baseUrl.replace(/\/+$/, '') // 若没有协议, 加 https:// (避免出现 //xcx... 这种相对协议) if (!/^https?:\/\//i.test(url)) { url = 'https://' + url } return url } /** * 递归替换隐私配置对象中的占位符 * @param {*} value 任意 JSON 值 * @param {object} siteConfig {baseUrl, uniacid} * @returns {*} 替换后的新值 (不修改原对象) */ function replacePlaceholders(value, siteConfig) { if (typeof value === 'string') { return value .replace(/YOUR_DOMAIN/g, siteConfig.baseUrl) .replace(/YOUR_UNIACID/g, siteConfig.uniacid) } if (Array.isArray(value)) { return value.map(v => replacePlaceholders(v, siteConfig)) } if (value !== null && typeof value === 'object') { const out = {} for (const k of Object.keys(value)) { out[k] = replacePlaceholders(value[k], siteConfig) } return out } return value } /** * 自定义错误: 验证 app.json 写后内容失败时抛出 * 上层 try/catch 会捕获并 process.exitCode = 1, 让 vue.config.js / npm run build 失败 */ class AppJsonVerificationError extends Error { constructor(messages) { super('app.json 写后验证失败:\n - ' + messages.join('\n - ')) this.name = 'AppJsonVerificationError' this.messages = messages } } /** * 验证写完的 app.json 内容正确 * * 关键字段: * - 顶级 privacy 块存在 * - privacyContractUrl / serviceContractUrl 都以 https:// + baseUrl 开头 * - URL 不含占位符 YOUR_DOMAIN / YOUR_UNIACID * - URL 不含双协议头 (https://https://) * - URL 不含双斜杠 (https://x.com//api) * - URL 的 uniacid= 参数值 == siteConfig.uniacid * - JSON 仍能正确 parse (没有序列化失败) * * 失败行为: 抛 AppJsonVerificationError, 包含所有不通过项 * * @param {string} appJsonPath * @param {{baseUrl: string, uniacid: string}} siteConfig */ async function verifyWrittenAppJson(appJsonPath, siteConfig) { const errors = [] // 1. 重新读取 + parse let written try { const raw = await fsp.readFile(appJsonPath, 'utf8') written = JSON.parse(raw) } catch (e) { throw new AppJsonVerificationError([`文件读取/解析失败: ${e.message}`]) } // 2. 顶级 privacy 块 const privacy = written.privacy if (!privacy || typeof privacy !== 'object') { throw new AppJsonVerificationError(['privacy 块缺失或不是对象']) } // 3. URL 字段检查 const urlFields = ['privacyContractUrl', 'serviceContractUrl'] for (const field of urlFields) { const url = privacy[field] if (typeof url !== 'string') { errors.push(`${field} 缺失或不是字符串 (实际: ${typeof url})`) continue } // 不含占位符 if (/YOUR_DOMAIN|YOUR_UNIACID/.test(url)) { errors.push(`${field} 仍含占位符: ${url}`) } // 不含双协议头 if (/^https:\/\/https:|^http:\/\/http:/i.test(url)) { errors.push(`${field} 含双协议头: ${url}`) } // 不含双斜杠 (协议头之后, 路径开始之前) // 关键: 截到第一个 ? 或 # 之前, 检查是否含 // (避免误报 query 里的 //) const afterProtocol = url.replace(/^https?:\/\//i, '') const queryStart = afterProtocol.search(/[?#]/) const beforeQuery = queryStart === -1 ? afterProtocol : afterProtocol.slice(0, queryStart) if (beforeQuery.includes('//')) { errors.push(`${field} 路径部分含双斜杠 (双斜杠 bug): ${url}`) } // 必须以 https:// + siteConfig.baseUrl 开头 const expectedPrefix = siteConfig.baseUrl + '/' if (!url.startsWith(expectedPrefix)) { errors.push(`${field} 不是以 "${expectedPrefix}" 开头 (实际: ${url.slice(0, expectedPrefix.length + 10)})`) } // uniacid= 参数值 == siteConfig.uniacid const uniacidMatch = url.match(/[?&]uniacid=([^&]+)/) if (!uniacidMatch) { errors.push(`${field} 缺少 uniacid= 参数: ${url}`) } else if (uniacidMatch[1] !== siteConfig.uniacid) { errors.push(`${field} 的 uniacid=${uniacidMatch[1]} 不等于 site.js 的 uniacid=${siteConfig.uniacid}`) } } // 4. contractName 字段未被破坏 (manifest 里的 zh/en name 应该原样保留) const nameFields = ['privacyContractName', 'serviceContractName'] for (const field of nameFields) { const name = privacy[field] if (!name || typeof name !== 'object' || !name.zh || !name.en) { errors.push(`${field} 结构损坏 (应有 zh/en): ${JSON.stringify(name)}`) } } if (errors.length > 0) { throw new AppJsonVerificationError(errors) } } /** * 同步 privacy 到**任意**目标 app.json 路径 * * @param {string} appJsonPath 目标 app.json 文件绝对路径 * @param {string} [label] 标识 (用于日志, 例 "stub" / "build") * @returns {Promise<{written: boolean, siteConfig?: object}>} * * 应用场景: * - syncPrivacyToPath('stub-app-projects/.../app.json', 'stub') * → 写 stub ascf_src/app.json (HBuilderX 之外的源) * - syncPrivacyToPath('unpackage/dist/build/.mp-harmony/app.json', 'build') * → 写 HBuilderX 构建产物的 app.json, **必须**在打 ZIP 之前 * → 否则发行 ZIP 里的 app.json 仍是占位符 */ async function syncPrivacyToPath(appJsonPath, label = '') { try { const cwd = path.join(__dirname, '..'); const manifestPath = path.join(cwd, 'manifest.json'); const siteJsPath = path.join(cwd, 'site.js'); if (!await exists(manifestPath)) { console.warn('[harmony.patch] manifest.json 不存在, 跳过 privacy 同步'); return { written: false }; } if (!await exists(appJsonPath)) { console.warn(`[harmony.patch] app.json 不存在 (${label || appJsonPath}), 跳过 privacy 同步`); return { written: false }; } // 1. 读 manifest.json, 提取 mp-harmony.privacy 配置 (含占位符) const manifestRaw = await fsp.readFile(manifestPath, 'utf8'); const manifestClean = stripJsonComments(manifestRaw); const manifest = JSON.parse(manifestClean); const privacyConfigTemplate = manifest && manifest['mp-harmony'] && manifest['mp-harmony'].privacy; if (!privacyConfigTemplate) { return { written: false }; } // 2. 读 site.js, 提取真实 baseUrl + uniacid if (!await exists(siteJsPath)) { console.warn('[harmony.patch] site.js 不存在, 跳过 privacy 同步'); return { written: false }; } const siteJsSource = await fsp.readFile(siteJsPath, 'utf8'); const siteConfigRaw = parseSiteConfig(siteJsSource); if (!siteConfigRaw) { console.warn('[harmony.patch] ⚠️ site.js 解析失败 (找不到 baseUrl / uniacid), 跳过 privacy 同步'); return { written: false }; } const siteConfig = { baseUrl: normalizeBaseUrlForPlaceholder(siteConfigRaw.baseUrl), uniacid: siteConfigRaw.uniacid, }; // 3. 占位符替换 const privacyConfig = replacePlaceholders(privacyConfigTemplate, siteConfig) // 4. 校验替换结果 const finalStr = JSON.stringify(privacyConfig) if (/YOUR_DOMAIN|YOUR_UNIACID/.test(finalStr)) { console.warn('[harmony.patch] ⚠️ 替换后仍含占位符, 跳过写入. 请检查 site.js / manifest.json 配置') return { written: false }; } // 5. 写目标 app.json const appJsonRaw = await fsp.readFile(appJsonPath, 'utf8'); const appJson = JSON.parse(appJsonRaw); appJson.privacy = privacyConfig; await fsp.writeFile(appJsonPath, JSON.stringify(appJson, null, 2) + '\n', 'utf8'); const labelSuffix = label ? ` (${label})` : ''; console.log(`[harmony.patch] privacy 配置已同步到 app.json${labelSuffix} (baseUrl=${siteConfig.baseUrl}, uniacid=${siteConfig.uniacid})`); // 6. 写后验证 await verifyWrittenAppJson(appJsonPath, siteConfig) return { written: true, siteConfig } } catch (err) { console.error(`[harmony.patch] 同步 privacy 配置失败 (${label || appJsonPath}):`, err); process.exitCode = 1; return { written: false } } } /** * 同步 privacy 到 stub ascf_src/app.json (兼容旧调用) */ async function syncPrivacyToStub() { const cwd = path.join(__dirname, '..'); const stubAppJsonPath = path.join(cwd, 'stub-app-projects', 'mp-harmony', 'ascf', 'ascf_src', 'app.json'); return syncPrivacyToPath(stubAppJsonPath, 'stub'); } async function copyAssets(mode = 'production') { try { const cwd = path.join(__dirname, '..'); const destDir = path.join(cwd, 'unpackage', 'dist', mode === 'production' ? 'build' : 'dev', '.mp-harmony'); await ensureDir(destDir); const copyPromises = []; if (await exists(path.join(cwd, 'harmony-configs'))) { copyPromises.push(copyDirectory(path.join(cwd, 'harmony-configs'), path.join(destDir, 'harmony-configs'))); } const srcSitePath = path.join(cwd, 'site.js'); if (await exists(srcSitePath)) { copyPromises.push(fsp.copyFile(srcSitePath, path.join(destDir, 'site.js'))); // 同步一份到 entry rawfile,让 FormAbility 能在运行时 getRawFileContent('site.js') 读取 // (FormAbility 进程读不到 ascf_src/ 那份 webpack bundle,需要 rawfile 副本) const rawfileSitePath = path.join(cwd, 'stub-app-projects', 'mp-harmony', 'entry', 'src', 'main', 'resources', 'rawfile', 'site.js'); copyPromises.push(fsp.copyFile(srcSitePath, rawfileSitePath)); } await Promise.all(copyPromises); // 给所有子组件 .json 注入 componentPlaceholder, 避免 "diy-* is asyncComponent" 报错. // 见 scripts/harmony-component-placeholder.js 说明. 该脚本跳过页面级, 只处理组件. try { const { execFileSync } = require('child_process'); execFileSync(process.execPath, [ path.join(__dirname, 'harmony-component-placeholder.js'), destDir, ], { stdio: 'inherit', cwd }); } catch (e) { console.warn('[harmony.patch] ⚠️ harmony-component-placeholder 注入失败:', e.message); } console.log('HarmonyOS 资源拷贝完成'); } catch (err) { console.error('发生错误:', err); process.exitCode = 1; } } async function copyDirectory(src, dest) { await ensureDir(dest); const entries = await fsp.readdir(src, { withFileTypes: true }); for (const entry of entries) { const srcPath = path.join(src, entry.name); const destPath = path.join(dest, entry.name); if (entry.isDirectory()) { await copyDirectory(srcPath, destPath); } else { await fsp.copyFile(srcPath, destPath); } } } async function main() { const argv = process.argv.slice(2); const options = { noZip: argv.includes('--no-zip'), mode: 'production', syncPrivacyOnly: argv.includes('--sync-privacy-only'), }; const modeIndex = argv.indexOf('--mode'); if (modeIndex !== -1 && modeIndex + 1 < argv.length) { options.mode = argv[modeIndex + 1]; } // 子命令: --sync-privacy-only // vue.config.js _copyMpHarmonyBuild() 后, 构建产物的 app.json // 会覆盖 stub ascf_src/app.json (因为产物里 app.json 含占位符)。 // vue.config.js 此时再 exec 这个子命令, 强制重新同步一次。 if (options.syncPrivacyOnly) { console.log('[harmony.patch] --sync-privacy-only: 重新同步 privacy (覆盖 _copyMpHarmonyBuild 的拷贝)'); await syncPrivacyToStub(); return; } // 同步 site.js 到 stub 项目源码目录,确保编译时能动态导入 await syncSiteToStub(options.mode); // 同步 manifest.json 的 mp-harmony.privacy 到 stub app.json (防止上架时 URL 不同步) await syncPrivacyToStub(); await copyAssets(options.mode); // 关键: 在打 ZIP 之前, 同步一次 **构建产物** 的 app.json // HBuilderX 构建产物的 app.json 含占位符, 不修正就直接打 ZIP, // 发行包里的 app.json 还是占位符, 上架会被审核驳回。 // 这步必须在 ZIP 之前, 必须在 _copyMpHarmonyBuild 之后 (vue.config.js 顺序)。 if (!options.noZip && options.mode === 'production') { const cwd = path.join(__dirname, '..'); const buildAppJsonPath = path.join(cwd, 'unpackage', 'dist', 'build', '.mp-harmony', 'app.json'); if (await exists(buildAppJsonPath)) { console.log('[harmony.patch] 同步构建产物的 app.json (打 ZIP 前)'); await syncPrivacyToPath(buildAppJsonPath, 'build'); } else { console.warn(`[harmony.patch] 构建产物的 app.json 不存在: ${buildAppJsonPath}`); } } if (!options.noZip && options.mode === 'production') { const cwd = path.join(__dirname, '..'); const sourceDir = path.join(cwd, 'unpackage', 'dist', 'build', '.mp-harmony'); const destDir = path.join(cwd, 'unpackage', 'dist', 'build'); const zipFilePath = await createZipWithSystemCommand(sourceDir, destDir, 'mp-harmony'); console.log(`ZIP 文件路径: ${zipFilePath}`); await openFileDirectory(zipFilePath); } else { console.log('跳过创建 ZIP 文件和打开目录'); } } async function exists(p) { try { await fsp.access(p, fs.constants.F_OK); return true; } catch (e) { return false; } } async function ensureDir(dirPath) { try { await fsp.mkdir(dirPath, { recursive: true }); } catch (e) { if (e.code !== 'EEXIST') throw e; } } main();