From add384916cc36e13b3097232e5f68181528771fa Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Sat, 17 Oct 2026 11:23:36 +0800 Subject: [PATCH 1/9] =?UTF-8?q?fix=EF=BC=9A=E9=B8=BF=E8=92=99=E5=85=83?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E9=BA=A6=E5=85=8B=E9=A3=8E=E6=9D=83=E9=99=90?= =?UTF-8?q?=E8=A1=A5=E5=85=85=20reason=20=E5=AD=97=E6=AE=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ohos.permission.MICROPHONE 是 user_grant 权限,必须同时声明 reason 和 usedScene,缺失导致 hvigor 报 00303218 配置错误、 entry:default@PreBuild 失败。 补充 reason 引用,并新增 permission_microphone_reason 字符串资源。 其余 INTERNET / GET_NETWORK_INFO / VIBRATE 均为 system_grant,无需改动。 --- stub-app-projects/mp-harmony/entry/src/main/module.json5 | 2 ++ .../entry/src/main/resources/base/element/string.json | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/stub-app-projects/mp-harmony/entry/src/main/module.json5 b/stub-app-projects/mp-harmony/entry/src/main/module.json5 index 9d9c47d..c134355 100644 --- a/stub-app-projects/mp-harmony/entry/src/main/module.json5 +++ b/stub-app-projects/mp-harmony/entry/src/main/module.json5 @@ -67,7 +67,9 @@ }, { // PR-FRONTEND-VOICE-INPUT (2026-09-24): 元服务录音需要麦克风权限 + // user_grant 权限必须同时声明 reason 和 usedScene "name": "ohos.permission.MICROPHONE", + "reason": "$string:permission_microphone_reason", "usedScene": { "abilities": [ "EntryAbility" diff --git a/stub-app-projects/mp-harmony/entry/src/main/resources/base/element/string.json b/stub-app-projects/mp-harmony/entry/src/main/resources/base/element/string.json index 9e41945..dec36a0 100644 --- a/stub-app-projects/mp-harmony/entry/src/main/resources/base/element/string.json +++ b/stub-app-projects/mp-harmony/entry/src/main/resources/base/element/string.json @@ -4,6 +4,10 @@ "name": "module_desc", "value": "module description" }, + { + "name": "permission_microphone_reason", + "value": "用于语音搜索与语音输入功能,录制语音以识别用户需求" + }, { "name": "EntryAbility_desc", "value": "EntryAbility description" From 53b5b725a5c5526fb5042fa521eb24423400c897 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Sat, 17 Oct 2026 11:35:38 +0800 Subject: [PATCH 2/9] =?UTF-8?q?docs=EF=BC=9A=E9=B8=BF=E8=92=99=E5=85=83?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E5=8F=91=E5=B8=83=E7=8B=AC=E7=AB=8B=E4=B8=BA?= =?UTF-8?q?=204.3=20=E7=AB=A0=E8=8A=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原先作为 4.1.2 嵌在「小程序发布」下,实际产出的是 atomicService 元服务包, 与小程序流程差异较大,拆为独立的 4.3 章节,排在 4.2 快应用之后。 新增 4.3.2 DevEco Studio 编译与产物:说明 assembleApp 是两步串联 (先编译 HAP,再打包成 APP),列出 HAP / APP 产物路径与 pack.info 中 6 个 HAP 的构成,并注明元服务上架 AppGallery Connect 上传的是 .app。 新增 4.3.3 常见错误:hvigor 00303218 Configuration Error 的成因与修法 (user_grant 权限必须同时声明 reason 和 usedScene),附 user_grant / system_grant 权限速查表。 新增 4.3.4 待完善:运行时权限申请未接入、release 签名仍指向 debug 证书。 --- readme.md | 117 ++++++++++++++++++++++++++++++++++++++++++++---------- 1 file changed, 97 insertions(+), 20 deletions(-) diff --git a/readme.md b/readme.md index fac02ff..1430c91 100644 --- a/readme.md +++ b/readme.md @@ -66,26 +66,6 @@ - 用微信开发者工具导入解压后的代码目录 - 编译验证无误后,上传发布 -#### 4.1.2 鸿蒙小程序发布(mp-harmony) - -##### ##### ##### ##### 【发布构建】 -1. 使用 HBuilderX 打开项目 -2. 选择菜单栏「发行」 → 「HarmonyOS」→ 「发行」,等待构建完成 -3. 构建完成后,**自动执行** `harmony.patch.js` 补丁脚本(配置拷贝 + ZIP 打包) -4. 找到项目根目录 `/unpackage/dist/build` 下生成的 mp-harmony 压缩包 - -##### ##### ##### ##### 【交付与最终发布】🔍 -1. 将生成的 `.zip` 文件发送给运维人员 -2. **运维人员操作流程**: - - 解压压缩包 - - 打开鸿蒙开发者工具,导入解压后的代码目录 - - 验证代码无报错后,执行上传发布操作 - -##### ##### ##### ##### 【本地设置】 - ![](readme_files/1.jpg) -##### ---- - ### 4.2 快应用发布 #### 4.2.1 基本操作(通用版/定制化版) @@ -122,6 +102,103 @@ H5:打开自己的浏览器输入网址(https://5g.aigc-quickapp.com/hwappx/co --- +### 4.3 鸿蒙元服务发布(mp-harmony) + +#### 4.3.1 【发布构建】 +1. 使用 HBuilderX 打开项目 +2. 选择菜单栏「发行」 → 「HarmonyOS」→ 「发行」,等待构建完成 +3. 构建完成后,**自动执行** `harmony.patch.js` 补丁脚本(配置拷贝 + ZIP 打包) +4. 找到项目根目录 `/unpackage/dist/build` 下生成的 mp-harmony 压缩包 + +#### 4.3.2 【DevEco Studio 编译与产物】 +在 DevEco Studio 中导入 `mp-harmony` 工程后,点击「Build」→「Build App(s)/Project(s)」,实际执行的任务是 `assembleApp`: + +``` +# 由 DevEco Studio 自动执行,无需手动敲 +hvigorw --mode project -p product=default assembleApp +``` + +这个任务是**两步串联**的 —— **先编译 HAP,再把 HAP 打包成 APP**: + +| 产物 | 路径 | 说明 | +|------|------|------| +| HAP(中间产物) | `{module}/build/default/outputs/default/{module}-default-signed.hap` | 每个 module 一个,编译+签名的最小单位 | +| APP(最终产物) | `build/outputs/default/mp-harmony-default-signed.app` | 打包全部 HAP 的分发单元 | + +📌 **元服务上架 AppGallery Connect 上传的是 `.app`**,不是 `.hap`。`pack.info` 中 `"bundleType": "atomicService"` 即元服务包标记。 + +📌 本工程共 6 个 HAP,详见 `build/outputs/default/pack.info`: + +| 包名 | moduleType | 说明 | +|------|------------|------| +| `entry-default` | `entry` | 主 HAP,EntryAbility + 服务卡片 | +| `components_diy-default` | `shared` | ascf 动态化分包 | +| `pages_goods-default` | `shared` | ascf 动态化分包 | +| `pages_order-default` | `shared` | ascf 动态化分包 | +| `pages_promotion-default` | `shared` | ascf 动态化分包 | +| `pages_tool-default` | `shared` | ascf 动态化分包 | + +> 若只想单模块调试,可在 DevEco 中用「Build Hap(s)/Module(s)」(即 `assembleHap`),只产出 `.hap`。 + +#### 4.3.3 【常见错误】 + +**❌ `hvigor ERROR: 00303218 Configuration Error`** +``` +Error Message: The reason and usedScene attributes are mandatory for user_grant permissions. + at entry\src\main\module.json5 +``` + +**原因**:`user_grant` 类型权限必须**同时**声明 `reason` 和 `usedScene` 两个字段,只写 `usedScene` 会导致 `:entry:default@PreBuild` 失败。 + +**解决**:给对应权限补上 `reason` 字段,并在 `entry/src/main/resources/base/element/string.json` 中新增对应的理由文案。 + +```json5 +// entry/src/main/module.json5 +{ + "name": "ohos.permission.MICROPHONE", + "reason": "$string:permission_microphone_reason", + "usedScene": { + "abilities": ["EntryAbility"], + "when": "always" + } +} +``` + +```json +// entry/src/main/resources/base/element/string.json +{ + "name": "permission_microphone_reason", + "value": "用于语音搜索与语音输入功能,录制语音以识别用户需求" +} +``` + +📌 **权限类型速查** —— 只有 `user_grant` 才需要 `reason`: + +| 权限 | 类型 | 需要 reason | +|------|------|-------------| +| `ohos.permission.INTERNET` | system_grant | 否 | +| `ohos.permission.GET_NETWORK_INFO` | system_grant | 否 | +| `ohos.permission.VIBRATE` | system_grant | 否 | +| `ohos.permission.MICROPHONE` | **user_grant** | **是** | + +📌 `reason` 文案必须是**用户可读的中文说明**,不能为空或写 `xxx description` 之类的占位文本,否则上架审核会被驳回。 + +#### 4.3.4 【待完善】⚠️ +- **运行时权限申请未接入**:entry 模块中暂未找到 `abilityAccessCtrl` / `requestPermissionsFromUser` 调用。`user_grant` 权限编译通过后仍需在运行时弹窗申请,否则录音等功能会静默失败。 +- **release 签名仍指向 debug 证书**:`build-profile.json5` 中 `release` 签名的 `profile` 仍是 `./cert-profile/qq/qqDebug.p7b`。本地调试无碍,但提交 AppGallery 前必须换成**发布证书 + release 类型 provisioning profile**,否则 AGC 校验会直接拒绝。 + +#### 4.3.5 【交付与最终发布】🔍 +1. 将生成的 `.zip` 文件发送给运维人员 +2. **运维人员操作流程**: + - 解压压缩包 + - 打开鸿蒙开发者工具,导入解压后的代码目录 + - 验证代码无报错后,执行上传发布操作 + +#### 4.3.6 【本地设置】 +![](readme_files/1.jpg) + +--- + ## 5. 📝 构建与版本信息 ### 5.1 自动注入机制 From a9f3c7acc2b13585d84bdbc307ca5dedd67c0097 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Sat, 17 Oct 2026 11:35:44 +0800 Subject: [PATCH 3/9] =?UTF-8?q?docs=EF=BC=9A=E6=B8=85=E7=90=86=E6=96=87?= =?UTF-8?q?=E6=A1=A3=E4=B8=AD=E7=9A=84=E6=9C=AC=E6=9C=BA=E7=BB=9D=E5=AF=B9?= =?UTF-8?q?=E8=B7=AF=E5=BE=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 绝对路径与本机目录绑定,换机器即失效,改为相对表述。 - docs/theme-multi-platform.md:删掉 cd 到绝对路径的命令行, 改为注释说明「在项目根目录(含 package.json)下执行」 - docs/i18n-audit-report.md:审计范围改为「项目根目录」 - docs/ai-agent-integration.md:适用前端改为「本仓库」; WB Cloud SDK 协议约束矩阵原指向外部 refs 仓的绝对路径, 改为相对文件名并标注「WB Cloud SDK 参考仓内」 --- docs/ai-agent-integration.md | 6 +++--- docs/i18n-audit-report.md | 2 +- docs/theme-multi-platform.md | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/docs/ai-agent-integration.md b/docs/ai-agent-integration.md index 3b6f8f0..ffa8efc 100644 --- a/docs/ai-agent-integration.md +++ b/docs/ai-agent-integration.md @@ -1,12 +1,12 @@ # AI Agent 接入使用说明 (Uniapp 前端) > 适用版本: backend PR-WB-MP-INLINE-MEDIA (2026-09-16) 及之后; PR-OSS-REGISTRY (2026-09-15) 之后 12 个云存储 provider -> 适用前端: `D:/projects/shop-projects/frontend` (uniapp 多端: 微信小程序 MP / H5 / App / Harmony) +> 适用前端: 本仓库 (uniapp 多端: 微信小程序 MP / H5 / App / Harmony) > 配套阅读: > - `common/js/ai-service.js`、`pages_tool/ai-chat/` > - 后端 `docs/mp-media-upload-integration.md` (媒体上传后端单点真源) > - 后端 `docs/ai-agent-protocol-spec.md` (本文档对应的完整后端协议规范, 仓内后端目录) -> - `D:/projects/refs/codebuddy-agent-sdk/docs/wb-cloud-sdk-coverage.md` (WB Cloud SDK 协议约束矩阵 — J7/J9/J10/J1/Q6) +> - WB Cloud SDK 协议约束矩阵 (`docs/wb-cloud-sdk-coverage.md`, WB Cloud SDK 参考仓内) (J7/J9/J10/J1/Q6) 本文档面向前端工程师, 解决把 WorkBuddy Agent / AI 智能客服集成进 uniapp 时 最常踩的 4 个坑: @@ -2177,7 +2177,7 @@ uniapp 内联 `:style` 绑定的 rpx 在 H5 端被浏览器丢弃, CSS 文件里 | 媒体上传后端单点真源 | `backend/docs/mp-media-upload-integration.md` | 后端 media_policy / HINTS / STS 错误翻译 | | WB Agent 平台协议 | `backend/src/addon/aiagent/docs/workbuddy_api.md` | ACP / AgentOS REST+ACP 双轨 | | **后端协议完整规范** | `backend/docs/ai-agent-protocol-spec.md` | SSE/WS 帧结构 + REST 端点 + A/B 路径差异 | -| **WB Cloud SDK 协议约束矩阵** | `D:/projects/refs/codebuddy-agent-sdk/docs/wb-cloud-sdk-coverage.md` | J7 (inline base64 ✅) / J9 (URL fetch, 服务端策略) / J10 (无 `/uploads/*` API) / J1 (KB 挂载) / Q6 (source_app 白名单) | +| **WB Cloud SDK 协议约束矩阵** | `docs/wb-cloud-sdk-coverage.md` (WB Cloud SDK 参考仓) | J7 (inline base64 ✅) / J9 (URL fetch, 服务端策略) / J10 (无 `/uploads/*` API) / J1 (KB 挂载) / Q6 (source_app 白名单) | | **后端 PR-WB-MP-INLINE-MEDIA ANALYSIS** | `backend/tests/wb_media_backend/ANALYSIS.md` §8 | attachments.data 字段 PR 详情 + 4 处文件改动面 + 协议层 A/B 对称修复 + 26 项 sanity 测试结果 | | ai-chat 前端入口 | `frontend/pages_tool/ai-chat/index.vue` | 路由 + agent_id 接收 | | ai-chat 消息渲染 | `frontend/pages_tool/ai-chat/ai-chat-message.vue` | 流式消息 / 历史 / 预览 | diff --git a/docs/i18n-audit-report.md b/docs/i18n-audit-report.md index b5b3914..74fe765 100644 --- a/docs/i18n-audit-report.md +++ b/docs/i18n-audit-report.md @@ -1,6 +1,6 @@ # 国际化(i18n)全量审计报告 -> 审计范围:`z:\D\projects\shop-projects\frontend` 下所有 `.vue` / `.js` 文件(排除 `node_modules`、`uni_modules`、`unpackage`、`docs`、`scripts` 等) +> 审计范围:项目根目录下所有 `.vue` / `.js` 文件(排除 `node_modules`、`uni_modules`、`unpackage`、`docs`、`scripts` 等) > 审计时间:2026-06-29 > 当前静态校验:`node scripts/i18n-check.js` 通过(zh-cn / en-us key 完全一致) > 修复状态:P0 / P1 / P2(兜底 + 验证)已修复并验证 ✅ diff --git a/docs/theme-multi-platform.md b/docs/theme-multi-platform.md index 7fbaa76..47e2d29 100644 --- a/docs/theme-multi-platform.md +++ b/docs/theme-multi-platform.md @@ -106,8 +106,9 @@ background: linear-gradient(<角度>, var(--token-start, #原始色) 0%, var(--t ## 四、强制兼容性测试项(上 HBuilder 前必做) ```bash +# 以下命令均在项目根目录(含 package.json)下执行 + # 1. 静态语法检查 -cd D:/projects/shop-projects/frontend node --check common/js/theme-override.js node --check store/index.js # App.vue 走 vue 编译器,不在 node 范畴 From 9086f1665c6c153f7a7f7f180a0f2b5fcb395baf Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Sat, 17 Oct 2026 11:46:57 +0800 Subject: [PATCH 4/9] =?UTF-8?q?feat=EF=BC=9A=E9=B8=BF=E8=92=99=E5=85=83?= =?UTF-8?q?=E6=9C=8D=E5=8A=A1=E6=8E=A5=E5=85=A5=E9=BA=A6=E5=85=8B=E9=A3=8E?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E6=97=B6=E6=9D=83=E9=99=90=E7=94=B3=E8=AF=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MICROPHONE 是 user_grant 权限, 上一提交补了 module.json5 的 reason 只解决 了编译校验, 运行时仍需弹窗申请, 否则录音功能不可用。 在 EntryAbility.onCreate 中调用 abilityAccessCtrl.requestPermissionsFromUser。 关键点: - AscfUIAbility 父类已定义 onCreate, 重写时必须调 super.onCreate(want, launchParam), 否则跳过 ascf runtime 初始化导致元服务白屏 - 选 onCreate 而非按需触发: 权限状态在 JS 侧 (uni.getRecorderManager) 读不到, 项目无现成 JS<->ArkTS 通道; 且 requestPermissionsFromUser 对已授权应用 直接 resolve 不弹窗, 实际只有首次启动打扰用户一次 - 降级: 未授权不影响浏览/下单/卡片, 仅语音不可用, 前端 voice-panel 会 showToast 提示 readme 4.3.3 补充该实现与踩坑说明, 4.3.4 移除已解决项。 --- readme.md | 21 ++++++++- .../main/ets/entryability/EntryAbility.ets | 45 +++++++++++++++++-- 2 files changed, 60 insertions(+), 6 deletions(-) diff --git a/readme.md b/readme.md index 1430c91..33853d8 100644 --- a/readme.md +++ b/readme.md @@ -183,9 +183,26 @@ Error Message: The reason and usedScene attributes are mandatory for user_grant 📌 `reason` 文案必须是**用户可读的中文说明**,不能为空或写 `xxx description` 之类的占位文本,否则上架审核会被驳回。 +📌 **改完 `reason` 还不够** —— `user_grant` 权限编译通过后,仍需在运行时弹窗申请,否则录音等功能不可用。本项目已在 [EntryAbility.ets](stub-app-projects/mp-harmony/entry/src/main/ets/entryability/EntryAbility.ets) 的 `onCreate` 中接入: + +```ts +onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { + // 必须先调父类: ascf runtime 的初始化在 AscfUIAbility.onCreate 里, 漏掉会导致元服务白屏 + super.onCreate(want, launchParam); + this.requestMicrophonePermission(); +} +``` + +> ⚠️ **坑**:`AscfUIAbility` 父类**已定义** `onCreate`,重写时必须调 `super.onCreate(want, launchParam)`,否则 ascf runtime 初始化被跳过,元服务白屏。 + +📌 为什么在 `onCreate` 申请而不是"用户点录音时才申请": +- 权限状态在 JS 侧(`uni.getRecorderManager`)读不到,项目里也没有现成的 JS ↔ ArkTS 通道,做不了按需触发 +- `requestPermissionsFromUser` 对已授权的应用直接 resolve 且**不弹窗**,所以实际只有首次启动打扰用户一次 + +📌 用户拒绝后的降级:不影响浏览/下单/卡片等其他功能,仅语音输入不可用;[ai-chat-voice-panel.vue](pages_tool/ai-chat/components/ai-chat-voice-panel.vue) 会 catch 到 recorder 错误并 `showToast` 提示用户。 + #### 4.3.4 【待完善】⚠️ -- **运行时权限申请未接入**:entry 模块中暂未找到 `abilityAccessCtrl` / `requestPermissionsFromUser` 调用。`user_grant` 权限编译通过后仍需在运行时弹窗申请,否则录音等功能会静默失败。 -- **release 签名仍指向 debug 证书**:`build-profile.json5` 中 `release` 签名的 `profile` 仍是 `./cert-profile/qq/qqDebug.p7b`。本地调试无碍,但提交 AppGallery 前必须换成**发布证书 + release 类型 provisioning profile**,否则 AGC 校验会直接拒绝。 +- **release 签名仍指向 debug 证书**:`build-profile.json5` 中 `release` 签名的 `profile` 仍是 `./cert-profile/qq/qqDebug.p7b`,与 `default`(debug)共用同一份证书。本地调试无碍,但提交 AppGallery 前必须换成**发布证书 + release 类型 provisioning profile**,否则 AGC 校验会直接拒绝。 #### 4.3.5 【交付与最终发布】🔍 1. 将生成的 `.zip` 文件发送给运维人员 diff --git a/stub-app-projects/mp-harmony/entry/src/main/ets/entryability/EntryAbility.ets b/stub-app-projects/mp-harmony/entry/src/main/ets/entryability/EntryAbility.ets index 988ead8..566fb15 100644 --- a/stub-app-projects/mp-harmony/entry/src/main/ets/entryability/EntryAbility.ets +++ b/stub-app-projects/mp-harmony/entry/src/main/ets/entryability/EntryAbility.ets @@ -1,4 +1,41 @@ -import { AscfUIAbility } from '@atomicservice/ascfapi'; - -export default class EntryAbility extends AscfUIAbility { -} \ No newline at end of file +import { abilityAccessCtrl, AbilityConstant, Permissions, Want } from '@kit.AbilityKit'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { AscfUIAbility } from '@atomicservice/ascfapi'; + +export default class EntryAbility extends AscfUIAbility { + onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { + // 必须先调父类: ascf runtime 的初始化在 AscfUIAbility.onCreate 里, 漏掉会导致元服务白屏 + super.onCreate(want, launchParam); + this.requestMicrophonePermission(); + } + + /** + * 申请麦克风权限 (ohos.permission.MICROPHONE, user_grant 类型)。 + * + * 为什么放在 onCreate 而不是"用户点录音时才申请": + * 1. 权限状态在 JS 侧 (uni.getRecorderManager) 读不到, 项目里也没有现成的 + * JS <-> ArkTS 通道, 做不了按需触发的条件判断; + * 2. requestPermissionsFromUser 对已授权的应用直接 resolve 且不弹窗, + * 所以实际只有首次启动会打扰用户一次, 之后每次冷启动都是无感的。 + * + * 未授权时的降级行为: + * - 不影响其他功能 (浏览/下单/卡片), 只是语音输入不可用; + * - 前端 ai-chat-voice-panel.vue 会 catch 到 recorder 错误并 showToast 提示用户。 + */ + private async requestMicrophonePermission(): Promise { + try { + const atManager = abilityAccessCtrl.createAtManager(); + const result = await atManager.requestPermissionsFromUser(this.context, [Permissions.MICROPHONE]); + const authResult = result.authResults[0]; + if (authResult === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) { + console.info('[EntryAbility] MICROPHONE 已授权, 语音输入可用'); + } else { + console.warn('[EntryAbility] MICROPHONE 未授权, 语音输入不可用 (不影响其他功能)'); + } + } catch (err) { + // 用户取消授权弹窗 / context 异常, 同样降级为"语音不可用", 不阻断启动 + const e = err as BusinessError; + console.warn(`[EntryAbility] 申请麦克风权限异常: code=${e.code}, message=${e.message}`); + } + } +} From 550ec72bb7a0c923c26ac600183dfea8f3290a3f Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Sat, 17 Oct 2026 11:53:44 +0800 Subject: [PATCH 5/9] =?UTF-8?q?fix=EF=BC=9A=E9=BA=A6=E5=85=8B=E9=A3=8E?= =?UTF-8?q?=E6=9D=83=E9=99=90=20Permissions=20=E7=B1=BB=E5=9E=8B=E7=94=A8?= =?UTF-8?q?=E6=B3=95=E4=BF=AE=E6=AD=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交写成 Permissions.MICROPHONE 导致 CompileArkTS 失败: 'Permissions' only refers to a type, but is being used as a value here Permissions 在 @ohos.permissions 中是 export type 字符串联合类型, 不是枚举, 只能用在类型位置。改为类型标注 + 权限名字面量: const PERMISSION_MICROPHONE: Permissions = 'ohos.permission.MICROPHONE'; 注意 abilityAccessCtrl 里的 SwitchType.MICROPHONE(=1) 是开关类型枚举, 与权限名无关, 容易误用。 readme 4.3.3 补充该类型坑说明。 --- readme.md | 13 +++++++++++++ .../src/main/ets/entryability/EntryAbility.ets | 10 +++++++++- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/readme.md b/readme.md index 33853d8..3ac20d8 100644 --- a/readme.md +++ b/readme.md @@ -195,6 +195,19 @@ onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { > ⚠️ **坑**:`AscfUIAbility` 父类**已定义** `onCreate`,重写时必须调 `super.onCreate(want, launchParam)`,否则 ascf runtime 初始化被跳过,元服务白屏。 +> ⚠️ **坑**:`Permissions` 在 `@ohos.permissions` 里是**字符串联合类型**([permissions.d.ts](https://gitee.com/openharmony/docs/blob/master/zh-cn/application-dev/reference/apis-ability-kit/js-apis-permissions.md)),不是枚举,**只能用在类型位置**,当值引用会报: +> ``` +> 'Permissions' only refers to a type, but is being used as a value here. +> ``` +> 所以正确写法是标注 + 字面量,且权限名必须与 `module.json5` 中声明的一致: +> ```ts +> import { abilityAccessCtrl, AbilityConstant, Permissions, Want } from '@kit.AbilityKit'; +> +> const PERMISSION_MICROPHONE: Permissions = 'ohos.permission.MICROPHONE'; +> // ❌ Permissions.MICROPHONE —— 编译不过 +> // ⚠️ abilityAccessCtrl.SwitchType.MICROPHONE(=1) 是"开关类型",不是权限名,别搞混 +> ``` + 📌 为什么在 `onCreate` 申请而不是"用户点录音时才申请": - 权限状态在 JS 侧(`uni.getRecorderManager`)读不到,项目里也没有现成的 JS ↔ ArkTS 通道,做不了按需触发 - `requestPermissionsFromUser` 对已授权的应用直接 resolve 且**不弹窗**,所以实际只有首次启动打扰用户一次 diff --git a/stub-app-projects/mp-harmony/entry/src/main/ets/entryability/EntryAbility.ets b/stub-app-projects/mp-harmony/entry/src/main/ets/entryability/EntryAbility.ets index 566fb15..8983402 100644 --- a/stub-app-projects/mp-harmony/entry/src/main/ets/entryability/EntryAbility.ets +++ b/stub-app-projects/mp-harmony/entry/src/main/ets/entryability/EntryAbility.ets @@ -2,6 +2,14 @@ import { abilityAccessCtrl, AbilityConstant, Permissions, Want } from '@kit.Abil import { BusinessError } from '@kit.BasicServicesKit'; import { AscfUIAbility } from '@atomicservice/ascfapi'; +// ⚠️ Permissions 在 @ohos.permissions 里是**字符串联合类型**, 不是枚举, +// 只能用在类型位置 (如这里的标注), 不能当值引用 (Permissions.MICROPHONE 会报 +// "'Permissions' only refers to a type, but is being used as a value here"), +// 所以权限名必须写字面量, 且要与 module.json5 中声明的一致。 +// 另外别被 abilityAccessCtrl 里的 SwitchType 枚举误导, 它的 MICROPHONE=1 是 +// 开关类型, 不是权限名。 +const PERMISSION_MICROPHONE: Permissions = 'ohos.permission.MICROPHONE'; + export default class EntryAbility extends AscfUIAbility { onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { // 必须先调父类: ascf runtime 的初始化在 AscfUIAbility.onCreate 里, 漏掉会导致元服务白屏 @@ -25,7 +33,7 @@ export default class EntryAbility extends AscfUIAbility { private async requestMicrophonePermission(): Promise { try { const atManager = abilityAccessCtrl.createAtManager(); - const result = await atManager.requestPermissionsFromUser(this.context, [Permissions.MICROPHONE]); + const result = await atManager.requestPermissionsFromUser(this.context, [PERMISSION_MICROPHONE]); const authResult = result.authResults[0]; if (authResult === abilityAccessCtrl.GrantStatus.PERMISSION_GRANTED) { console.info('[EntryAbility] MICROPHONE 已授权, 语音输入可用'); From 373db1fb73fc198398d0812ff323d9b049b8cb61 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Sat, 17 Oct 2026 13:55:54 +0800 Subject: [PATCH 6/9] =?UTF-8?q?docs=EF=BC=9A=E4=BF=AE=E6=AD=A3=E9=B8=BF?= =?UTF-8?q?=E8=92=99=E4=BA=A7=E7=89=A9=E6=9E=84=E6=88=90=E6=8F=8F=E8=BF=B0?= =?UTF-8?q?=EF=BC=881=20HAP=20+=205=20HSP=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 原描述「共 6 个 HAP」不准确。module.json5 中 type: "entry" 编成 HAP, type: "shared" 编成 HSP(共享包, 动态下发), 本工程实际是 1 个 HAP + 5 个 HSP。 同步修正: - 两步流程描述改为「逐个 module 编译出包, 再打成 APP」, 产物路径扩展名 改为 {hap|hsp} - 包列表新增扩展名列, 标明哪些是 .hap、哪些是 .hsp - 单模块调试说明改为「只产出分包、不打 APP」 另补充实测体积: 主包 entry-default.hap 约 2.37 MiB(超官方单包 2MB 限制), 总包约 6.39 MiB(在 10MB 以内)。 --- readme.md | 28 +++++++++++++++------------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/readme.md b/readme.md index 3ac20d8..fd4ed1b 100644 --- a/readme.md +++ b/readme.md @@ -118,27 +118,29 @@ H5:打开自己的浏览器输入网址(https://5g.aigc-quickapp.com/hwappx/co hvigorw --mode project -p product=default assembleApp ``` -这个任务是**两步串联**的 —— **先编译 HAP,再把 HAP 打包成 APP**: +这个任务是**两步串联**的 —— **先逐个 module 编译出包,再把这些包打成 APP**: | 产物 | 路径 | 说明 | |------|------|------| -| HAP(中间产物) | `{module}/build/default/outputs/default/{module}-default-signed.hap` | 每个 module 一个,编译+签名的最小单位 | -| APP(最终产物) | `build/outputs/default/mp-harmony-default-signed.app` | 打包全部 HAP 的分发单元 | +| 分包(中间产物) | `{module}/build/default/outputs/default/{module}-default-signed.{hap\|hsp}` | 每个 module 一个,编译+签名的最小单位 | +| APP(最终产物) | `build/outputs/default/mp-harmony-default-signed.app` | 把全部分包收拢成的分发单元 | 📌 **元服务上架 AppGallery Connect 上传的是 `.app`**,不是 `.hap`。`pack.info` 中 `"bundleType": "atomicService"` 即元服务包标记。 -📌 本工程共 6 个 HAP,详见 `build/outputs/default/pack.info`: +📌 **本工程共 1 个 HAP + 5 个 HSP**(不是 6 个 HAP)—— `module.json5` 里 `type: "entry"` 编成 **HAP**,`type: "shared"` 编成 **HSP**(共享包,动态下发)。详见 `build/outputs/default/pack.info`: -| 包名 | moduleType | 说明 | -|------|------------|------| -| `entry-default` | `entry` | 主 HAP,EntryAbility + 服务卡片 | -| `components_diy-default` | `shared` | ascf 动态化分包 | -| `pages_goods-default` | `shared` | ascf 动态化分包 | -| `pages_order-default` | `shared` | ascf 动态化分包 | -| `pages_promotion-default` | `shared` | ascf 动态化分包 | -| `pages_tool-default` | `shared` | ascf 动态化分包 | +| 包名 | 扩展名 | moduleType | 说明 | +|------|--------|------------|------| +| `entry-default` | `.hap` | `entry` | 主包,EntryAbility + 服务卡片 + uniapp 主 bundle | +| `components_diy-default` | `.hsp` | `shared` | ascf 动态化分包 | +| `pages_goods-default` | `.hsp` | `shared` | ascf 动态化分包 | +| `pages_order-default` | `.hsp` | `shared` | ascf 动态化分包 | +| `pages_promotion-default` | `.hsp` | `shared` | ascf 动态化分包 | +| `pages_tool-default` | `.hsp` | `shared` | ascf 动态化分包 | -> 若只想单模块调试,可在 DevEco 中用「Build Hap(s)/Module(s)」(即 `assembleHap`),只产出 `.hap`。 +⚠️ **主包体积**:元服务官方要求**单个包 ≤ 2MB**(按压缩后算,含其直接依赖的 HSP),同一设备类型下全部分包总和 ≤ 10MB。实测本工程主包 `entry-default.hap` 约 **2.37 MiB**,已超单包 2MB 限制;总包约 **6.39 MiB**,在 10MB 以内。 + +> 若只想单模块调试,可在 DevEco 中用「Build Hap(s)/Module(s)」(即 `assembleHap`),只产出分包、不打 APP。 #### 4.3.3 【常见错误】 From 77b1a3372c032e14b3d27b562142a40969db8f71 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Sat, 17 Oct 2026 14:19:54 +0800 Subject: [PATCH 7/9] =?UTF-8?q?feat=EF=BC=9A=E4=B8=80=E9=94=AE=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E9=B8=BF=E8=92=99=E5=B7=A5=E7=A8=8B=E5=8C=85=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8D=A2=E6=9C=BA=E7=BC=96=E8=AF=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 把 stub-app-projects/mp-harmony 中编译所需的文件导成 zip, 供没有本仓库/没有 HBuilderX 的电脑解压后直接用 DevEco 编译上传。 用法: npm run pack:harmony # 默认: 含证书, 不含 node_modules npm run pack:harmony -- --no-certs # 不含签名证书 npm run pack:harmony -- --with-node-modules # 含 hvigor/node_modules(320MB), 离线机 npm run pack:harmony -- --out # 自定义输出目录 输出 unpackage/dist/harmony-export/mp-harmony-project-*.zip, 内含一层 mp-harmony/。 实现要点: - 全程只读源目录: 先复制到暂存目录再压缩, 不修改 stub 下任何文件 - 采用「全量复制 - 排除清单」而非白名单, 避免漏掉新增必需文件 - 包含 ascf/ascf_src (uniapp 编译产物快照), 目标机因此无需本仓库和 HBuilderX - 包含 cert-profile 签名证书, 否则 hvigor 无法签名, 产不出可上传的 .app - 排除 node_modules(320M)/oh_modules/build/.preview(22M)/.idea/.hvigor/local.properties ZIP 由纯 Node 实现 (zlib + 手写 ZIP 头), 未用系统命令, 因为本机两种方案都产出 不合规 zip 且失败是静默的: - PowerShell Compress-Archive / ZipFile::CreateFromDirectory 写反斜杠条目名, 非 ZIP 规范, macOS/Linux 解压出畸形路径 - 本机 tar 是 GNU tar 1.35, 不支持创建 zip 且 exit 0, try/catch 拦不住 已验证: 解压后骨架齐全(AppScope/build-profile/entry/ascf_src/证书/README), 排除项均不存在, unzip -t 无错误, 中文文件名改 ASCII 规避工具编码差异。 --- package.json | 1 + readme.md | 55 +++- scripts/pack-harmony-project.js | 444 ++++++++++++++++++++++++++++++++ 3 files changed, 496 insertions(+), 4 deletions(-) create mode 100644 scripts/pack-harmony-project.js diff --git a/package.json b/package.json index 3db959f..c1d6d00 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,7 @@ { "scripts": { "mp-weixin": "node scripts/mp-weixin.patch.js", + "pack:harmony": "node scripts/pack-harmony-project.js", "i18n:check": "node scripts/i18n-check.js", "test": "node scripts/test-gate.js", "test:util": "node --test tests/util.test.js", diff --git a/readme.md b/readme.md index fd4ed1b..b45dda7 100644 --- a/readme.md +++ b/readme.md @@ -142,7 +142,54 @@ hvigorw --mode project -p product=default assembleApp > 若只想单模块调试,可在 DevEco 中用「Build Hap(s)/Module(s)」(即 `assembleHap`),只产出分包、不打 APP。 -#### 4.3.3 【常见错误】 +#### 4.3.3 【工程包导出(换机编译)】 + +需要在一台**没有本仓库 / 没有 HBuilderX** 的电脑上编译鸿蒙工程时,用脚本把 `stub-app-projects/mp-harmony` 里编译所需的文件导成一个 zip: + +```bash +npm run pack:harmony +``` + +输出到 `unpackage/dist/harmony-export/mp-harmony-project-v<日期>-<提交ID>.zip`,解压后即得 `mp-harmony/` 目录。 + +| 参数 | 用途 | +|------|------| +| (无参数) | 默认行为:含签名证书,不含 `node_modules` | +| `--no-certs` | 不含 `cert-profile/`(见下方安全提示) | +| `--with-node-modules` | 额外含 `hvigor/node_modules`(约 320 MB),供**离线**目标机 | +| `--out <目录>` | 自定义输出目录 | + +**脚本全程只读源目录**,产物先复制到暂存目录再压缩,不修改 `stub-app-projects` 下任何文件。 + +**导出内容** + +| 类别 | 说明 | +|------|------| +| 工程源码 | `AppScope/`、`entry/src/`、`ascf/ascf_build/*/src/`、各类 `*.json5` / `hvigorfile.ts` | +| `ascf/ascf_src/` | uniapp 编译产物(页面/组件代码),**带上它目标机就不需要本仓库和 HBuilderX** | +| `cert-profile/` | 签名证书(`--no-certs` 可排除) | + +**已排除**(可由目标机 DevEco 重新生成) + +`node_modules/`(约 320 MB)、`oh_modules/`、`**/build/`、`.preview/`(约 22 MB)、`.idea/`、`.hvigor/`、`local.properties`、`.cxx/`、`.test/` + +> ⚠️ **证书安全**:默认导出**包含签名私钥** `cert-profile/qq/qq.p12`,且 p12 密码在 `build-profile.json5` 中本就是明文。请仅在可信渠道传递,用完及时销毁或归档。对外发给不可信对象时务必加 `--no-certs`。 +> +> 📌 缺证书 hvigor 无法签名,产不出可上传的 `.app`。用 `--no-certs` 时需自行把 `cert-profile/` 放到目标工程对应位置。 + +**目标机操作** + +1. 安装 DevEco Studio 5.0.x(本工程 `compatibleSdkVersion` 5.0.0(12)) +2. 解压 zip,得到 `mp-harmony/` +3. DevEco Studio「打开」`mp-harmony` 目录本身 +4. 首次打开自动执行依赖同步(`ohpm install` 拉取 `hvigor/node_modules`),**需联网**;离线机请用 `--with-node-modules` 导出 +5. 「Build」→「Build App(s)/Project(s)」,产物 `build/outputs/default/mp-harmony-default-signed.app` + +> ⚠️ **`ascf/ascf_src/` 是导出时刻的快照**。若前端代码有更新,必须回源机器重新执行导出,否则编译出的包内容是旧的。 + +📌 zip 由脚本内纯 Node 实现(`zlib` + 手写 ZIP 头)生成,条目名统一正斜杠并置 UTF-8 标志位,可跨 Windows/macOS/Linux 使用。 + +#### 4.3.4 【常见错误】 **❌ `hvigor ERROR: 00303218 Configuration Error`** ``` @@ -216,17 +263,17 @@ onCreate(want: Want, launchParam: AbilityConstant.LaunchParam): void { 📌 用户拒绝后的降级:不影响浏览/下单/卡片等其他功能,仅语音输入不可用;[ai-chat-voice-panel.vue](pages_tool/ai-chat/components/ai-chat-voice-panel.vue) 会 catch 到 recorder 错误并 `showToast` 提示用户。 -#### 4.3.4 【待完善】⚠️ +#### 4.3.5 【待完善】⚠️ - **release 签名仍指向 debug 证书**:`build-profile.json5` 中 `release` 签名的 `profile` 仍是 `./cert-profile/qq/qqDebug.p7b`,与 `default`(debug)共用同一份证书。本地调试无碍,但提交 AppGallery 前必须换成**发布证书 + release 类型 provisioning profile**,否则 AGC 校验会直接拒绝。 -#### 4.3.5 【交付与最终发布】🔍 +#### 4.3.6 【交付与最终发布】🔍 1. 将生成的 `.zip` 文件发送给运维人员 2. **运维人员操作流程**: - 解压压缩包 - 打开鸿蒙开发者工具,导入解压后的代码目录 - 验证代码无报错后,执行上传发布操作 -#### 4.3.6 【本地设置】 +#### 4.3.7 【本地设置】 ![](readme_files/1.jpg) --- diff --git a/scripts/pack-harmony-project.js b/scripts/pack-harmony-project.js new file mode 100644 index 0000000..d3434ed --- /dev/null +++ b/scripts/pack-harmony-project.js @@ -0,0 +1,444 @@ +#!/usr/bin/env node +/** + * pack-harmony-project.js + * + * 把 stub-app-projects/mp-harmony 中「编译所需」的文件导出为一个 zip, + * 供其他电脑解压后直接用 DevEco Studio 编译并上传产物。 + * + * 用法: + * node scripts/pack-harmony-project.js # 默认: 含证书, 不含 node_modules + * node scripts/pack-harmony-project.js --with-node-modules # 额外含 hvigor/node_modules (约 320MB), 供离线机器 + * node scripts/pack-harmony-project.js --no-certs # 不含签名证书 + * node scripts/pack-harmony-project.js --out # 自定义输出目录 (默认 unpackage/dist/harmony-export) + * + * 设计原则: + * - **只读**源目录, 全程不修改 stub-app-projects 下任何文件 (产物先复制到暂存目录再压缩) + * - 采用「全量复制 - 排除清单」策略, 而不是白名单, 避免漏掉新增的必需文件 + * - 输出 zip 内含一层 mp-harmony/ 目录, 解压后不会散落一地 + * + * 为什么必须包含 cert-profile: + * build-profile.json5 的 signingConfigs 引用了 ./cert-profile/qq/ 下的 + * qq.p12 / qqDebug.p7b / qq.cer。证书缺失时 hvigor 无法签名, 产不出可上传的 .app。 + * ⚠️ 默认包含意味着 zip 内含私钥, 仅在可信渠道传递。 + * + * 为什么包含 ascf/ascf_src: + * 它是 uniapp 构建产物 (由 vue.config.js 的 _copyMpHarmonyBuild 从 + * unpackage/dist/build/.mp-harmony 拷入), 装着真正的页面/组件代码。 + * 带上它, 目标机就不需要本仓库和 HBuilderX, 解压即可编译。 + * ⚠️ 它是导出时刻的快照; 若前端代码有更新, 需重新执行本脚本。 + */ + +const fs = require('fs'); +const fsp = fs.promises; +const path = require('path'); +const { exec } = require('child_process'); +const { promisify } = require('util'); + +const execAsync = promisify(exec); + +const PROJECT_DIR_NAME = 'mp-harmony'; +const DEFAULT_OUT_DIR = path.join('unpackage', 'dist', 'harmony-export'); + +/** + * 目录名黑名单 —— 出现在任意层级都跳过 + * + * - node_modules / oh_modules: 依赖, 可由 DevEco 打开工程时自动拉取 + * (hvigor/node_modules 约 320MB, 不可能进包; 需要离线机时用 --with-node-modules) + * - build: 各 module 的编译产物 + * - .preview: DevEco 预览缓存 (entry 下就占 22MB) + * - .hvigor / .idea: IDE 本地状态 + * - .cxx / .test: 原生编译缓存与测试产物 + */ +const EXCLUDE_DIRS = new Set([ + 'node_modules', 'oh_modules', 'build', '.preview', '.hvigor', '.idea', + '.cxx', '.test', '.git', '.appanalyzer', '.clangd', '.kotlin', +]); + +/** + * 文件名黑名单 —— 出现在任意层级都跳过 + * + * - local.properties: DevEco 自动生成, 内含本机 SDK 路径 + * - _1.clang-format / _1.gitignore: macOS Finder 产生的重名副本 (Windows 上是 _1, macOS 是 _副本) + */ +const EXCLUDE_FILES = new Set([ + 'local.properties', '.DS_Store', 'Thumbs.db', '_1.clang-format', '_1.gitignore', +]); + +/** 含私钥的目录, --no-certs 时跳过 */ +const CERT_DIR = 'cert-profile'; + +function parseArgs(argv) { + const opts = { + withNodeModules: false, + withCerts: true, + outDir: null, + }; + for (const arg of argv) { + if (arg === '--with-node-modules') opts.withNodeModules = true; + else if (arg === '--no-certs') opts.withCerts = false; + else if (arg === '--out') { /* 由调用方给出下一项 */ } + } + const outIdx = argv.indexOf('--out'); + if (outIdx !== -1 && outIdx + 1 < argv.length) { + opts.outDir = path.resolve(argv[outIdx + 1]); + } + return opts; +} + +/** 该目录是否需要跳过 */ +function shouldSkipDir(name, opts, isRootLevel) { + if (EXCLUDE_DIRS.has(name)) { + // node_modules 只有根级 hvigor/node_modules 需要可选保留; + // oh_modules / entry/node_modules 等一律跳过 (体积小, DevEco 会重建) + if (opts.withNodeModules && name === 'node_modules' && isRootLevel) return false; + return true; + } + if (!opts.withCerts && name === CERT_DIR) return true; + return false; +} + +function shouldSkipFile(name) { + if (EXCLUDE_FILES.has(name)) return true; + // 编译/日志类临时文件 + return /\.(log|tmp|swp)$/.test(name); +} + +const stats = { files: 0, bytes: 0, skipped: 0 }; + +async function copyDir(srcDir, destDir, opts, depth = 0, nameOfParent = '') { + await fsp.mkdir(destDir, { recursive: true }); + const entries = await fsp.readdir(srcDir, { withFileTypes: true }); + for (const entry of entries) { + const srcPath = path.join(srcDir, entry.name); + const destPath = path.join(destDir, entry.name); + + if (entry.isDirectory()) { + if (shouldSkipDir(entry.name, opts, depth === 0)) { + stats.skipped++; + continue; + } + await copyDir(srcPath, destPath, opts, depth + 1, entry.name); + } else if (entry.isFile()) { + if (shouldSkipFile(entry.name)) { + stats.skipped++; + continue; + } + await fsp.copyFile(srcPath, destPath); + const st = await fsp.stat(destPath); + stats.files++; + stats.bytes += st.size; + } + // 符号链接等其它类型直接跳过 + } +} + +/* ========================================================================== + * ZIP 写入 (纯 Node, 零依赖) + * + * 为什么要自己写: + * 本机两种系统方案都产出**不合规**的 zip, 且失败方式是静默的: + * 1. PowerShell Compress-Archive / [ZipFile]::CreateFromDirectory + * 写入的条目名用**反斜杠** (mp-harmony\AppScope\app.json5)。ZIP 规范要求 + * 正斜杠; Windows 解压侥幸能过, macOS/Linux 会把 `\` 当字面量, 解出畸形路径。 + * 2. 本机 tar 是 GNU tar 1.35, **不支持创建 zip** (`-a` 仅对 tar 归档生效), + * 而且 exit 0, try/catch 拦不住, 会产出无法解压的 .zip。 + * 另外 .NET 方案还把中文文件名写成 ANSI 编码 (导出说明.txt -> 乱码), 因为没置 + * UTF-8 标志位 (general purpose bit 11)。 + * + * 自己写则能保证: 条目名统一正斜杠 + UTF-8 标志位恒置 + 跨平台字节一致。 + * ========================================================================== */ + +const zlib = require('zlib'); + +/** CRC32 查表 (标准多项式 0xEDB88320) */ +const CRC_TABLE = (() => { + const table = new Int32Array(256); + for (let n = 0; n < 256; n++) { + let c = n; + for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + table[n] = c; + } + return table; +})(); + +function crc32(buf) { + let c = -1; + for (let i = 0; i < buf.length; i++) c = (c >>> 8) ^ CRC_TABLE[(c ^ buf[i]) & 0xff]; + return (c ^ -1) >>> 0; +} + +/** JS Date -> DOS 日期/时间 (ZIP 格式要求, 精度到 2 秒) */ +function toDosDateTime(date) { + return { + time: ((date.getHours() & 0x1f) << 11) | ((date.getMinutes() & 0x3f) << 5) | ((date.getSeconds() / 2) & 0x1f), + date: (((date.getFullYear() - 1980) & 0x7f) << 9) | (((date.getMonth() + 1) & 0xf) << 5) | (date.getDate() & 0x1f), + }; +} + +/** 文件头 4 字节 */ +const SIG_LOCAL = 0x04034b50; +const SIG_CENTRAL = 0x02014b50; +const SIG_EOCD = 0x06054b50; +/** 通用标志位: bit11 = 文件名/注释为 UTF-8 */ +const FLAG_UTF8 = 0x0800; +/** 单文件超过 4GB 需要 ZIP64, 本工程产物远小于此, 直接拒绝而不是产出坏包 */ +const MAX_FILE_SIZE = 0xffffffff; + +/** + * 把 dirToZip 整个目录压成 zipPath, 顶层保留目录名 + * @returns {Promise<{files:number, bytes:number}>} + */ +async function zipDirectory(dirToZip, zipPath) { + const rootName = path.basename(dirToZip); + const rootAbs = path.resolve(dirToZip); + + // 收集文件与目录, 路径统一转正斜杠 + const fileEntries = []; + const dirEntries = []; + + // ⚠️ 必须 await: 不 await 的话 zip 会只写入部分条目就收尾, + // 且递归 walk 会在暂存目录被清理后继续跑并抛 ENOENT + await (async function walk(absDir, relBase) { + const items = await fsp.readdir(absDir, { withFileTypes: true }); + for (const item of items) { + const abs = path.join(absDir, item.name); + const rel = relBase ? `${relBase}/${item.name}` : item.name; + if (item.isDirectory()) { + dirEntries.push(rel); + await walk(abs, rel); + } else if (item.isFile()) { + fileEntries.push({ abs, rel }); + } + } + })(rootAbs, rootName); + + const { time, date } = toDosDateTime(new Date()); + const chunks = []; + const central = []; + let offset = 0; + + const pushLocalHeader = (nameBuf, method, crc, compSize, uncompSize) => { + // ⚠️ 必须在累加前取值: 中央目录记录的 localOffset 指向**本地文件头的起始**, + // 不是紧随其后的数据区 + const localOffset = offset; + const h = Buffer.alloc(30); + h.writeUInt32LE(SIG_LOCAL, 0); + h.writeUInt16LE(20, 4); // version needed + h.writeUInt16LE(FLAG_UTF8, 6); // 标志位: UTF-8 文件名 + h.writeUInt16LE(method, 8); + h.writeUInt16LE(time, 10); + h.writeUInt16LE(date, 12); + h.writeUInt32LE(crc, 14); + h.writeUInt32LE(compSize, 18); + h.writeUInt32LE(uncompSize, 22); + h.writeUInt16LE(nameBuf.length, 26); + h.writeUInt16LE(0, 28); // extra len + chunks.push(h, nameBuf); + offset += h.length + nameBuf.length; + return localOffset; + }; + + const pushCentralHeader = (nameBuf, method, crc, compSize, uncompSize, localOffset) => { + const c = Buffer.alloc(46); + c.writeUInt32LE(SIG_CENTRAL, 0); + c.writeUInt16LE(20, 4); // version made by + c.writeUInt16LE(20, 6); // version needed + c.writeUInt16LE(FLAG_UTF8, 8); + c.writeUInt16LE(method, 10); + c.writeUInt16LE(time, 12); + c.writeUInt16LE(date, 14); + c.writeUInt32LE(crc, 16); + c.writeUInt32LE(compSize, 20); + c.writeUInt32LE(uncompSize, 24); + c.writeUInt16LE(nameBuf.length, 28); + c.writeUInt16LE(0, 30); // extra len + c.writeUInt16LE(0, 32); // comment len + c.writeUInt16LE(0, 34); // disk number + c.writeUInt16LE(0, 36); // internal attrs + c.writeUInt32LE(0, 38); // external attrs + c.writeUInt32LE(localOffset, 42); + central.push(c, nameBuf); + }; + + // 目录条目 (以 / 结尾), 保证空目录也能保留 + for (const rel of dirEntries) { + const nameBuf = Buffer.from(`${rel}/`, 'utf8'); + const localOffset = pushLocalHeader(nameBuf, 0, 0, 0, 0); + pushCentralHeader(nameBuf, 0, 0, 0, 0, localOffset); + } + + let totalBytes = 0; + for (const f of fileEntries) { + const data = await fsp.readFile(f.abs); + if (data.length > MAX_FILE_SIZE) { + throw new Error(`文件超过 4GB, 需要 ZIP64 支持: ${f.rel} (${data.length} 字节)`); + } + const crc = crc32(data); + // 先试 deflate, 若压缩后反而更大则退回 stored + const deflated = zlib.deflateRawSync(data, { level: 9 }); + const useDeflate = deflated.length < data.length; + const payload = useDeflate ? deflated : data; + const method = useDeflate ? 8 : 0; + + const nameBuf = Buffer.from(f.rel, 'utf8'); + const localOffset = pushLocalHeader(nameBuf, method, crc, payload.length, data.length); + chunks.push(payload); + // ⚠️ 必须把文件数据长度计入 offset, 否则 EOCD 里的中央目录偏移会偏小, + // 解压工具会报 "N extra bytes at beginning or within zipfile" + offset += payload.length; + pushCentralHeader(nameBuf, method, crc, payload.length, data.length, localOffset); + totalBytes += data.length; + } + + // EOCD + const centralBuf = Buffer.concat(central); + const eocd = Buffer.alloc(22); + eocd.writeUInt32LE(SIG_EOCD, 0); + eocd.writeUInt16LE(0, 4); + eocd.writeUInt16LE(0, 6); + eocd.writeUInt16LE(fileEntries.length + dirEntries.length, 8); + eocd.writeUInt16LE(fileEntries.length + dirEntries.length, 10); + eocd.writeUInt32LE(centralBuf.length, 12); + eocd.writeUInt32LE(offset, 16); + eocd.writeUInt16LE(0, 20); + + await fsp.writeFile(zipPath, Buffer.concat([...chunks, centralBuf, eocd])); + return { files: fileEntries.length, dirs: dirEntries.length, bytes: totalBytes }; +} + +function humanSize(bytes) { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`; + return `${(bytes / 1024 / 1024).toFixed(2)} MiB`; +} + +/** 校验暂存目录里构建必需的骨架文件都在 */ +async function verifySkeleton(dir) { + const required = [ + 'AppScope/app.json5', + 'build-profile.json5', + 'oh-package.json5', + 'hvigor/hvigor-config.json5', + 'entry/src/main/module.json5', + 'entry/oh-package.json5', + 'ascf/ascf_src/app.js', + ]; + const missing = []; + for (const rel of required) { + if (!fs.existsSync(path.join(dir, rel))) missing.push(rel); + } + return missing; +} + +async function main() { + const opts = parseArgs(process.argv.slice(2)); + const repoRoot = path.join(__dirname, '..'); + const srcRoot = path.join(repoRoot, 'stub-app-projects', PROJECT_DIR_NAME); + const outDir = opts.outDir || path.join(repoRoot, DEFAULT_OUT_DIR); + + if (!fs.existsSync(srcRoot)) { + console.error(`❌ 源目录不存在: ${srcRoot}`); + process.exitCode = 1; + return; + } + + console.log('📦 打包鸿蒙工程 (仅读取源目录, 不做任何修改)'); + console.log(` 源目录: ${path.relative(repoRoot, srcRoot)}`); + console.log(` 含证书: ${opts.withCerts ? '是 ⚠️ 包含私钥' : '否'}`); + console.log(` 含 node_modules: ${opts.withNodeModules ? '是 (体积大)' : '否 (由 DevEco 自动拉取)'}`); + + // 暂存目录: 与源目录物理隔离, 保证源目录零改动 + const stageParent = path.join(outDir, '.stage'); + const stageDir = path.join(stageParent, PROJECT_DIR_NAME); + await fsp.rm(stageDir, { recursive: true, force: true }); + await fsp.mkdir(stageDir, { recursive: true }); + + const t0 = Date.now(); + await copyDir(srcRoot, stageDir, opts); + console.log(`\n 复制完成: ${stats.files} 个文件 / ${humanSize(stats.bytes)} (跳过 ${stats.skipped} 项, ${Date.now() - t0}ms)`); + + // 写入说明文件 + const readme = [ + '# 鸿蒙元服务工程包', + '', + `导出时间: ${new Date().toLocaleString('zh-CN')}`, + `包含签名证书: ${opts.withCerts ? '是 (cert-profile/, 含私钥, 请妥善保管)' : '否'}`, + '', + '## 在目标电脑上编译', + '', + '1. 安装 DevEco Studio 5.0.x (本工程 compatibleSdkVersion 5.0.0(12) / targetSdkVersion 5.0.0(12))', + '2. 解压本 zip, 得到 mp-harmony/ 目录', + '3. 用 DevEco Studio 「打开」该 mp-harmony 目录 (不是其父目录)', + '4. 首次打开会自动执行依赖同步 (ohpm install 拉取 hvigor/node_modules), 需联网', + ` ${opts.withNodeModules ? '' : ' 若目标机离线, 请在源机器用 --with-node-modules 重新导出\n'}`, + '5. 菜单「Build」→「Build App(s)/Project(s)」, 等价于:', + ' hvigorw --mode project -p product=default assembleApp', + '6. 产物: build/outputs/default/mp-harmony-default-signed.app', + '7. 上传 .app 到 AppGallery Connect (元服务上传的是 .app, 不是 .hap)', + '', + '## 注意事项', + '', + '- **ascf/ascf_src 是导出时刻的快照**, 内含 uniapp 编译产物 (页面/组件代码)。', + ' 若前端代码有更新, 需回到源机器重新执行本脚本导出, 否则编译出的包内容是旧的。', + opts.withCerts + ? '- **本包含签名私钥** (cert-profile/qq/qq.p12)。请仅在可信渠道传递, 用完及时销毁或归档。' + : '- **本包不含签名证书**。缺证书 hvigor 无法签名, 产不出可上传的 .app。\n 请把源机器的 cert-profile/ 目录放到 mp-harmony/ 下再编译。', + '- hvigor/node_modules 未包含时由 DevEco 自动拉取; 联网失败会导致依赖同步失败。', + '', + ].join('\n'); + // 文件名用 ASCII: 虽然本脚本写入的条目名是标准 UTF-8 且已置 bit11 标志位, + // 但个别解压工具 (如 Windows 上的 MSYS unzip) 不认该标志位, 中文名会落盘成乱码。 + // 说明文档正文保持中文, 文件名用 README.txt 兼容所有工具。 + await fsp.writeFile(path.join(stageDir, 'README.txt'), readme, 'utf8'); + + // 校验骨架完整性 + const missing = await verifySkeleton(stageDir); + if (missing.length > 0) { + console.error('\n❌ 导出包缺少构建必需文件:'); + missing.forEach((m) => console.error(` - ${m}`)); + console.error(' 请检查源目录是否完整 (是否执行过 git clean?)'); + process.exitCode = 1; + return; + } + if (!opts.withCerts && fs.existsSync(path.join(stageDir, CERT_DIR))) { + console.error('❌ --no-certs 生效异常: 暂存目录仍存在 cert-profile'); + process.exitCode = 1; + return; + } + const certNote = opts.withCerts ? ' / 证书' : ''; + console.log(` 骨架校验通过 (AppScope / build-profile / entry / ascf_src${certNote})`); + + // 压缩 + await fsp.mkdir(outDir, { recursive: true }); + const now = new Date(); + const ts = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}-${String(now.getHours()).padStart(2, '0')}${String(now.getMinutes()).padStart(2, '0')}`; + let commitId = 'unknown'; + try { + const { stdout } = await execAsync('git rev-parse --short HEAD', { cwd: repoRoot }); + commitId = stdout.trim(); + } catch (e) { /* 非 git 环境忽略 */ } + + const zipPath = path.join(outDir, `${PROJECT_DIR_NAME}-project-${opts.withCerts ? '' : 'nocert-'}v${ts}-${commitId}.zip`); + const zres = await zipDirectory(stageDir, zipPath); + + const zipSize = (await fsp.stat(zipPath)).size; + // 输出目录可能在仓库外 (如 --out /tmp/xxx), 这时相对路径会变成 ../.. 很难读 + const relZip = path.relative(repoRoot, zipPath); + const shownZip = relZip.startsWith('..') ? zipPath : relZip; + console.log(`\n✅ 完成`); + console.log(` ZIP: ${shownZip}`); + console.log(` 大小: ${humanSize(zipSize)} (原始 ${humanSize(stats.bytes)})`); + console.log(` 条目: ${zres.files} 个文件 + ${zres.dirs} 个目录, 条目名统一正斜杠 + UTF-8`); + + // 清暂存目录 + await fsp.rm(stageParent, { recursive: true, force: true }); + + if (opts.withCerts) { + console.log('\n⚠️ 本包含签名私钥 (cert-profile/qq/qq.p12), 请仅在可信渠道传递。'); + } +} + +main().catch((err) => { + console.error('❌ 打包失败:', err.message); + process.exitCode = 1; +}); From 336b4d21da52f14348eb4d20a16bf316cb7ebc8a Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Sat, 17 Oct 2026 14:42:28 +0800 Subject: [PATCH 8/9] =?UTF-8?q?test=EF=BC=9A=E9=B8=BF=E8=92=99=E5=B7=A5?= =?UTF-8?q?=E7=A8=8B=E5=8C=85=E5=AF=BC=E5=87=BA=E5=A2=9E=E5=8A=A0=E4=BA=A7?= =?UTF-8?q?=E5=87=BA=E8=87=AA=E6=A3=80=E4=B8=8E=E5=8D=95=E5=85=83=E6=B5=8B?= =?UTF-8?q?=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 手写 ZIP 编码器的典型 bug 都是静默的 —— 包能生成、看着正常, 但某些机器上 解不开或解出畸形路径。开发期已真实踩过两次(中央目录偏移漏算文件数据长度、 localOffset 记成数据区起点), 两次都只有靠 unzip -t 才发现, 因此加两层保障。 一、脚本内产出自检 (每次导出都跑) 新增 verifyZipFile(), 把写好的 zip 重新解析一遍, 任一项不符直接抛错、不输出 「完成」。刻意用独立解析实现, 不复用写入端逻辑, 避免自证其说: - EOCD / 中央目录: 签名、条目数、偏移与长度边界一致 - localOffset: 每条必须真的指向本地文件头, 且文件名与中央目录一致 - CRC: 逐条解压后重算 CRC32 比对 - 条目名: 必须正斜杠、必须置 UTF-8 标志位、必须可逆解码为合法 UTF-8 - 内容断言: 必需文件齐全; node_modules/build/.preview 等排除项不得混入 二、单元测试 tests/pack-harmony-project.test.js (16 条) 正向用例写夹具目录打包再独立解析还原, 校验内容/目录/中文名/零字节/空目录/ 可压缩与不可压缩内容; 反向用例故意篡改 zip 断言自检能抓到 —— 反向用例一旦 失效自检就形同虚设, 故必须保持通过。 过程中自检抓到自身一条误判: 排除校验原先用子串匹配, 把合法源码 entry/src/ohosTest/ets/test/Ability.test.ets 当成 .test 构建产物。已改为按 路径分段精确匹配, 并加了一条反向用例锁定。 同时修掉两个隐患: stats 是模块级累加器(同进程重复调用会翻倍), main() 原先 无条件执行(require 脚本就会跑一遍打包), 现加 require.main 守卫并导出内部 实现供测试使用。 已验证: 本测试文件 16/16 通过, npm test 全量 17 个测试文件通过, 实打包 2.99MiB 包自检通过且 unzip -t 交叉验证无错误。 --- readme.md | 18 ++ scripts/pack-harmony-project.js | 170 ++++++++++++- tests/pack-harmony-project.test.js | 378 +++++++++++++++++++++++++++++ 3 files changed, 560 insertions(+), 6 deletions(-) create mode 100644 tests/pack-harmony-project.test.js diff --git a/readme.md b/readme.md index b45dda7..87f3aeb 100644 --- a/readme.md +++ b/readme.md @@ -189,6 +189,24 @@ npm run pack:harmony 📌 zip 由脚本内纯 Node 实现(`zlib` + 手写 ZIP 头)生成,条目名统一正斜杠并置 UTF-8 标志位,可跨 Windows/macOS/Linux 使用。 +**产出后自检(每次导出都会跑)** + +脚本写完 zip 后会把文件**重新解析一遍**,任何一项不符直接判失败、不输出「完成」: + +| 校验项 | 内容 | +|---|---| +| EOCD / 中央目录 | 签名、条目数、中央目录偏移与长度边界一致 | +| `localOffset` | 每条都必须真的指向本地文件头,且文件名与中央目录一致 | +| CRC | **逐条**解压后重算 CRC32 比对 | +| 条目名 | 必须正斜杠、必须置 UTF-8 标志位、必须可逆解码为合法 UTF-8 | +| 内容断言 | 构建必需文件齐全;`node_modules`/`build`/`.preview` 等排除项不得混入 | + +```bash +npm test # 跑全量测试, 含 pack-harmony-project.test.js 的 16 条用例 +``` + +> 📌 测试分**正向**(写出来能还原)与**反向**(故意篡改 zip,断言自检能抓到)两类。反向用例锁定了开发期真实踩过的三个 bug:中央目录偏移漏算文件数据长度、`localOffset` 记成数据区起点、`.test` 子串匹配误伤 `entry/src/ohosTest/ets/test/` 源码。**反向用例如果失效,自检就形同虚设**,改动脚本时务必保持它们通过。 + #### 4.3.4 【常见错误】 **❌ `hvigor ERROR: 00303218 Configuration Error`** diff --git a/scripts/pack-harmony-project.js b/scripts/pack-harmony-project.js index d3434ed..6e4670b 100644 --- a/scripts/pack-harmony-project.js +++ b/scripts/pack-harmony-project.js @@ -312,6 +312,141 @@ function humanSize(bytes) { return `${(bytes / 1024 / 1024).toFixed(2)} MiB`; } +/* ========================================================================== + * 产出后自检 + * + * 手写 ZIP 编码器最容易出的两类 bug 都是**静默**的: + * - 偏移算错 -> 文件内容对但部分条目解不出来 (不校验就发现不了) + * - 标志位/分隔符写错 -> Windows 能解开, macOS/Linux 解出畸形路径 + * 所以每次导出后都把 zip 重新解析一遍, 任何一项不符就判失败, 不输出「完成」。 + * + * 刻意不复用写入端的逻辑: 这里用独立的解析实现, 避免自证其说。 + * ========================================================================== */ + +/** + * @param {string} zipPath + * @param {{withCerts?: boolean, skipRequired?: boolean}} opts + * skipRequired: 仅供单元测试用夹具目录时跳过"必需文件"断言 + * @returns {Promise<{entries:number, files:number, dirs:number}>} + * @throws {Error} 任一项校验不通过 + */ +async function verifyZipFile(zipPath, opts = {}) { + const buf = await fsp.readFile(zipPath); + const fail = (msg) => { throw new Error(`zip 自检失败: ${msg}`); }; + + // --- 定位并校验 EOCD (从尾部往前扫, 兼容可能存在的注释) --- + const EOCD_MIN = 22; + if (buf.length < EOCD_MIN) fail('文件小于 EOCD 最小长度'); + let eocd = -1; + for (let i = buf.length - EOCD_MIN; i >= 0; i--) { + if (buf.readUInt32LE(i) === SIG_EOCD) { eocd = i; break; } + } + if (eocd === -1) fail('找不到 EOCD 结尾记录'); + + const totalEntries = buf.readUInt16LE(eocd + 10); + const centralSize = buf.readUInt32LE(eocd + 12); + const centralOffset = buf.readUInt32LE(eocd + 16); + + if (centralOffset + centralSize > buf.length) { + fail(`中央目录越界 (offset=${centralOffset} size=${centralSize} 文件长=${buf.length})`); + } + + // --- 逐条遍历中央目录 --- + const names = []; + let p = centralOffset; + for (let idx = 0; idx < totalEntries; idx++) { + if (p + 46 > buf.length) fail(`第 ${idx} 条中央目录记录越界`); + if (buf.readUInt32LE(p) !== SIG_CENTRAL) fail(`第 ${idx} 条中央目录签名错误`); + + const flags = buf.readUInt16LE(p + 8); + const method = buf.readUInt16LE(p + 10); + const crc = buf.readUInt32LE(p + 16); + const compSize = buf.readUInt32LE(p + 20); + const uncompSize = buf.readUInt32LE(p + 24); + const nameLen = buf.readUInt16LE(p + 28); + const extraLen = buf.readUInt16LE(p + 30); + const commentLen = buf.readUInt16LE(p + 32); + const localOffset = buf.readUInt32LE(p + 42); + const nameBuf = buf.subarray(p + 46, p + 46 + nameLen); + + // 名称合法性: 必须正斜杠 + 合法 UTF-8 + 与 UTF-8 标志位一致 + if (!(flags & FLAG_UTF8)) fail(`第 ${idx} 条缺少 UTF-8 标志位: ${nameBuf.toString('latin1')}`); + const name = nameBuf.toString('utf8'); + if (name.includes('\\')) fail(`条目名含反斜杠 (非 ZIP 规范): ${name}`); + if (!Buffer.from(name, 'utf8').equals(nameBuf)) fail(`条目名不是合法 UTF-8: ${nameBuf.toString('hex')}`); + names.push(name); + + // 本地文件头: 偏移必须真的指向一个本地头, 且文件名与中央目录一致 + if (localOffset + 30 > buf.length) fail(`第 ${idx} 条 localOffset 越界: ${localOffset}`); + if (buf.readUInt32LE(localOffset) !== SIG_LOCAL) fail(`第 ${idx} 条 localOffset 未指向本地文件头: ${localOffset}`); + const lNameLen = buf.readUInt16LE(localOffset + 26); + const lExtraLen = buf.readUInt16LE(localOffset + 28); + const localName = buf.subarray(localOffset + 30, localOffset + 30 + lNameLen); + if (!localName.equals(nameBuf)) fail(`第 ${idx} 条本地头与中央目录文件名不一致`); + + // 数据区: 解压后长度与 CRC 必须对得上 (目录条目除外) + if (!name.endsWith('/')) { + const dataStart = localOffset + 30 + lNameLen + lExtraLen; + const raw = buf.subarray(dataStart, dataStart + compSize); + if (raw.length !== compSize) fail(`第 ${idx} 条数据区越界`); + let data; + if (method === 0) data = raw; + else if (method === 8) data = zlib.inflateRawSync(raw); + else fail(`第 ${idx} 条压缩方式不支持: ${method}`); + if (data.length !== uncompSize) fail(`第 ${idx} 条解压后长度不符 (${data.length} != ${uncompSize})`); + if (crc32(data) !== crc) fail(`第 ${idx} 条 CRC 校验失败`); + } + + p += 46 + nameLen + extraLen + commentLen; + } + + if (p - centralOffset !== centralSize) { + fail(`中央目录实际长度 ${p - centralOffset} != EOCD 记录 ${centralSize}`); + } + + // --- 内容断言 --- + if (!opts.skipRequired) { + const required = [ + 'mp-harmony/AppScope/app.json5', + 'mp-harmony/build-profile.json5', + 'mp-harmony/oh-package.json5', + 'mp-harmony/hvigor/hvigor-config.json5', + 'mp-harmony/entry/src/main/module.json5', + 'mp-harmony/entry/oh-package.json5', + 'mp-harmony/ascf/ascf_src/app.js', + ]; + if (opts.withCerts !== false) { + required.push('mp-harmony/cert-profile/qq/qq.p12', 'mp-harmony/cert-profile/qq/qqDebug.p7b'); + } + const missing = required.filter((r) => !names.includes(r)); + if (missing.length) fail(`缺少构建必需文件: ${missing.join(', ')}`); + } + + // 按**路径分段**判断, 不用子串 —— 否则 .test 会误伤 entry/src/ohosTest/ets/test/ + // 这类合法源码文件 (.test 排除的是名为 .test 的目录, 不是任何含 .test 的路径) + const dirSegmentsOf = (n) => n.split('/').slice(0, -1); + const fileNameOf = (n) => n.split('/').pop(); + const leaked = names.filter((n) => dirSegmentsOf(n).some((s) => EXCLUDE_DIRS.has(s))); + if (leaked.length) { + fail(`混入了应排除的目录: ${leaked.slice(0, 3).join(', ')}${leaked.length > 3 ? ` ...共 ${leaked.length} 条` : ''}`); + } + const leakedFiles = names.filter((n) => !n.endsWith('/') && EXCLUDE_FILES.has(fileNameOf(n))); + if (leakedFiles.length) { + fail(`混入了应排除的文件: ${leakedFiles.slice(0, 3).join(', ')}`); + } + + if (opts.withCerts === false && names.some((n) => n.includes(CERT_DIR))) { + fail('--no-certs 生效异常: zip 内仍存在 cert-profile'); + } + + const fileCount = names.filter((n) => !n.endsWith('/')).length; + return { + entries: totalEntries, + files: fileCount, + dirs: totalEntries - fileCount, + }; +} + /** 校验暂存目录里构建必需的骨架文件都在 */ async function verifySkeleton(dir) { const required = [ @@ -354,6 +489,10 @@ async function main() { await fsp.mkdir(stageDir, { recursive: true }); const t0 = Date.now(); + // stats 是模块级累加器, 同一进程内重复调用 main 时需先清零, 否则体积/数量会翻倍 + stats.files = 0; + stats.bytes = 0; + stats.skipped = 0; await copyDir(srcRoot, stageDir, opts); console.log(`\n 复制完成: ${stats.files} 个文件 / ${humanSize(stats.bytes)} (跳过 ${stats.skipped} 项, ${Date.now() - t0}ms)`); @@ -419,7 +558,10 @@ async function main() { } catch (e) { /* 非 git 环境忽略 */ } const zipPath = path.join(outDir, `${PROJECT_DIR_NAME}-project-${opts.withCerts ? '' : 'nocert-'}v${ts}-${commitId}.zip`); - const zres = await zipDirectory(stageDir, zipPath); + await zipDirectory(stageDir, zipPath); + + // 自检: 解析回 zip 逐项校验, 不通过就抛错 (绝不打印「完成」) + const vres = await verifyZipFile(zipPath, { withCerts: opts.withCerts }); const zipSize = (await fsp.stat(zipPath)).size; // 输出目录可能在仓库外 (如 --out /tmp/xxx), 这时相对路径会变成 ../.. 很难读 @@ -428,7 +570,8 @@ async function main() { console.log(`\n✅ 完成`); console.log(` ZIP: ${shownZip}`); console.log(` 大小: ${humanSize(zipSize)} (原始 ${humanSize(stats.bytes)})`); - console.log(` 条目: ${zres.files} 个文件 + ${zres.dirs} 个目录, 条目名统一正斜杠 + UTF-8`); + console.log(` 条目: ${vres.files} 个文件 + ${vres.entries - vres.files} 个目录 (自检通过)`); + console.log(` 自检: CRC 逐条校验 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅`); // 清暂存目录 await fsp.rm(stageParent, { recursive: true, force: true }); @@ -438,7 +581,22 @@ async function main() { } } -main().catch((err) => { - console.error('❌ 打包失败:', err.message); - process.exitCode = 1; -}); +// 导出内部实现供单元测试使用; 作为命令行执行时才跑 main() +module.exports = { + crc32, + toDosDateTime, + zipDirectory, + verifyZipFile, + shouldSkipDir, + shouldSkipFile, + copyDir, + EXCLUDE_DIRS, + EXCLUDE_FILES, +}; + +if (require.main === module) { + main().catch((err) => { + console.error('❌ 打包失败:', err.message); + process.exitCode = 1; + }); +} diff --git a/tests/pack-harmony-project.test.js b/tests/pack-harmony-project.test.js new file mode 100644 index 0000000..551d7e8 --- /dev/null +++ b/tests/pack-harmony-project.test.js @@ -0,0 +1,378 @@ +/** + * scripts/pack-harmony-project.js 单元测试 + * + * 跑法: + * node --test tests/pack-harmony-project.test.js + * + * 为什么这个脚本需要测试: + * 它**手写了 ZIP 编码格式**(没有用系统命令 / 第三方库), 而这类代码的典型 bug + * 都是静默的 —— 包能生成、看着正常, 但某些机器上解不开或解出畸形路径。 + * 历史上已经踩过两次: + * 1. 漏算文件数据长度, 中央目录偏移偏小 -> "N extra bytes at beginning" + * 2. localOffset 记成了数据区起点而非本地头起点 -> "bad zipfile offset" + * 两者都不会抛异常, 只有真正解压/解析才会暴露。 + * + * 所以测试分两类: + * - 正向: 写出来的 zip 结构合法、内容可还原 + * - **反向**: 故意损坏 zip, 断言 verifyZipFile 真的能抓到 + * (否则自检可能形同虚设, 永远返回 true) + */ + +'use strict'; + +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const fsp = require('node:fs/promises'); +const os = require('node:os'); +const path = require('node:path'); +const zlib = require('node:zlib'); + +const { + crc32, + toDosDateTime, + zipDirectory, + verifyZipFile, + shouldSkipDir, + shouldSkipFile, + EXCLUDE_DIRS, + EXCLUDE_FILES, +} = require('../scripts/pack-harmony-project'); + +/** 建临时目录, 测试结束自动清理 */ +function tmpDir(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'pack-harmony-test-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +/** 建一个夹具目录, 内含各种体积/编码的文件 */ +function makeFixture(root) { + fs.mkdirSync(path.join(root, 'AppScope'), { recursive: true }); + fs.mkdirSync(path.join(root, 'a/b/c'), { recursive: true }); + fs.mkdirSync(path.join(root, 'empty-dir'), { recursive: true }); + fs.writeFileSync(path.join(root, 'AppScope/app.json5'), '{"app":{"bundleName":"x"}}'); + fs.writeFileSync(path.join(root, 'a/b/c/deep.txt'), 'deep content'); + // 可压缩内容 (应走 deflate) + fs.writeFileSync(path.join(root, 'a/repeatable.txt'), 'abc'.repeat(2000)); + // 不可压缩内容 (应退回 stored) + fs.writeFileSync(path.join(root, 'a/random.bin'), require('node:crypto').randomBytes(4096)); + // 中文文件名 + fs.writeFileSync(path.join(root, 'a/中文名.txt'), '中文内容'); + // 零字节文件 + fs.writeFileSync(path.join(root, 'a/zero.txt'), ''); + return root; +} + +/* ------------------------------------------------------------------------- + * 独立 zip 读取器 (刻意不复用写入端实现, 避免自证其说) + * 只实现测试需要的最小功能: 按条目名取回内容 + * ------------------------------------------------------------------------- */ +function readZipEntries(zipPath) { + const buf = fs.readFileSync(zipPath); + let eocd = -1; + for (let i = buf.length - 22; i >= 0; i--) { + if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } + } + assert.ok(eocd !== -1, '应能找到 EOCD'); + + const count = buf.readUInt16LE(eocd + 10); + let p = buf.readUInt32LE(eocd + 16); + const out = new Map(); + + for (let i = 0; i < count; i++) { + assert.equal(buf.readUInt32LE(p), 0x02014b50, `第 ${i} 条中央目录签名应正确`); + const method = buf.readUInt16LE(p + 10); + const crc = buf.readUInt32LE(p + 16); + const compSize = buf.readUInt32LE(p + 20); + const uncompSize = buf.readUInt32LE(p + 24); + const nameLen = buf.readUInt16LE(p + 28); + const extraLen = buf.readUInt16LE(p + 30); + const commentLen = buf.readUInt16LE(p + 32); + const localOffset = buf.readUInt32LE(p + 42); + const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8'); + + if (!name.endsWith('/')) { + const lNameLen = buf.readUInt16LE(localOffset + 26); + const lExtraLen = buf.readUInt16LE(localOffset + 28); + const start = localOffset + 30 + lNameLen + lExtraLen; + const raw = buf.subarray(start, start + compSize); + const data = method === 0 ? raw : zlib.inflateRawSync(raw); + assert.equal(data.length, uncompSize, `${name} 解压长度应匹配`); + assert.equal(crc32(data), crc, `${name} CRC 应匹配`); + out.set(name, data); + } else { + out.set(name, null); // 目录条目 + } + p += 46 + nameLen + extraLen + commentLen; + } + return out; +} + +/* ============================ 纯函数 ============================ */ + +test('crc32: 标准校验向量', () => { + // CRC-32/ISO-HDLC 的标准 check value + assert.equal(crc32(Buffer.from('123456789')), 0xcbf43926); + assert.equal(crc32(Buffer.from('')), 0); + assert.equal(crc32(Buffer.from('a')), 0xe8b7be43); +}); + +test('toDosDateTime: 字段不溢出且能反解', () => { + const d = new Date(2026, 9, 17, 14, 30, 20); + const { time, date } = toDosDateTime(d); + assert.equal((date >> 9) & 0x7f, 2026 - 1980, '年份'); + assert.equal((date >> 5) & 0xf, 10, '月份'); + assert.equal(date & 0x1f, 17, '日'); + assert.equal((time >> 11) & 0x1f, 14, '时'); + assert.equal((time >> 5) & 0x3f, 30, '分'); +}); + +/* ======================= 排除规则 (回归锁定) ======================= */ + +test('shouldSkipDir: node_modules/oh_modules/build 等一律排除', () => { + const opts = { withNodeModules: false, withCerts: true }; + for (const d of ['node_modules', 'oh_modules', 'build', '.preview', '.idea', '.hvigor', '.cxx', '.test', '.git']) { + assert.equal(shouldSkipDir(d, opts, false), true, `${d} 应被排除`); + } +}); + +test('shouldSkipDir: --with-node-modules 只放开根级 node_modules', () => { + const opts = { withNodeModules: true, withCerts: true }; + assert.equal(shouldSkipDir('node_modules', opts, true), false, '根级 node_modules 应保留'); + assert.equal(shouldSkipDir('node_modules', opts, false), true, '非根级 node_modules 仍应排除'); + assert.equal(shouldSkipDir('oh_modules', opts, true), true, 'oh_modules 始终排除'); +}); + +test('shouldSkipDir: cert-profile 受 --no-certs 控制', () => { + assert.equal(shouldSkipDir('cert-profile', { withCerts: true }, false), false); + assert.equal(shouldSkipDir('cert-profile', { withCerts: false }, false), true); +}); + +test('shouldSkipFile: local.properties / 编辑器临时文件排除', () => { + for (const f of ['local.properties', '.DS_Store', 'Thumbs.db', '_1.clang-format', '_1.gitignore', 'build.log']) { + assert.equal(shouldSkipFile(f), true, `${f} 应被排除`); + } + // 回归锁定: 这些是**源码**, 不能误伤 + for (const f of ['module.json5', 'app.json', 'Ability.test.ets', 'List.test.js', 'oh-package.json5']) { + assert.equal(shouldSkipFile(f), false, `${f} 不应被排除`); + } +}); + +/* ========================= zip 写入正确性 ========================= */ + +test('zipDirectory: 往返后内容与目录结构完全一致', async (t) => { + const root = tmpDir(t); + const fixture = makeFixture(path.join(root, 'mp-harmony')); + const zipPath = path.join(root, 'out.zip'); + + const res = await zipDirectory(fixture, zipPath); + assert.equal(res.files, 6, '夹具应有 6 个文件 (app.json5/deep.txt/repeatable.txt/random.bin/中文名.txt/zero.txt)'); + + const entries = readZipEntries(zipPath); + const prefix = 'mp-harmony/'; + const get = (rel) => entries.get(prefix + rel); + + assert.equal(get('AppScope/app.json5').toString(), '{"app":{"bundleName":"x"}}'); + assert.equal(get('a/b/c/deep.txt').toString(), 'deep content'); + assert.equal(get('a/repeatable.txt').toString(), 'abc'.repeat(2000)); + assert.equal(get('a/中文名.txt').toString(), '中文内容'); + assert.equal(get('a/zero.txt').length, 0, '零字节文件应存在且为空'); + assert.ok(get('a/random.bin').equals(fs.readFileSync(path.join(fixture, 'a/random.bin')))); + + // 目录条目: 空目录也要保留 + assert.ok(entries.has(prefix + 'empty-dir/'), '空目录条目应保留'); + assert.ok(entries.has(prefix + 'a/b/c/'), '嵌套目录条目应保留'); +}); + +test('zipDirectory: 条目名全部用正斜杠且置 UTF-8 标志位', async (t) => { + const root = tmpDir(t); + const fixture = makeFixture(path.join(root, 'mp-harmony')); + const zipPath = path.join(root, 'out.zip'); + await zipDirectory(fixture, zipPath); + + const buf = fs.readFileSync(zipPath); + let eocd = -1; + for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } + const count = buf.readUInt16LE(eocd + 10); + let p = buf.readUInt32LE(eocd + 16); + + for (let i = 0; i < count; i++) { + const flags = buf.readUInt16LE(p + 8); + const nameLen = buf.readUInt16LE(p + 28); + const extraLen = buf.readUInt16LE(p + 30); + const commentLen = buf.readUInt16LE(p + 32); + const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8'); + + assert.ok(flags & 0x0800, `第 ${i} 条 ${name} 应置 UTF-8 标志位`); + assert.ok(!name.includes('\\'), `第 ${i} 条 ${name} 不应含反斜杠`); + assert.ok(!name.startsWith('/'), `第 ${i} 条 ${name} 不应是绝对路径`); + assert.ok(!name.includes('//'), `第 ${i} 条 ${name} 不应有连续斜杠`); + p += 46 + nameLen + extraLen + commentLen; + } +}); + +test('verifyZipFile: 合法的包应通过', async (t) => { + const root = tmpDir(t); + const zipPath = path.join(root, 'out.zip'); + await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath); + const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true }); + assert.ok(res.files > 0); + assert.equal(res.entries, res.files + res.dirs); +}); + +/* ==================== 反向测试: 自检必须能抓到损坏 ==================== */ + +test('verifyZipFile: 数据区被篡改应报 CRC 错误', async (t) => { + const root = tmpDir(t); + const zipPath = path.join(root, 'out.zip'); + await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath); + + const buf = fs.readFileSync(zipPath); + // 找一个文件条目的数据区并改掉一个字节 + let eocd = -1; + for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } + let p = buf.readUInt32LE(eocd + 16); + const count = buf.readUInt16LE(eocd + 10); + for (let i = 0; i < count; i++) { + const compSize = buf.readUInt32LE(p + 20); + const nameLen = buf.readUInt16LE(p + 28); + const extraLen = buf.readUInt16LE(p + 30); + const commentLen = buf.readUInt16LE(p + 32); + const localOffset = buf.readUInt32LE(p + 42); + const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8'); + if (!name.endsWith('/') && compSize > 10) { + const start = localOffset + 30 + buf.readUInt16LE(localOffset + 26) + buf.readUInt16LE(localOffset + 28); + buf[start + 2] ^= 0xff; // 翻转数据区中的一个字节 + break; + } + p += 46 + nameLen + extraLen + commentLen; + } + fs.writeFileSync(zipPath, buf); + + await assert.rejects( + () => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }), + /CRC|解压/, + '数据被篡改时 verifyZipFile 必须报错', + ); +}); + +test('verifyZipFile: central directory 偏移错误应被拒绝 (回归: 曾漏算数据长度)', async (t) => { + const root = tmpDir(t); + const zipPath = path.join(root, 'out.zip'); + await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath); + + const buf = fs.readFileSync(zipPath); + let eocd = -1; + for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } + const realOffset = buf.readUInt32LE(eocd + 16); + buf.writeUInt32LE(realOffset + 1000, eocd + 16); // 偏移挪 1000 字节 + fs.writeFileSync(zipPath, buf); + + await assert.rejects( + () => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }), + /中央目录/, + '中央目录偏移错误时 verifyZipFile 必须报错', + ); +}); + +test('verifyZipFile: localOffset 指向数据区而非本地头应被拒绝 (回归: 曾记成数据起点)', async (t) => { + const root = tmpDir(t); + const zipPath = path.join(root, 'out.zip'); + await zipDirectory(makeFixture(path.join(root, 'mp-harmony')), zipPath); + + const buf = fs.readFileSync(zipPath); + let eocd = -1; + for (let i = buf.length - 22; i >= 0; i--) if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; } + let p = buf.readUInt32LE(eocd + 16); + // 把第一条**文件**记录的 localOffset 加 30 (= 跳到数据区) + for (let i = 0; i < buf.readUInt16LE(eocd + 10); i++) { + const nameLen = buf.readUInt16LE(p + 28); + const extraLen = buf.readUInt16LE(p + 30); + const commentLen = buf.readUInt16LE(p + 32); + const name = buf.subarray(p + 46, p + 46 + nameLen).toString('utf8'); + if (!name.endsWith('/')) { + buf.writeUInt32LE(buf.readUInt32LE(p + 42) + 30, p + 42); + break; + } + p += 46 + nameLen + extraLen + commentLen; + } + fs.writeFileSync(zipPath, buf); + + await assert.rejects( + () => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }), + /localOffset|中央目录签名/, + 'localOffset 错误时 verifyZipFile 必须报错', + ); +}); + +test('verifyZipFile: 混入应排除目录应被拒绝 (回归: 曾用子串匹配误伤 ohosTest/ets/test/)', async (t) => { + const root = tmpDir(t); + const fixture = makeFixture(path.join(root, 'mp-harmony')); + // 手工塞一个不该存在的目录 + fs.mkdirSync(path.join(fixture, 'oh_modules/foo'), { recursive: true }); + fs.writeFileSync(path.join(fixture, 'oh_modules/foo/index.js'), 'x'); + const zipPath = path.join(root, 'out.zip'); + await zipDirectory(fixture, zipPath); + + await assert.rejects( + () => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }), + /应排除/, + '混入 oh_modules 时 verifyZipFile 必须报错', + ); +}); + +test('verifyZipFile: 合法源码文件不应被误判为应排除 (回归锁定)', async (t) => { + const root = tmpDir(t); + const fixture = makeFixture(path.join(root, 'mp-harmony')); + // 这些路径含 .test / test 字样, 但是**源码**, 必须放行 + fs.mkdirSync(path.join(fixture, 'entry/src/ohosTest/ets/test'), { recursive: true }); + fs.writeFileSync(path.join(fixture, 'entry/src/ohosTest/ets/test/Ability.test.ets'), 'x'); + fs.mkdirSync(path.join(fixture, 'entry/src/test'), { recursive: true }); + fs.writeFileSync(path.join(fixture, 'entry/src/test/List.test.ets'), 'x'); + const zipPath = path.join(root, 'out.zip'); + await zipDirectory(fixture, zipPath); + + // 不应抛错 (夹具缺必需文件, 用 skipRequired 跳过该断言) + const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true }); + assert.ok(res.files > 0); +}); + +test('verifyZipFile: --no-certs 时出现 cert-profile 应被拒绝', async (t) => { + const root = tmpDir(t); + const fixture = makeFixture(path.join(root, 'mp-harmony')); + fs.mkdirSync(path.join(fixture, 'cert-profile/qq'), { recursive: true }); + fs.writeFileSync(path.join(fixture, 'cert-profile/qq/qq.p12'), 'FAKE'); + const zipPath = path.join(root, 'out.zip'); + await zipDirectory(fixture, zipPath); + + await assert.rejects( + () => verifyZipFile(zipPath, { withCerts: false, skipRequired: true }), + /nocerts|cert-profile/i, + '--no-certs 下混入证书必须报错', + ); +}); + +/* ============================ 导出骨架 ============================ */ + +test('copyDir: 排除规则在真实复制中生效', async (t) => { + const root = tmpDir(t); + const src = path.join(root, 'src'); + const dest = path.join(root, 'dest'); + + fs.mkdirSync(path.join(src, 'keep'), { recursive: true }); + fs.mkdirSync(path.join(src, 'node_modules/pkg'), { recursive: true }); + fs.mkdirSync(path.join(src, 'build'), { recursive: true }); + fs.writeFileSync(path.join(src, 'keep/a.txt'), 'a'); + fs.writeFileSync(path.join(src, 'node_modules/pkg/i.js'), 'i'); + fs.writeFileSync(path.join(src, 'build/out.txt'), 'o'); + fs.writeFileSync(path.join(src, 'local.properties'), 'sdk.dir=x'); + + const { copyDir } = require('../scripts/pack-harmony-project'); + await copyDir(src, dest, { withNodeModules: false, withCerts: true }); + + assert.ok(fs.existsSync(path.join(dest, 'keep/a.txt')), '正常文件应保留'); + assert.ok(!fs.existsSync(path.join(dest, 'node_modules')), 'node_modules 应排除'); + assert.ok(!fs.existsSync(path.join(dest, 'build')), 'build 应排除'); + assert.ok(!fs.existsSync(path.join(dest, 'local.properties')), 'local.properties 应排除'); +}); From 7909cf2b225e54f3f1629e89d5a4745868735c05 Mon Sep 17 00:00:00 2001 From: ZF sun <34314687@qq.com> Date: Sat, 17 Oct 2026 16:13:09 +0800 Subject: [PATCH 9/9] =?UTF-8?q?test=EF=BC=9A=E5=AF=BC=E5=87=BA=E8=84=9A?= =?UTF-8?q?=E6=9C=AC=E5=A2=9E=E5=8A=A0=E9=9B=B6=E6=BC=8F=E6=8B=B7/?= =?UTF-8?q?=E9=9B=B6=E5=A4=9A=E6=8B=B7=E7=A1=AC=E4=BF=9D=E8=AF=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 质疑"某个目录有没有被打进 zip"时, 原先只能人工逐目录核对。现在把这个检查做进 脚本, 每次导出都跑: copyDir 记录每个被复制文件在 zip 中应有的条目名, verifyZipFile 把它与实际 zip 条目做**逐个双向比对** —— 少一个报"漏拷"并列出文件名, 多一个报"多拷"。这样 漏拷会当场失败, 而不是等在别的电脑上解压缩时才发现。 自检输出新增一行, 例如: 自检: 零漏拷 ✅(1381/1381) / CRC 逐条 ✅ / 偏移一致 ✅ / ... 同时把 copyDir 的条目名拼接由"从 nameOfParent 反推"改为显式传 relBase, 去掉 原先那段绕的 split/map 逻辑。 新增 3 条测试(漏拷拒绝 / 多拷拒绝 / 清单吻合时放行且 README.txt 豁免), 本 测试文件 19/19 通过, npm test 全量 17 个测试文件通过。 --- readme.md | 14 ++++++- scripts/pack-harmony-project.js | 49 +++++++++++++++++++--- tests/pack-harmony-project.test.js | 65 ++++++++++++++++++++++++++++++ 3 files changed, 121 insertions(+), 7 deletions(-) diff --git a/readme.md b/readme.md index 87f3aeb..0956a46 100644 --- a/readme.md +++ b/readme.md @@ -195,6 +195,7 @@ npm run pack:harmony | 校验项 | 内容 | |---|---| +| **零漏拷 / 零多拷** | 把本次实际复制的文件清单与 zip 条目**逐个双向比对**,有任何文件没打进去、或出现清单外的文件,当场失败 | | EOCD / 中央目录 | 签名、条目数、中央目录偏移与长度边界一致 | | `localOffset` | 每条都必须真的指向本地文件头,且文件名与中央目录一致 | | CRC | **逐条**解压后重算 CRC32 比对 | @@ -202,9 +203,20 @@ npm run pack:harmony | 内容断言 | 构建必需文件齐全;`node_modules`/`build`/`.preview` 等排除项不得混入 | ```bash -npm test # 跑全量测试, 含 pack-harmony-project.test.js 的 16 条用例 +npm test # 跑全量测试, 含 pack-harmony-project.test.js 的 19 条用例 ``` +正常输出示例: + +``` +条目: 1382 个文件 + 327 个目录 (自检通过) +自检: 零漏拷 ✅(1381/1381) / CRC 逐条 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅ +``` + +> 📌 **不必再人工核对"某个目录有没有打进去"**——上面的「零漏拷」会逐文件双向比对并打印数量。怀疑漏拷时看这一行即可;若真的漏了,脚本会直接失败并列出文件名。 +> +> 📌 `EntryCard/` 不在 `build-profile.json5` 的模块列表中,**不是编译模块**,其内容不参与构建;但导出脚本按「全量复制 − 排除清单」处理,同样会把它带进 zip。 + > 📌 测试分**正向**(写出来能还原)与**反向**(故意篡改 zip,断言自检能抓到)两类。反向用例锁定了开发期真实踩过的三个 bug:中央目录偏移漏算文件数据长度、`localOffset` 记成数据区起点、`.test` 子串匹配误伤 `entry/src/ohosTest/ets/test/` 源码。**反向用例如果失效,自检就形同虚设**,改动脚本时务必保持它们通过。 #### 4.3.4 【常见错误】 diff --git a/scripts/pack-harmony-project.js b/scripts/pack-harmony-project.js index 6e4670b..3cc52a8 100644 --- a/scripts/pack-harmony-project.js +++ b/scripts/pack-harmony-project.js @@ -103,9 +103,17 @@ function shouldSkipFile(name) { return /\.(log|tmp|swp)$/.test(name); } -const stats = { files: 0, bytes: 0, skipped: 0 }; +const stats = { files: 0, bytes: 0, skipped: 0, copied: [] }; -async function copyDir(srcDir, destDir, opts, depth = 0, nameOfParent = '') { +/** + * 复制源目录到暂存目录 (应用排除规则), 同时记录每个被复制文件在 zip 中应有的条目名 + * @param {string} srcDir + * @param {string} destDir + * @param {object} opts + * @param {string} relBase 该目录在 zip 中的条目名前缀 (首层传 PROJECT_DIR_NAME) + * @param {number} depth + */ +async function copyDir(srcDir, destDir, opts, relBase = PROJECT_DIR_NAME, depth = 0) { await fsp.mkdir(destDir, { recursive: true }); const entries = await fsp.readdir(srcDir, { withFileTypes: true }); for (const entry of entries) { @@ -117,7 +125,7 @@ async function copyDir(srcDir, destDir, opts, depth = 0, nameOfParent = '') { stats.skipped++; continue; } - await copyDir(srcPath, destPath, opts, depth + 1, entry.name); + await copyDir(srcPath, destPath, opts, `${relBase}/${entry.name}`, depth + 1); } else if (entry.isFile()) { if (shouldSkipFile(entry.name)) { stats.skipped++; @@ -127,6 +135,7 @@ async function copyDir(srcDir, destDir, opts, depth = 0, nameOfParent = '') { const st = await fsp.stat(destPath); stats.files++; stats.bytes += st.size; + stats.copied.push(`${relBase}/${entry.name}`); } // 符号链接等其它类型直接跳过 } @@ -325,12 +334,15 @@ function humanSize(bytes) { /** * @param {string} zipPath - * @param {{withCerts?: boolean, skipRequired?: boolean}} opts + * @param {{withCerts?: boolean, skipRequired?: boolean, expectFiles?: string[], rootName?: string}} opts * skipRequired: 仅供单元测试用夹具目录时跳过"必需文件"断言 + * expectFiles: 实际复制到暂存目录的文件清单 (zip 内条目名), 用于零漏拷/零多拷比对 + * rootName: zip 内顶层目录名 (默认 mp-harmony) * @returns {Promise<{entries:number, files:number, dirs:number}>} * @throws {Error} 任一项校验不通过 */ async function verifyZipFile(zipPath, opts = {}) { + const rootName = opts.rootName || PROJECT_DIR_NAME; const buf = await fsp.readFile(zipPath); const fail = (msg) => { throw new Error(`zip 自检失败: ${msg}`); }; @@ -440,6 +452,25 @@ async function verifyZipFile(zipPath, opts = {}) { } const fileCount = names.filter((n) => !n.endsWith('/')).length; + + // --- 零漏拷 / 零多拷 比对 --- + // expectFiles 是 copyDir 实际复制到暂存目录的文件清单 (已应用排除规则)。 + // 逐个核对 zip 内是否都有, 且 zip 内没有清单之外的文件 (README.txt 是后补的, 除外)。 + // 这样"某个目录没被打进去"这类问题会被脚本当场拦下, 不必人工逐目录核对。 + if (Array.isArray(opts.expectFiles)) { + const nameSet = new Set(names); + const missingFiles = opts.expectFiles.filter((f) => !nameSet.has(f)); + if (missingFiles.length) { + fail(`漏拷: ${missingFiles.length} 个已复制的文件不在 zip 内, 例如 ${missingFiles.slice(0, 5).join(', ')}`); + } + const expected = new Set(opts.expectFiles); + expected.add(`${rootName}/README.txt`); + const unexpected = names.filter((n) => !n.endsWith('/') && !expected.has(n)); + if (unexpected.length) { + fail(`多拷: ${unexpected.length} 个 zip 内文件不在复制清单中, 例如 ${unexpected.slice(0, 5).join(', ')}`); + } + } + return { entries: totalEntries, files: fileCount, @@ -493,6 +524,7 @@ async function main() { stats.files = 0; stats.bytes = 0; stats.skipped = 0; + stats.copied = []; await copyDir(srcRoot, stageDir, opts); console.log(`\n 复制完成: ${stats.files} 个文件 / ${humanSize(stats.bytes)} (跳过 ${stats.skipped} 项, ${Date.now() - t0}ms)`); @@ -561,7 +593,12 @@ async function main() { await zipDirectory(stageDir, zipPath); // 自检: 解析回 zip 逐项校验, 不通过就抛错 (绝不打印「完成」) - const vres = await verifyZipFile(zipPath, { withCerts: opts.withCerts }); + // expectFiles 传入实际复制清单, 让脚本自己证明"零漏拷", 无需人工逐目录核对 + const vres = await verifyZipFile(zipPath, { + withCerts: opts.withCerts, + expectFiles: stats.copied, + rootName: PROJECT_DIR_NAME, + }); const zipSize = (await fsp.stat(zipPath)).size; // 输出目录可能在仓库外 (如 --out /tmp/xxx), 这时相对路径会变成 ../.. 很难读 @@ -571,7 +608,7 @@ async function main() { console.log(` ZIP: ${shownZip}`); console.log(` 大小: ${humanSize(zipSize)} (原始 ${humanSize(stats.bytes)})`); console.log(` 条目: ${vres.files} 个文件 + ${vres.entries - vres.files} 个目录 (自检通过)`); - console.log(` 自检: CRC 逐条校验 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅`); + console.log(` 自检: 零漏拷 ✅(${stats.copied.length}/${stats.copied.length}) / CRC 逐条 ✅ / 偏移一致 ✅ / 正斜杠 ✅ / UTF-8 ✅ / 骨架齐全 ✅`); // 清暂存目录 await fsp.rm(stageParent, { recursive: true, force: true }); diff --git a/tests/pack-harmony-project.test.js b/tests/pack-harmony-project.test.js index 551d7e8..8ef6c99 100644 --- a/tests/pack-harmony-project.test.js +++ b/tests/pack-harmony-project.test.js @@ -355,6 +355,71 @@ test('verifyZipFile: --no-certs 时出现 cert-profile 应被拒绝', async (t) /* ============================ 导出骨架 ============================ */ +test('verifyZipFile: 漏拷应被拒绝 (expectFiles 与 zip 内容不符)', async (t) => { + const root = tmpDir(t); + const fixture = makeFixture(path.join(root, 'mp-harmony')); + const zipPath = path.join(root, 'out.zip'); + await zipDirectory(fixture, zipPath); + + // 假装 EntryCard/entry/base/snapshot/widget-2x2.png 被复制了, 但 zip 里其实没有 + const expectFiles = [ + 'mp-harmony/AppScope/app.json5', + 'mp-harmony/a/b/c/deep.txt', + 'mp-harmony/a/repeatable.txt', + 'mp-harmony/a/random.bin', + 'mp-harmony/a/中文名.txt', + 'mp-harmony/a/zero.txt', + 'mp-harmony/EntryCard/entry/base/snapshot/widget-2x2.png', + ]; + await assert.rejects( + () => verifyZipFile(zipPath, { withCerts: false, skipRequired: true, expectFiles }), + /漏拷/, + 'expectFiles 里多出的文件必须被报为漏拷', + ); +}); + +test('verifyZipFile: 多拷应被拒绝 (zip 内出现复制清单外的文件)', async (t) => { + const root = tmpDir(t); + const fixture = makeFixture(path.join(root, 'mp-harmony')); + const zipPath = path.join(root, 'out.zip'); + await zipDirectory(fixture, zipPath); + + // 清单里故意少写一个, zip 里却存在 -> 应报多拷 + const expectFiles = [ + 'mp-harmony/AppScope/app.json5', + 'mp-harmony/a/b/c/deep.txt', + 'mp-harmony/a/repeatable.txt', + 'mp-harmony/a/random.bin', + 'mp-harmony/a/中文名.txt', + // 漏掉 a/zero.txt + ]; + await assert.rejects( + () => verifyZipFile(zipPath, { withCerts: false, skipRequired: true, expectFiles }), + /多拷/, + '清单外的 zip 文件必须被报为多拷', + ); +}); + +test('verifyZipFile: 清单完全吻合时应通过 (含 README.txt 豁免)', async (t) => { + const root = tmpDir(t); + const fixture = makeFixture(path.join(root, 'mp-harmony')); + fs.writeFileSync(path.join(fixture, 'README.txt'), '# 说明'); + const zipPath = path.join(root, 'out.zip'); + await zipDirectory(fixture, zipPath); + + const expectFiles = [ + 'mp-harmony/AppScope/app.json5', + 'mp-harmony/a/b/c/deep.txt', + 'mp-harmony/a/repeatable.txt', + 'mp-harmony/a/random.bin', + 'mp-harmony/a/中文名.txt', + 'mp-harmony/a/zero.txt', + 'mp-harmony/README.txt', + ]; + const res = await verifyZipFile(zipPath, { withCerts: false, skipRequired: true, expectFiles }); + assert.equal(res.files, 7); +}); + test('copyDir: 排除规则在真实复制中生效', async (t) => { const root = tmpDir(t); const src = path.join(root, 'src');