diff --git a/docs/IMPLEMENTATION_HANDOFF.md b/docs/IMPLEMENTATION_HANDOFF.md index cd13169..6234415 100644 --- a/docs/IMPLEMENTATION_HANDOFF.md +++ b/docs/IMPLEMENTATION_HANDOFF.md @@ -1,6 +1,6 @@ # RN SDK 重构实施接管文档 -> 状态更新时间:2026-07-18 +> 状态更新时间:2026-07-19 > 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview` > 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。 @@ -39,7 +39,7 @@ - 本地已使用 JDK 21 + Gradle 9.3.1 编译通过;RN Bridge 明确输出 Java 17 字节码,不再隐式回落到 Java 8。 - RN 包不再内置第二份 AGP buildscript;宿主负责插件版本,独立夹具使用与 Android SDK 一致的 AGP 9.1.0。 - `pnpm validate`:通过。 -- common:19 个测试通过。 +- common:20 个测试通过。 - bugcollect:5 个独立测试通过。 - xwebview:4 个独立测试通过。 - update CLI/Metro:11 个测试通过;release-set:6 个测试通过。 @@ -113,6 +113,22 @@ pnpm --dir packages/update test method、相对 path、HTTP 状态、错误码和 Zod 字段路径。 - 新增测试使用带手机号、sessionId、userId、token 的伪响应,强制验证 序列化后的诊断结果不包含这些敏感值。 + +### 2026-07-19 / common Bundle 模块过滤修复 + +- App4 模拟器首次加载内嵌插件时出现 `Requiring unknown module "726"`。定位结果不是宿主页面问题,而是 Metro 的模块编号工厂把本轮首次发现的 common 模块写入共享缓存后,过滤器又读取同一份可变缓存并把该模块从当前 Bundle 删除。 +- `withXuqmModuleConfig()` 现在只使用构建开始前的共享模块快照做去重:common 本轮新分配的模块保留在 common Bundle;后续 app/buz 仍过滤 startup/common 已有模块。宿主不增加补丁、模块白名单或第二套编号逻辑。 +- Metro 测试新增“common 本轮新模块必须保留”断言;格式、全部 workspace 类型检查及 48 项测试通过。 +- 根工作区脚本统一通过 Corepack 调用锁定的 pnpm,校验不再依赖开发机预装全局 pnpm。 +- 当前下一步:刷新 App4 的 `file:` SDK 联调副本,重建并安装 debug APK;确认六个 Hermes Bundle 可连续加载后继续双模拟器像素校准。修复尚未通过 Jenkins 发布,不得把相邻源码依赖作为正式发版基线。 + +### 2026-07-19 / Android 插件图片资源恢复 + +- App4 common/app 连续加载成功后,登录 logo 和单选框仍为空。根因是 Metro 生成的 `drawable-*` 仅位于 `assets/rn-bundles`,同时原生恢复只复制 `active.bundle`;动态 `loadBundle(file)` 会相对当前 Bundle 文件目录解析图片。 +- Gradle 生成任务现在公开 assets 与 res 两个独立 `DirectoryProperty`:Bundle/manifest 进入 APK assets,drawable 同时进入 aapt2 资源表,符合 AGP 生成源 API 的类型约束。 +- `XuqmBundleModule` 恢复内嵌 Bundle 时同步读取该模块 manifest 的 `assetFiles`,校验路径只允许标准 `drawable-*/resource_name.ext`,再原子写入同一模块目录;图片恢复不由宿主页面逐个处理。 +- App4 arm64 debug 已真实编译该 Java 实现并安装验证;app 模块私有目录共恢复 289 个 Bundle/状态/资源文件,登录 logo 与单选框显示正常。 +- 当前只取得内嵌基线恢复证据;远程 release set 的二进制 Bundle 与资源下载、校验、事务激活仍需作为同一组实现和验证,不能把本次结果扩大为 OTA 资源已完成。 - 全局 API 错误回调不再交付带 Axios cause 的 `RequestError`,只交付不可逆的安全诊断报告;宿主无法误把请求体、headers 或响应上传到采集平台。 - bugcollect 的全局 fetch 拦截器只上报 method、相对 path 和 HTTP status;完整 host、query、请求体、headers 及原始网络异常对象全部丢弃,测试覆盖敏感查询参数和鉴权头。 @@ -215,3 +231,83 @@ pnpm --dir packages/update test - Jenkins Windows 节点直接访问外部 Android 仓库会出现无输出的长时间依赖等待;`native-test` 的 plugin management 已与 AndroidSDK 对齐,统一优先使用 Nexus `android` 聚合仓库,再回退官方仓库。 - Windows CLI 测试路径统一使用 `fileURLToPath`,禁止把 `file:` URL 的 pathname 直接作为本机路径。 - native baseline 现在按 Android/iOS 分别计算,并覆盖全部运行时依赖的实际安装版本、平台原生源码/资源、`src`/`assets` 内会随安装包打入的图片、字体和媒体文件;这些内容变化后必须先发布完整 App,不能只发布 JS 插件。 + +### 2026-07-19 / 同版本内嵌基线覆盖修复 + +- App4 覆盖安装新 debug APK 后仍加载手机内旧 `active.bundle`。根因是 `ensureEmbeddedBundle()` 只判断文件存在,插件版本仍为 `1.0.0` 时无法识别整包内的代码已经变化。 +- `xuqm-rn embed` 现在为每个模块记录实际 bundle 的 SHA-256;Android state 同步记录 `activeHash`。状态仍为 `embedded` 且摘要变化时,SDK 原子替换 bundle、清理该模块旧 drawable 目录并按 manifest 恢复新资源。 +- 状态为 `active` 或事务中的远程插件不会被覆盖安装改写;线上更新仍只能经过 stage/activate/confirm/rollback。该分支避免以开发便利破坏生产事务边界。 +- RN SDK 全量门禁通过。App4 重新编译 Java Bridge 与六模块共 450 tasks 成功;第一次不清 `rn-bundles` 覆盖安装把旧 state 自动写入新 manifest 摘要,第二次相同 APK 覆盖后 bundle mtime 未变化,证明变化替换和相同复用均生效。下一步为 Jenkins 发布新 alpha 后恢复 App4 精确 Nexus 版本。 + +### 2026-07-19 / 平台地址与更新检查超时收敛 + +- `XuqmConfig` 新增职责明确的 `platformUrl`;配置文件 `serverUrl/baseUrl` 只在解密入口归一为该字段。租户配置、整包和插件更新统一访问 `platformUrl`,远程 `apiUrl` 只表示业务扩展服务,禁止两者混用。 +- common HTTP 增加唯一 30 秒默认超时,并将调用方 `AbortSignal` 与内部超时合并;定时器在成功、失败和取消后统一清理,不要求每个 SDK 复制 `Promise.race`。 +- `checkPluginRelease()` 支持 `signal/timeoutMs`;`checkAndInstallPlugin()` 支持 `signal/checkTimeoutMs`,宿主可为“进入插件前检查”设置更短交互边界,同时保留纯检查、确认后安装和自动检查安装三种既有 API 语义。 +- App4 使用 5 秒检查边界:平台暂时不可达且本地 Bundle 健康时继续进入本地插件,网络异常不被误报为 Bundle 崩溃;真实 `szyx/active.bundle` 已在 Android 模拟器加载成功。 +- common 新增停滞请求超时测试,并扩展生命周期/初始化测试覆盖 `platformUrl` 与 `apiUrl` 分离;全量 `pnpm validate` 再次通过:六个 workspace 类型检查、common 20、bugcollect 5、xwebview 4、IM 3、update CLI/Metro 11 与 release-set 6 项测试全部无回归。 + +### 2026-07-20 / 启动壳轻量原生分包入口 + +- `@xuqm/rn-update` 新增公开子路径 `@xuqm/rn-update/native-bundle`,只导出原生 Bundle 状态与路径桥,不加载 AsyncStorage、HTTP、SemVer、整包更新或 release-set 编排。 +- App4 startup 已改用 `NativeBundle.preparePendingRelease()` / `getLaunchPath()`;完整 `UpdateSDK` 仍只在 app 更新和插件激活阶段加载,不存在第二套状态实现。 +- App4 的 startup Android Hermes bytecode 从此前约 2.2 MB 降至约 1.27 MB。该数据只证明启动入口依赖收敛;common/app 动态装载和模拟器交互首屏仍需独立计时,不能只用 startup 文件大小宣称启动性能完成。 +- `packages/update/package.json` 的 exports、README 与 App4 实际调用已同步。该变更仍是 main 本地源码联调状态,未通过 Jenkins 发布新 alpha,外部项目不得提前写入一个尚不存在的 Nexus 版本号。 + +### 2026-07-20 / common 构建期配置解析与 XWebView 权限收口 + +- App4 冷启动日志证明 common 请求与 app 请求之间曾固定阻塞约 10.6 秒;直接读取原生 Bundle 仅约 1ms。根因是 common 自动初始化在 Hermes 主线程执行 120,000 次纯 JavaScript PBKDF2,而不是 Bundle IO 或 App 页面渲染。 +- `withXuqmConfig()` 现在使用 Node `crypto` 在 Metro 构建期完成 PBKDF2、AES-256-GCM 解密和 Schema 校验,虚拟模块只导出解析后的只读配置。设备端自动初始化不再执行解密;临时网络失败重试复用同一份解析结果,不恢复第二套初始化入口。 +- App4 重新生成六模块并安装后,common 请求到 app 请求的间隔由约 10.6 秒降至约 405ms;一次 Android 冷启动 `Displayed` 为 783ms。该结果是 API 34 ARM64 软件模拟器观测值,只作为本轮相对回归证据,不替代真机性能验收。 +- XWebView 默认权限策略只接受摄像头和麦克风两类已知 WebView 资源,并逐项核对 Android 运行时授权;未知资源明确拒绝。单测覆盖摄像头、麦克风、混合资源、拒绝和未知资源场景。 +- common 18 项 TypeScript 测试、2 项 Metro 测试、类型检查,以及 xwebview 4 项测试和类型检查均通过。App4 当前使用相邻 `file:` 源码联调;完成设备 H5 权限闭环后仍需由 Jenkins 发布新 alpha,再恢复精确 Nexus 版本。 + +### 2026-07-20 / 多 Bundle 插件注册表单例收口 + +- App4 内嵌 `miniapp` 设备验证暴露 `[XuqmRuntime] Plugin did not call definePlugin()`。bundle 摘要与内嵌 manifest 完全一致,插件入口也确实调用 `definePlugin()`;根因是 app/buz 模块区间隔离后,各自复制的 `PluginRuntime` 创建了不同 `definitions` Map。 +- update SDK 将插件定义 Map 拆到轻量 `PluginRegistry.ts`,并导出 `@xuqm/rn-update/plugin-registry` 子路径。宿主 common 入口必须副作用导入该子路径,使 app 与所有 buz 通过共享模块编号读写同一个注册表;不得在 App 内写兼容 Map 或改成全局变量。 +- 新增注册、读取、删除和重复定义测试;RNSDK 完整 `pnpm validate` 已通过:common 20、bugcollect 5、xwebview 4、IM 3、update CLI/Metro 11 与 update domain 8 项测试全部通过。 +- App4 重建六模块并进入真实“医信课堂”后,注册表错误消失;设备随后进一步暴露并修复两类相同边界缺陷:app/miniapp 重复执行 `BVLinearGradient` 原生 View 注册,以及 miniapp 反向导入 host 后复制 `UpdateContext`。宿主 common 现统一持有跨 Bundle 原生依赖;插件通过 common 更新命令端口调用 host 实现,不再导入宿主 Context,也未增加全局变量兼容层。 +- 最终 `emulator-5556` 的 common/app/miniapp 均加载成功,日志无插件注册、重复 View、Context 或 Java/React FATAL;XWebView 容器已显示真实服务标题和 DNS 错误页。该结果只证明多 Bundle 与容器运行时通过,远端 H5 内容及摄像头/麦克风授权仍需网络正常的开发环境设备验收。 + +### 2026-07-20 / XWebView 浏览器内核与错误态唯一实现 + +- 设备 DNS 失败复测发现 Android 系统英文错误页会在 `onError` 后再次触发加载事件,旧实现因此清空自定义错误状态;同时错误分支卸载 WebView,按钮持有的 ref 已为空,重试实际无效。 +- `XWebViewView` 现在始终保留 WebView,并以白底中文错误层覆盖系统页;刷新、前进、后退和重试统一先清理错误状态再操作同一控制器。错误页不再由原生事件先后顺序随机决定。 +- 删除 `XWebViewScreen` 内 600 余行重复的 WebView、下载、Bridge、权限和错误处理。Screen 现在只组合统一导航栏、系统返回与 `XWebViewView`;全屏和内嵌场景共享同一个浏览器内核实现,不再修复一处、遗漏另一处。 +- xwebview 类型检查与 4 项下载/权限测试通过;RNSDK 全量 `pnpm validate` 同步通过。App4 已刷新相邻 `file:` 依赖并完成 457 tasks 构建:首次 DNS 失败与点击重试后都稳定显示统一中文错误层,截图为 `/tmp/app4-xwebview-error-unified.png`、`/tmp/app4-xwebview-retry-final.png`,日志无 Java/React FATAL。 + +### 2026-07-20 / Android 内嵌 Bundle 启动短路 + +- 审计确认旧 `ensureEmbeddedBundle()` 每次查询模块路径或状态都会先从 APK assets 读取整份 Bundle,再用摘要判断是否需要覆盖;虽然不会每次写盘,但六模块冷启动仍产生无效的大文件读取和摘要对象创建。 +- Android 原生模块现将 APK `manifest.json` 按进程缓存,并先读取本地 `state.json`:下载版本 SemVer 大于等于内嵌版本、原生 baseline/API 等级兼容且记录长度正常时直接加载;APK 自有 embedded 状态则使用 manifest SHA-256 短路,同版本新开发构建仍能正确替换。 +- 恢复 API 始终按传入的单个 moduleId 工作,不预装 manifest 中的全部模块。宿主冷启动只请求 common、app;buz 必须在用户进入相应入口后,通过目标插件的检查/安装与 `XuqmRuntime.activate()` 按需恢复和执行。 +- 设备清数据复核进一步发现,启动期 app/common 更新检查会收集全部已登记模块版本,而旧 `getBundleState()` 内部错误调用 `ensureEmbeddedBundle()`,导致“查询状态”顺带把三个 buz 全部释放到私有目录。该副作用已删除:状态存在时只读 state,未访问模块从 manifest 构造只读内嵌状态;只有 `getLaunchBundlePath(moduleId)` 能触发恢复。 +- manifest 新增 `byteLength`;远程 staging 同步记录 pending/active/rollback 长度。首次安装、本地落后、baseline 不匹配、长度异常或摘要变化时才读取内嵌 Bundle,且写盘前强制校验 manifest SHA-256,再原子恢复 Bundle 与 drawable 资源。 +- release-set 规划器新增 `NATIVE_BASELINE_INCOMPATIBLE`,候选缺失或不匹配当前 `builtAgainstNativeBaselineId` 时拒绝安装,避免修改原生依赖后仍向旧完整包发布 JS 插件。 +- RNSDK 全量格式、六 workspace TypeScript、common 20、bugcollect 5、xwebview 4、IM 3、update CLI/Metro 11 与 update domain 9 项测试通过;App4 宿主真实执行 `:xuqm_rn-update:compileDebugJavaWithJavac`,19 tasks 构建成功,完整六模块 Debug APK 457 tasks 构建成功。 +- `emulator-5556` 覆盖安装后的第一次启动只为旧状态补写 `activeByteLength` 等小型元数据,未重写已有 common `active.bundle`;第二次 `force-stop` 冷启动前后,common/app 的 `active.bundle` 与 `state.json` mtime、长度均完全不变,日志确认两者直接从应用私有目录 `rn-bundles/*/active.bundle` 装载,common 到 app 请求间隔约 15ms,且无 Java/React FATAL。两次 `am start -W` TotalTime 分别为 1052ms、1085ms,只作为模拟器回归记录;系统首帧耗时不能单独归因于 Bundle 短路,也不替代真机性能验收。 +- 修复状态查询副作用后重新构建 457 tasks APK,并在 `emulator-5556` 清除数据验证:首次启动私有目录只有 `rn-bundles/common`、`rn-bundles/app`,不再出现 szyx/miniapp/workbench;第二次冷启动仍只有这两个目录,四个 bundle/state 文件的 mtime 与长度完全一致。两次清数据/二次启动 TotalTime 分别为 1137ms、1028ms,只作为正确性和相对回归记录。 + +### 2026-07-20 / 本地原生依赖 baseline 与原生 SemVer 收口 + +- `XuqmBundleModule` 不再内嵌一套 80 余行 SemVer 算法。Android 启动门禁统一调用包内 `XuqmSemanticVersion`;原生单测覆盖 SemVer 官方预发布顺序、构建元数据、超大数字标识和非法旧状态,非法状态固定返回“不可信”并恢复 APK 基线。 +- 完整包 baseline 原先只记录宿主 Android/iOS 源码和依赖版本;相邻 `file:` SDK 在版本号未变时修改 Java/Kotlin 会被漏掉。`computeNativeBaseline()` 现在额外哈希每个 `file:` 运行时依赖的对应平台原生源码与资源,registry 依赖仍由不可变安装版本标识,不扫描整个 `node_modules`。 +- Gradle `prepareXuqmEmbeddedBundles` 同步把所有 `file:` 依赖的 Android/iOS 原生源码、资源、podspec 与 package manifest 声明为输入,并排除 build/.cxx/.gradle/generated。baseline 计算和 Gradle 增量使用同一边界,不再出现脚本认为变化而 Gradle 跳过、或 Gradle 重建但 baseline 不变的分裂状态。 +- 新增 Node fixture 证明本地依赖版本不变、只修改原生源码也会改变 Android baseline;RNSDK 全量格式、六 workspace 类型检查与全部测试通过。update CLI/Metro 测试增至 14 项,domain 仍为 9 项。 +- App4 刷新本地依赖后,旧 baseline `android-sha256:4567…7501` 正确触发六模块重建,新 baseline 为 `android-sha256:eae4…99df`;紧接着复跑 998ms,11 tasks 全部 `UP-TO-DATE`。原生 SemVer 单测、update Java 编译与 App4 457 tasks Debug 构建均通过。 +- 新 APK 在 `emulator-5556` 不清用户数据覆盖安装后,common/app 状态都原子刷新为 1.0.0、native API 2 和新 baseline;私有目录仍只有 common/app,无 buz,也无 Java/React FATAL。宿主同时运行 DevEco Studio/Harmony 模拟器导致本轮三次冷启动约 2.27–2.47s、fully-drawn 约 3.28–3.55s;该受竞争负载样本只保留为功能回归证据,不覆盖独立性能基准。 + +### 2026-07-20 / Android 插件包暂存与存储职责拆分 + +- `XuqmBundleModule` 不再同时承担 RN Bridge、发布事务、ZIP 解压、manifest 校验、摘要和文件系统实现。下载包大小/SHA-256、ZIP 条目与解压上限、manifest/候选版本一致性、Bundle/资源完整性统一归入 `XuqmPluginPackageStager`;桥接模块只编排 stage/activate/confirm/rollback。 +- `XuqmBundleStorage` 成为插件更新原生层文件读取、安全相对路径、SHA-256、原子替换和递归删除的唯一实现;内嵌 Bundle 恢复与远端插件暂存共用同一摘要和写盘语义,没有保留旧私有方法或补丁兼容层。 +- `XuqmBundleModule` 从 1081 行降至 853 行。新增原生单测覆盖标准 SHA-256、目录穿越拒绝、Android 资源路径白名单,并与既有 SemVer 测试一起在 App4 宿主执行:3 个测试类、6 项测试、0 failure/error。 +- App4 刷新相邻 `file:` 依赖后,原生源码变化按设计触发六模块重建,native baseline 更新为 `android-sha256:1a95…5cf0`;紧接着复跑 `:app:prepareXuqmEmbeddedBundles` 为 11 tasks 全部 `UP-TO-DATE`。完整 `:app:assembleDebug` 与 `:xuqm_rn-update:testDebugUnitTest` 同轮构建成功。 + +### 2026-07-20 / Gradle 9.3.1 宿主兼容复核 + +- update Android 仓库声明已改为 Gradle 9 持续支持的 `url = uri(...)` 赋值形式,不再由 Xuqm 自有脚本产生旧 Groovy property-space assignment warning;不在 SDK 内覆盖宿主 AGP、Kotlin、Gradle 或 React Native 版本。 +- App4 按 RN 0.86 官方模板切换到 Gradle 9.3.1 后,真实执行 `:xuqm_rn-update:testDebugUnitTest` 成功,并完成 451 tasks 六模块 Debug APK 构建;原生存储、暂存、SemVer 与 Bridge 拆分均进入宿主编译,不只是独立源码测试。 +- 本轮 SDK 定向复核结果:common 21 tests、update CLI/Metro/native-baseline 14 tests、release-set/plugin-registry 9 tests、bugcollect 5 tests、xwebview 4 tests;根工作区及各 package TypeScript、Prettier 和 update 发布包 35 文件校验均通过。App4 完整门禁同步达到 20 suites / 87 tests。 +- Gradle 9 构建仍报告的弃用项来自 React Native/三方插件,不通过 SDK 关闭 warning。后续升级必须继续以宿主完整构建和原生单测为门禁,不能只验证 TypeScript 或 npm 打包。 diff --git a/docs/SDK-API参考.md b/docs/SDK-API参考.md index c0faa3b..280fd51 100644 --- a/docs/SDK-API参考.md +++ b/docs/SDK-API参考.md @@ -3,9 +3,9 @@ ## 公共生命周期 ```ts -import {XuqmSDK} from '@xuqm/rn-common'; +import { XuqmSDK } from '@xuqm/rn-common' -await XuqmSDK.awaitInitialization(); +await XuqmSDK.awaitInitialization() await XuqmSDK.login({ userId: 'user-id', @@ -13,9 +13,9 @@ await XuqmSDK.login({ userSig: 'optional-im-credential', name: '姓名', phone: '手机号', -}); +}) -await XuqmSDK.logout(); +await XuqmSDK.logout() ``` - `initialize(options)`:仅在不能使用配置文件自动初始化时调用。 @@ -26,6 +26,12 @@ await XuqmSDK.logout(); 不提供 `setUserInfo`、`setUserId` 或公开的配置文件初始化入口。 +### 地址职责 + +- `platformUrl`:Xuqm 租户平台地址。远程配置、整包更新、插件 release set 检查等平台接口只使用该地址;自动配置未提供时使用 SDK 内置的公有平台地址。 +- `apiUrl`:租户平台返回的业务扩展服务地址,只供明确声明使用该业务服务的扩展能力读取,不能替代 `platformUrl`。 +- common 的 `apiRequest()` 默认基础地址在 SDK 初始化后固定为 `platformUrl`;访问其它业务服务时必须传绝对 URL 或在 common-only 模式显式 `configureHttp()`,禁止用远程 `apiUrl` 静默改写平台请求。 + ## common 通用能力 下列能力可以零初始化、零登录独立使用。`apiRequest` 未初始化时不会生成 SDK 签名头;可使用绝对 URL 或 `configureHttp` 设置基础地址。 @@ -46,3 +52,27 @@ await XuqmSDK.logout(); - `@xuqm/rn-push`:在 common 会话变化时同步原生推送绑定。 所有扩展包都不提供第二套初始化或登录 API。 + +## update 插件检查边界 + +```ts +const plan = await UpdateSDK.checkPluginRelease('buz1', { + signal, + timeoutMs: 5_000, +}) + +if (plan) { + await UpdateSDK.installPluginRelease(plan, { signal, onProgress }) +} + +await UpdateSDK.checkAndInstallPlugin('buz1', { + signal, + checkTimeoutMs: 5_000, + onProgress, +}) +``` + +- `checkPluginRelease()` 只检查版本和兼容依赖闭包,不下载、不激活;`timeoutMs` 只约束检查请求。 +- `installPluginRelease()` 只安装此前确认的计划;本地基线已变化时抛出 `StaleReleaseSetError`。 +- `checkAndInstallPlugin()` 用于宿主自动进入插件的场景;`checkTimeoutMs` 只传给检查阶段,下载仍由 `signal` 和进度回调控制。 +- common HTTP 的统一默认请求上限为 30 秒;宿主需要更短的交互等待时显式传上述参数,不得在页面复制 `Promise.race` 或第二套超时器。 diff --git a/docs/配置文件规范.md b/docs/配置文件规范.md index 7f161ba..349cbed 100644 --- a/docs/配置文件规范.md +++ b/docs/配置文件规范.md @@ -30,14 +30,14 @@ src/assets/config/config.xuqmconfig Metro 配置只包装一次: ```js -const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); -const {withXuqmConfig} = require('@xuqm/rn-common/metro'); +const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config') +const { withXuqmConfig } = require('@xuqm/rn-common/metro') const baseConfig = mergeConfig(getDefaultConfig(__dirname), { // 宿主自己的 Metro 配置 -}); +}) -module.exports = withXuqmConfig(baseConfig); +module.exports = withXuqmConfig(baseConfig) ``` ## 自动初始化时序 @@ -78,28 +78,42 @@ XUQM-CONFIG-V1.{salt}.{iv}.{ciphertextAndTag} - 12 字节 IV - 16 字节认证标签 -解密后的必要字段为 `appKey`,可选字段包括 `serverUrl`、`baseUrl` 和 `signingKey`。明文结构由平台负责签发,业务仓库不得自行构造。 +解密后的必要字段为 `appKey`,可选字段包括 `serverUrl`、`baseUrl` 和 `signingKey`。`serverUrl` 优先、`baseUrl` 次之,二者都表示 Xuqm 租户平台地址并在运行时归一为 `platformUrl`;它们不是 App 业务接口地址。明文结构由平台负责签发,业务仓库不得自行构造。 + +远程配置响应中的 `apiUrl` 是业务扩展服务地址,与配置文件中的平台地址职责不同: + +```text +配置文件 serverUrl/baseUrl + → platformUrl + → /api/sdk/config、整包更新、插件 release set 等 Xuqm 平台接口 + +平台返回 apiUrl + → 业务扩展服务 + → 只有明确依赖该服务的扩展能力使用 +``` + +禁止把远程 `apiUrl` 写回公共 HTTP 基础地址,否则会把版本检查错误地发送到 App 自身业务服务。 ## 登录 初始化与业务登录是两个动作。App 登录完成后只同步一次公共会话: ```ts -import {XuqmSDK} from '@xuqm/rn-common'; +import { XuqmSDK } from '@xuqm/rn-common' -await XuqmSDK.awaitInitialization(); +await XuqmSDK.awaitInitialization() await XuqmSDK.login({ userId: 'user-id', accessToken: 'access-token', name: '姓名', phone: '手机号', -}); +}) ``` 退出时只调用: ```ts -await XuqmSDK.logout(); +await XuqmSDK.logout() ``` 扩展包不得各自初始化或登录,业务代码不得导入 `@xuqm/rn-common/internal`。 diff --git a/package.json b/package.json index 36b8888..44e550c 100644 --- a/package.json +++ b/package.json @@ -20,10 +20,10 @@ "scripts": { "format": "prettier --write AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json packages/common packages/bugcollect packages/update packages/xwebview", "format:check": "prettier --check AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json packages/common packages/bugcollect packages/update packages/xwebview", - "test": "pnpm --recursive --if-present test", + "test": "corepack pnpm --recursive --if-present test", "typecheck": "tsc --noEmit", - "typecheck:all": "pnpm --recursive --if-present typecheck", - "validate": "pnpm format:check && pnpm typecheck:all && pnpm test" + "typecheck:all": "corepack pnpm --recursive --if-present typecheck", + "validate": "corepack pnpm format:check && corepack pnpm typecheck:all && corepack pnpm test" }, "peerDependencies": { "@react-native-async-storage/async-storage": ">=1.21.0", diff --git a/packages/common/README.md b/packages/common/README.md index e56647d..2f695b1 100644 --- a/packages/common/README.md +++ b/packages/common/README.md @@ -38,7 +38,7 @@ module.exports = withXuqmConfig( ) ``` -使用 update、bugcollect、xwebview 等扩展包时,`withXuqmConfig()` 会把 common 的初始化入口注册为 Metro pre-main module,确保初始化早于业务入口且不受 `inlineRequires` 影响。Metro 插件还会把同一份配置同步到 Android assets;宿主不需要在多个位置维护副本。 +使用 update、bugcollect、xwebview 等扩展包时,`withXuqmConfig()` 会在 Node/Metro 构建进程中解密配置,生成只读的虚拟配置模块,并把 common 的初始化入口注册为 Metro pre-main module,确保初始化早于业务入口且不受 `inlineRequires` 影响。Metro 插件还会把同一份加密配置同步到 Android assets;宿主不需要在多个位置维护副本。PBKDF2 与 AES-GCM 解密不在 Hermes 启动线程执行,应用启动时只消费构建已经解析的配置并发起共享初始化。 common-only 项目不要放置 `.xuqmconfig`,也不要使用 `withXuqmConfig()`,即可保持零初始化。是否初始化只由这一条规则决定,不提供额外 alias 或多套配置约定。 @@ -50,7 +50,7 @@ await XuqmSDK.initialize({ appKey: 'app-key' }) 同一配置的并发初始化会复用同一个 Promise;重复使用不同配置会直接报错。 -Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败,common 会终止该 Promise,避免形成未处理异常。随后第一个真正需要配置的扩展调用共享 `awaitInitialization()` 时,会使用同一份加密配置重新尝试;配置、appKey 和平台地址不需要由页面或子 SDK 再次传入。持续失败由宿主入口统一记录一次,SDK 不重复打印同一错误。 +Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败,common 会终止该 Promise,避免形成未处理异常。随后第一个真正需要配置的扩展调用共享 `awaitInitialization()` 时,会使用同一份已解析配置重新尝试;配置、appKey 和平台地址不需要由页面或子 SDK 再次传入。持续失败由宿主入口统一记录一次,SDK 不重复打印同一错误。构建期配置损坏、密钥不匹配或 Schema 非法会直接阻断构建,禁止打出运行时才失败的安装包。 ## 扩展包的唯一登录入口 diff --git a/packages/common/metro/index.js b/packages/common/metro/index.js index 3d3c511..a0d6125 100644 --- a/packages/common/metro/index.js +++ b/packages/common/metro/index.js @@ -14,6 +14,7 @@ */ const fs = require('fs') const path = require('path') +const crypto = require('node:crypto') const VIRTUAL_MODULE_ID = '@xuqm/autoinit-config' const AUTO_INIT_MODULE = path.resolve(__dirname, '../src/internal.ts') @@ -23,6 +24,44 @@ const CONFIG_DIR_CANDIDATES = [ 'assets/config', 'assets/xuqm', ] +const CONFIG_MAGIC = 'XUQM-CONFIG-V1' +const CONFIG_PASSPHRASE = 'xuqm-config-file-v1.2026.internal' +const PBKDF2_ITERATIONS = 120_000 + +function decodeBase64Url(value) { + return Buffer.from(value, 'base64url') +} + +/** + * 配置文件只在 Node/Metro 构建期解密。移动端运行时直接消费解析后的配置, + * 避免 Hermes 在应用启动阶段执行 12 万次 PBKDF2 并阻塞 JS 线程。 + */ +function decryptConfigAtBuildTime(content) { + const parts = content.trim().split('.') + if (parts.length !== 4 || parts[0] !== CONFIG_MAGIC) { + throw new Error('配置文件格式无效') + } + + const salt = decodeBase64Url(parts[1]) + const iv = decodeBase64Url(parts[2]) + const encrypted = decodeBase64Url(parts[3]) + if (salt.length === 0 || iv.length !== 12 || encrypted.length <= 16) { + throw new Error('配置文件加密参数无效') + } + + const body = encrypted.subarray(0, -16) + const authTag = encrypted.subarray(-16) + const key = crypto.pbkdf2Sync(CONFIG_PASSPHRASE, salt, PBKDF2_ITERATIONS, 32, 'sha256') + const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv) + decipher.setAuthTag(authTag) + + try { + const plaintext = Buffer.concat([decipher.update(body), decipher.final()]) + return JSON.parse(plaintext.toString('utf8')) + } catch { + throw new Error('配置文件解密或内容校验失败') + } +} function findConfigFile(projectRoot) { for (const dir of CONFIG_DIR_CANDIDATES) { @@ -116,7 +155,14 @@ function withXuqmConfig(metroConfig) { // 单点放置:自动同步到原生 Android/iOS(缺失或不一致才复制) syncNativeConfig(projectRoot, encryptedContent) - // 生成临时 TS 文件作为虚拟模块 + let resolvedConfig + try { + resolvedConfig = decryptConfigAtBuildTime(encryptedContent) + } catch (error) { + throw new Error(`[XuqmConfig] ${configFile}: ${error.message}`) + } + + // 生成临时 TS 文件作为虚拟模块。这里只传输已解析对象,移动端不再解密。 // Metro 0.84+ only hashes files under projectRoot/watchFolders. Keep the // generated transport module in the ignored package-manager cache instead // of the system temp directory; the .xuqmconfig file remains the sole source. @@ -125,7 +171,7 @@ function withXuqmConfig(metroConfig) { const virtualFile = path.join(generatedDir, 'autoinit-config.ts') fs.writeFileSync( virtualFile, - `// Auto-generated by @xuqm/rn-common/metro — do not edit\nexport const ENCRYPTED_CONFIG = ${JSON.stringify(encryptedContent)};\n`, + `// Auto-generated by @xuqm/rn-common/metro — do not edit\nexport const RESOLVED_CONFIG = ${JSON.stringify(resolvedConfig)} as const;\n`, 'utf-8', ) diff --git a/packages/common/src/autoInit.ts b/packages/common/src/autoInit.ts index 2dd1e2c..5569cc0 100644 --- a/packages/common/src/autoInit.ts +++ b/packages/common/src/autoInit.ts @@ -12,16 +12,20 @@ */ import { isInitialized } from './config' -import { _initializeFromConfigFile } from './sdk' +import type { DecryptedConfig } from './crypto' +import { _initializeFromResolvedConfig } from './sdk' let _autoInitAttempted = false -function tryRequireConfig(): string | null { +function tryRequireConfig(): DecryptedConfig | null { try { - // withXuqmConfig 将此模块映射到构建期生成的配置传输模块 + // withXuqmConfig 将此模块映射到构建期已解密、已校验的配置传输模块。 // eslint-disable-next-line @typescript-eslint/no-require-imports - const mod = require('@xuqm/autoinit-config') as { ENCRYPTED_CONFIG?: string; default?: string } - return mod?.ENCRYPTED_CONFIG ?? mod?.default ?? null + const mod = require('@xuqm/autoinit-config') as { + RESOLVED_CONFIG?: DecryptedConfig + default?: DecryptedConfig + } + return mod?.RESOLVED_CONFIG ?? mod?.default ?? null } catch { return null } @@ -38,10 +42,10 @@ export function tryAutoInit(): void { _autoInitAttempted = true if (isInitialized()) return - const encrypted = tryRequireConfig() - if (!encrypted) return + const config = tryRequireConfig() + if (!config) return - void _initializeFromConfigFile(encrypted, { debug: __DEV__ }).catch(() => undefined) + void _initializeFromResolvedConfig(config, { debug: __DEV__ }).catch(() => undefined) } tryAutoInit() diff --git a/packages/common/src/config.ts b/packages/common/src/config.ts index e834dda..4bcaaeb 100644 --- a/packages/common/src/config.ts +++ b/packages/common/src/config.ts @@ -6,6 +6,9 @@ export interface XuqmInitOptions { export interface XuqmConfig { appKey: string + /** 租户平台地址:配置、更新、插件发布等 Xuqm 平台接口统一使用该地址。 */ + platformUrl: string + /** 业务扩展服务地址,由租户平台远程配置返回,不能代替 platformUrl。 */ apiUrl: string imWsUrl: string fileServiceUrl: string @@ -62,9 +65,11 @@ export function initConfigFromRemote( remote: XuqmRemoteConfig, signingKey?: string, ): void { + const platformUrl = (options.platformUrl ?? '').replace(/\/$/, '') const apiUrl = remote.apiUrl ?? remote.imApiUrl ?? '' _config = { appKey: options.appKey, + platformUrl, apiUrl, imWsUrl: remote.imWsUrl ?? '', fileServiceUrl: remote.fileServiceUrl ?? apiUrl, diff --git a/packages/common/src/crypto.ts b/packages/common/src/crypto.ts index 132da84..cc63a03 100644 --- a/packages/common/src/crypto.ts +++ b/packages/common/src/crypto.ts @@ -31,12 +31,18 @@ function base64UrlDecode(value: string): Uint8Array { return Uint8Array.from({ length: binary.length }, (_, index) => binary.charCodeAt(index)) } -function base64UrlEncode(value: Uint8Array): string { +/** Encode arbitrary binary data without exceeding Hermes argument-count limits. */ +export function bytesToBase64(value: Uint8Array): string { let binary = '' - value.forEach(byte => { - binary += String.fromCharCode(byte) - }) - return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') + const chunkSize = 0x8000 + for (let offset = 0; offset < value.length; offset += chunkSize) { + binary += String.fromCharCode(...value.subarray(offset, offset + chunkSize)) + } + return btoa(binary) +} + +function base64UrlEncode(value: Uint8Array): string { + return bytesToBase64(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '') } /** diff --git a/packages/common/src/http.ts b/packages/common/src/http.ts index 7275976..3c50dbc 100644 --- a/packages/common/src/http.ts +++ b/packages/common/src/http.ts @@ -44,6 +44,10 @@ export async function apiRequest( body?: unknown params?: Record skipAuth?: boolean + /** 调用方取消信号;版本检查等可在页面退出时立即停止。 */ + signal?: AbortSignal + /** 默认 30 秒,必须为正数;防止网络异常永久阻塞扩展入口。 */ + timeoutMs?: number } = {}, ): Promise { let url = path.startsWith('http://') || path.startsWith('https://') ? path : `${_baseUrl}${path}` @@ -84,11 +88,31 @@ export async function apiRequest( }) } - const res = await fetch(url, { - method: options.method ?? 'GET', - headers, - body: options.body ? JSON.stringify(options.body) : undefined, - }) + const timeoutMs = options.timeoutMs ?? 30_000 + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) { + throw new Error('[XuqmSDK][HTTP] timeoutMs must be a positive number') + } + + const controller = new AbortController() + const abortFromCaller = () => controller.abort(options.signal?.reason) + if (options.signal?.aborted) abortFromCaller() + else options.signal?.addEventListener('abort', abortFromCaller, { once: true }) + const timeout = setTimeout(() => { + controller.abort(new Error(`[XuqmSDK][HTTP] Request timed out after ${timeoutMs}ms`)) + }, timeoutMs) + + let res: Response + try { + res = await fetch(url, { + method: options.method ?? 'GET', + headers, + body: options.body ? JSON.stringify(options.body) : undefined, + signal: controller.signal, + }) + } finally { + clearTimeout(timeout) + options.signal?.removeEventListener('abort', abortFromCaller) + } if (!res.ok) { const err = await res.json().catch(() => ({ message: res.statusText })) diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 242128e..38e6685 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -3,7 +3,13 @@ export type { XuqmInitOptions, XuqmConfig, XuqmLoginOptions, XuqmUserInfo } from export { getConfig, isInitialized, getUserId, getUserInfo } from './config' export { awaitInitialization } from './sdk' export { apiRequest, configureHttp } from './http' -export { decryptConfigFile, decryptXuqmFile, hmacSha256Base64Url, sha256Hex } from './crypto' +export { + bytesToBase64, + decryptConfigFile, + decryptXuqmFile, + hmacSha256Base64Url, + sha256Hex, +} from './crypto' export type { DecryptedConfig, XuqmEncryptedFileMagic } from './crypto' export { DEFAULT_TENANT_PLATFORM_URL, DEFAULT_IM_WS_URL } from './constants' export { getDeviceId, getDeviceInfo, detectPushVendor } from './device' diff --git a/packages/common/src/sdk.ts b/packages/common/src/sdk.ts index 83d891e..4735ced 100644 --- a/packages/common/src/sdk.ts +++ b/packages/common/src/sdk.ts @@ -11,12 +11,12 @@ import { } from './config' import { DEFAULT_TENANT_PLATFORM_URL } from './constants' import { _clearAccessToken, _saveAccessToken, configureHttp } from './http' -import { decryptConfigFile } from './crypto' +import type { DecryptedConfig } from './crypto' let _initPromise: Promise | null = null -let _configFileInitPromise: Promise | null = null -let _configFileRequest: { - encryptedContent: string +let _resolvedConfigInitPromise: Promise | null = null +let _resolvedConfigRequest: { + config: DecryptedConfig options?: { debug?: boolean } } | null = null let _initializationKey: string | null = null @@ -46,8 +46,8 @@ function startInitialization(options: XuqmInitOptions, signingKey?: string): Pro _initializationKey = nextKey _initPromise = (async () => { - const baseUrl = options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL - const configUrl = `${baseUrl.replace(/\/$/, '')}/api/sdk/config?appKey=${encodeURIComponent(options.appKey)}` + const platformUrl = (options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '') + const configUrl = `${platformUrl}/api/sdk/config?appKey=${encodeURIComponent(options.appKey)}` const res = await fetch(configUrl) if (!res.ok) { throw new Error(`[XuqmSDK] Platform config request failed: ${res.status}`) @@ -55,7 +55,7 @@ function startInitialization(options: XuqmInitOptions, signingKey?: string): Pro const json = (await res.json()) as Record const remote = (json.data ?? json) as Record initConfigFromRemote( - options, + { ...options, platformUrl }, { apiUrl: remote.apiUrl as string | undefined, imWsUrl: remote.imWsUrl as string | undefined, @@ -67,8 +67,10 @@ function startInitialization(options: XuqmInitOptions, signingKey?: string): Pro }, signingKey, ) + // Xuqm 平台接口始终回到租户平台。remote.apiUrl 是业务扩展服务地址, + // 二者职责不同;混用会把版本检查错误地发送到 App 自身的业务服务。 configureHttp({ - baseUrl: (remote.apiUrl as string | undefined) ?? baseUrl, + baseUrl: platformUrl, debug: options.debug, }) await _notifyInitialized() @@ -152,10 +154,10 @@ export const XuqmSDK = { export async function awaitInitialization(): Promise { if (isInitialized()) return const pendingInitialization = - _configFileInitPromise ?? + _resolvedConfigInitPromise ?? _initPromise ?? - (_configFileRequest - ? _initializeFromConfigFile(_configFileRequest.encryptedContent, _configFileRequest.options) + (_resolvedConfigRequest + ? _initializeFromResolvedConfig(_resolvedConfigRequest.config, _resolvedConfigRequest.options) : null) if (!pendingInitialization) { throw new Error( @@ -165,25 +167,24 @@ export async function awaitInitialization(): Promise { await pendingInitialization } -/** Internal entry used only by the Metro auto-init module. */ -export function _initializeFromConfigFile( - encryptedContent: string, +/** Metro 自动初始化专用入口;配置已在可信构建进程中完成解密和格式校验。 */ +export function _initializeFromResolvedConfig( + config: DecryptedConfig, options?: { debug?: boolean }, ): Promise { - _configFileRequest = { encryptedContent, options } - if (_configFileInitPromise) return _configFileInitPromise + _resolvedConfigRequest = { config, options } + if (_resolvedConfigInitPromise) return _resolvedConfigInitPromise - _configFileInitPromise = (async () => { - const file = await decryptConfigFile(encryptedContent) - const platformUrl = file.serverUrl ?? file.baseUrl ?? undefined + _resolvedConfigInitPromise = (async () => { + const platformUrl = config.serverUrl ?? config.baseUrl ?? undefined await startInitialization( - { appKey: file.appKey, platformUrl, debug: options?.debug }, - file.signingKey, + { appKey: config.appKey, platformUrl, debug: options?.debug }, + config.signingKey, ) })().catch(error => { - _configFileInitPromise = null + _resolvedConfigInitPromise = null throw error }) - return _configFileInitPromise + return _resolvedConfigInitPromise } diff --git a/packages/common/tests/crypto.test.ts b/packages/common/tests/crypto.test.ts index a9caa75..fb7ea8c 100644 --- a/packages/common/tests/crypto.test.ts +++ b/packages/common/tests/crypto.test.ts @@ -1,6 +1,6 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { decryptXuqmFile, hmacSha256Base64Url } from '../src/crypto' +import { bytesToBase64, decryptXuqmFile, hmacSha256Base64Url } from '../src/crypto' const CONFIG_VECTOR = 'XUQM-CONFIG-V1.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.vhdKh3AhEdeftXCdXp9KusVySkrBNAzmdaA747uf1tVWGFnAsJeCLe2mHKk0dxxez8SRTknIbk1UuRibnXY0Gbot1fmkR-jSN2ssMJO0_zQ8dUu8' @@ -12,6 +12,11 @@ test('hmacSha256Base64Url matches the standard HMAC-SHA256 vector', () => { ) }) +test('bytesToBase64 supports plugin-sized byte arrays', () => { + const bytes = Uint8Array.from({ length: 100_000 }, (_, index) => index % 251) + assert.equal(Buffer.from(bytes).toString('base64'), bytesToBase64(bytes)) +}) + test('decryptXuqmFile remains compatible with existing PBKDF2 + AES-256-GCM files', async () => { await assert.doesNotReject(async () => { const result = await decryptXuqmFile(CONFIG_VECTOR) diff --git a/packages/common/tests/http.test.ts b/packages/common/tests/http.test.ts new file mode 100644 index 0000000..0f5637e --- /dev/null +++ b/packages/common/tests/http.test.ts @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { apiRequest, configureHttp } from '../src/http' + +test('apiRequest aborts a stalled request at the shared timeout boundary', async () => { + const originalFetch = globalThis.fetch + configureHttp({ baseUrl: 'https://platform.example.test' }) + globalThis.fetch = async (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + 'abort', + () => reject(init.signal?.reason ?? new Error('aborted')), + { once: true }, + ) + }) + + try { + await assert.rejects( + () => apiRequest('/api/v1/rn/release-set/check', { skipAuth: true, timeoutMs: 5 }), + /Request timed out after 5ms/, + ) + } finally { + globalThis.fetch = originalFetch + } +}) diff --git a/packages/common/tests/lifecycle.test.ts b/packages/common/tests/lifecycle.test.ts index fba2cbe..d79c380 100644 --- a/packages/common/tests/lifecycle.test.ts +++ b/packages/common/tests/lifecycle.test.ts @@ -15,7 +15,10 @@ test('all extensions observe the same initialized config exactly once', async () }) initConfigFromRemote( - { appKey: 'shared-app' }, + { + appKey: 'shared-app', + platformUrl: 'https://platform.example.test', + }, { apiUrl: 'https://api.example.test', imApiUrl: 'https://im.example.test', @@ -24,6 +27,7 @@ test('all extensions observe the same initialized config exactly once', async () await _notifyInitialized() assert.equal(getConfig().apiUrl, 'https://api.example.test') + assert.equal(getConfig().platformUrl, 'https://platform.example.test') assert.deepEqual(observed, ['shared-app:https://api.example.test']) unregister() }) diff --git a/packages/common/tests/metro.test.cjs b/packages/common/tests/metro.test.cjs index a5a79d9..290c7d5 100644 --- a/packages/common/tests/metro.test.cjs +++ b/packages/common/tests/metro.test.cjs @@ -6,6 +6,9 @@ const test = require('node:test') const { withXuqmConfig } = require('../metro') +const CONFIG_VECTOR = + 'XUQM-CONFIG-V1.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.vhdKh3AhEdeftXCdXp9KusVySkrBNAzmdaA747uf1tVWGFnAsJeCLe2mHKk0dxxez8SRTknIbk1UuRibnXY0Gbot1fmkR-jSN2ssMJO0_zQ8dUu8' + test('withXuqmConfig schedules automatic initialization before the app entry', () => { const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-')) const configDir = path.join(projectRoot, 'src/assets/config') @@ -13,7 +16,7 @@ test('withXuqmConfig schedules automatic initialization before the app entry', ( try { fs.mkdirSync(configDir, { recursive: true }) - fs.writeFileSync(path.join(configDir, 'app.xuqmconfig'), 'XUQM-CONFIG-V1.test', 'utf8') + fs.writeFileSync(path.join(configDir, 'app.xuqmconfig'), CONFIG_VECTOR, 'utf8') const config = withXuqmConfig({ projectRoot, @@ -33,7 +36,10 @@ test('withXuqmConfig schedules automatic initialization before the app entry', ( 'android', ) assert.equal(virtualModule.type, 'sourceFile') - assert.match(fs.readFileSync(virtualModule.filePath, 'utf8'), /XUQM-CONFIG-V1\.test/) + const generatedSource = fs.readFileSync(virtualModule.filePath, 'utf8') + assert.match(generatedSource, /RESOLVED_CONFIG/) + assert.match(generatedSource, /"appKey":"test-app"/) + assert.equal(generatedSource.includes('XUQM-CONFIG-V1'), false) } finally { fs.rmSync(projectRoot, { recursive: true, force: true }) } diff --git a/packages/common/tests/sdk.test.ts b/packages/common/tests/sdk.test.ts index 950408c..abfa81c 100644 --- a/packages/common/tests/sdk.test.ts +++ b/packages/common/tests/sdk.test.ts @@ -1,19 +1,25 @@ import assert from 'node:assert/strict' import test from 'node:test' -import { _initializeFromConfigFile, awaitInitialization } from '../src/sdk' - -const CONFIG_VECTOR = - 'XUQM-CONFIG-V1.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.vhdKh3AhEdeftXCdXp9KusVySkrBNAzmdaA747uf1tVWGFnAsJeCLe2mHKk0dxxez8SRTknIbk1UuRibnXY0Gbot1fmkR-jSN2ssMJO0_zQ8dUu8' +import { apiRequest } from '../src/http' +import { _initializeFromResolvedConfig, awaitInitialization } from '../src/sdk' test('awaitInitialization observes in-flight work and retries a transient config request failure', async () => { const originalFetch = globalThis.fetch let requestCount = 0 - globalThis.fetch = async () => { + const requestedUrls: string[] = [] + globalThis.fetch = async input => { requestCount += 1 + requestedUrls.push(String(input)) if (requestCount === 1) { throw new TypeError('Network request failed') } + if (requestCount === 3) { + return new Response(JSON.stringify({ data: { available: true } }), { + headers: { 'content-type': 'application/json' }, + status: 200, + }) + } return new Response( JSON.stringify({ data: { @@ -28,15 +34,28 @@ test('awaitInitialization observes in-flight work and retries a transient config } try { - const autoInitialization = _initializeFromConfigFile(CONFIG_VECTOR, { - debug: true, - }) + const autoInitialization = _initializeFromResolvedConfig( + { + appKey: 'test-app', + appName: '测试应用', + serverUrl: 'https://dev.xuqinmin.com', + signingKey: 'secret', + }, + { debug: true }, + ) await assert.rejects(() => awaitInitialization(), /Network request failed/) await assert.rejects(() => autoInitialization, /Network request failed/) // 配置内容由 common 保存,扩展包再次等待时可以自行重试,无需宿主传递配置。 await assert.doesNotReject(() => awaitInitialization()) assert.equal(requestCount, 2) + + const result = await apiRequest<{ available: boolean }>('/api/v1/rn/release-set/check', { + skipAuth: true, + }) + assert.deepEqual(result, { available: true }) + assert.equal(requestedUrls[2], 'https://dev.xuqinmin.com/api/v1/rn/release-set/check') + assert.equal(requestedUrls[2]?.includes('api.example.test'), false) } finally { globalThis.fetch = originalFetch } diff --git a/packages/update/README.md b/packages/update/README.md index af5c3b3..7717e6c 100644 --- a/packages/update/README.md +++ b/packages/update/README.md @@ -46,6 +46,28 @@ apply from: file("../../node_modules/@xuqm/rn-update/android/xuqm-bundles.gradle 该脚本关闭 React Native 默认单体 Bundle,避免宿主同时维护两套启动产物。 +多 Bundle 启动壳只需要查询原生分包路径时使用轻量子路径,禁止为了两个原生调用导入完整更新、网络与 release-set 实现: + +```ts +import { NativeBundle } from '@xuqm/rn-update/native-bundle' + +await NativeBundle.preparePendingRelease() +const commonPath = await NativeBundle.getLaunchPath('common') +const appPath = await NativeBundle.getLaunchPath('app') +``` + +启动壳只能依次准备并装载 common、app,不得读取 manifest 后枚举或释放 buz。普通业务继续从 `@xuqm/rn-update` 根入口使用 `UpdateSDK`/`XuqmRuntime`;该子路径只用于首屏启动壳和底层宿主编排,不是第二套状态或兼容 API。 + +多 bundle 宿主还必须在 common 入口保留轻量注册表: + +```ts +import '@xuqm/rn-update/plugin-registry' +``` + +`app` 与各 `buz` 拥有隔离的模块编号区间,common 中预注册的 `plugin-registry` 保证插件执行 `definePlugin()` 后,宿主 `XuqmRuntime` 读取到同一份定义。SDK 的构建/脚手架应生成该入口,业务项目不得自行复制注册表。 + +同一规则适用于跨 Bundle React Context 和具有原生注册副作用的依赖:Provider 在 app 挂载、由 buz 消费的 Context,以及 app/buz 都会使用的原生 View/Module,必须由 common 入口静态持有并分配共享编号。禁止在插件中复制 Context、注册表,或让多个 Bundle 分别执行同名原生组件注册。 + 插件产物的引擎只有一个事实来源:Android 宿主的 `android/gradle.properties`。当 `hermesEnabled=true` 时,CLI 自动使用与宿主 React Native 匹配的 `hermes-compiler` 将每个 startup/common/app/buz 编译成 Hermes bytecode,并组合 Metro/Hermes source map;无需也不允许在 @@ -53,6 +75,18 @@ startup/common/app/buz 编译成 Hermes bytecode,并组合 Metro/Hermes source ## 运行时规则 +Android 启动只把 APK 内嵌版本当作可恢复基线,不会每次重新读取或复制大 Bundle: + +- 原生模块每个进程只解析一次内嵌 `manifest.json`,随后以本地 `state.json` 为版本事实源。 +- `getBundleState()` 是纯状态查询:已有本地状态时直接读取,尚未访问的模块只从 manifest 返回内嵌基线元数据,不创建目录、不复制 Bundle。安装/恢复只允许由目标模块的 launch path 请求触发。 +- 本地下载版本 SemVer 大于等于内嵌版本,且 `nativeBaselineId`、`minNativeApiLevel` 和文件长度仍兼容时,直接返回现有 `active.bundle`。 +- 本地文件由当前 APK 内嵌基线产生时,版本相同仍比较 manifest SHA-256;这样开发期同版本的新 APK 可以替换旧基线,完全相同的 APK 则直接短路。 +- 只有首次安装、本地版本落后、原生基线不兼容、摘要/长度异常或 APK 内嵌内容变化时,才读取 APK 中的 Bundle,校验 SHA-256,并连同资源原子恢复。 + +因此“版本检查”不等于每次网络检查,也不等于解析 Bundle 内容。启动路径只做小状态读取;插件网络更新仍按下述入口策略执行。 + +上述恢复是严格的单模块按需操作:冷启动只触发 common、app;buz 在用户进入相应功能时才调用 `checkAndInstallPlugin(moduleId)` 和 `XuqmRuntime.activate(moduleId)`。健康的本地 buz 直接执行,缺失或不兼容时只处理目标 buz 与其 common 依赖,未访问插件不复制、不解压、不执行。 + 安装仅包含 buz 的 release set 后,使用 `XuqmRuntime.activate(moduleId, { reloadBundle: true })` 重新求值并激活新 bundle。若 release set 包含 common,宿主必须冷启动,不能在同一 JavaScript VM 中混用新旧 common。 @@ -99,7 +133,7 @@ const plan = await UpdateSDK.checkAndInstallPlugin('prescription', { }) ``` -SDK 会验证所有已安装模块在目标 common 下仍兼容,然后整组下载、校验、staging、激活、确认或回滚。Bundle 版本与事务状态只保存在原生 state;宿主不得另建 AsyncStorage 版本表,也不得逐层传递版本或路径状态。 +SDK 会验证所有已安装模块在目标 common 下仍兼容,并拒绝 `builtAgainstNativeBaselineId` 与当前完整包不一致的候选,然后整组下载、校验、staging、激活、确认或回滚。Bundle 版本与事务状态只保存在原生 state;宿主不得另建 AsyncStorage 版本表,也不得逐层传递版本或路径状态。 需要先展示插件更新提示的项目,分别调用纯检查和安装 API: diff --git a/packages/update/android/build.gradle b/packages/update/android/build.gradle index 4bfbc9a..87b8447 100644 --- a/packages/update/android/build.gradle +++ b/packages/update/android/build.gradle @@ -15,7 +15,7 @@ android { } repositories { - maven { url "https://nexus.xuqinmin.com/repository/android/" } + maven { url = uri("https://nexus.xuqinmin.com/repository/android/") } mavenCentral() google() } @@ -23,4 +23,5 @@ repositories { dependencies { implementation("com.facebook.react:react-android") implementation("com.xuqm:sdk-update:2.0.0-SNAPSHOT") + testImplementation("junit:junit:4.13.2") } diff --git a/packages/update/android/src/main/java/com/xuqm/update/XuqmBundleModule.java b/packages/update/android/src/main/java/com/xuqm/update/XuqmBundleModule.java index 8b5b8e8..7b53aae 100644 --- a/packages/update/android/src/main/java/com/xuqm/update/XuqmBundleModule.java +++ b/packages/update/android/src/main/java/com/xuqm/update/XuqmBundleModule.java @@ -1,8 +1,7 @@ package com.xuqm.update; import android.content.Context; -import android.system.ErrnoException; -import android.system.Os; +import android.util.Base64; import androidx.annotation.NonNull; @@ -21,22 +20,29 @@ import org.json.JSONObject; import java.io.ByteArrayOutputStream; import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; -import java.security.MessageDigest; import java.util.LinkedHashSet; import java.util.Locale; import java.util.Set; +import static com.xuqm.update.XuqmBundleStorage.atomicWrite; +import static com.xuqm.update.XuqmBundleStorage.deleteIfExists; +import static com.xuqm.update.XuqmBundleStorage.deleteRecursively; +import static com.xuqm.update.XuqmBundleStorage.readBytes; +import static com.xuqm.update.XuqmBundleStorage.sha256; +import static com.xuqm.update.XuqmBundleStorage.verifySha256; +import static com.xuqm.update.XuqmPluginPackageStager.areAssetsPresent; +import static com.xuqm.update.XuqmPluginPackageStager.assetPaths; +import static com.xuqm.update.XuqmPluginPackageStager.validateAssetPath; + /** Owns embedded recovery and transactional activation for every RN plugin bundle. */ @ReactModule(name = XuqmBundleModule.NAME) public final class XuqmBundleModule extends ReactContextBaseJavaModule { public static final String NAME = "XuqmBundleModule"; - private static final int NATIVE_API_LEVEL = 1; + private static final int NATIVE_API_LEVEL = 2; private static final String BUNDLE_DIR = "rn-bundles"; private static final String EMBEDDED_DIR = "rn-bundles"; private static final String ACTIVE_FILE = "active.bundle"; @@ -44,6 +50,13 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { private static final String ROLLBACK_FILE = "rollback.bundle"; private static final String STATE_FILE = "state.json"; private static final String RELEASE_STATE_FILE = "release-state.json"; + private static final String ACTIVE_ASSETS_DIR = "active-assets"; + private static final String STAGED_ASSETS_DIR = "staged-assets"; + private static final String ROLLBACK_ASSETS_DIR = "rollback-assets"; + private static final String PACKAGE_TEMP_DIR = "package-temp"; + + /** APK 内嵌 manifest 很小且在进程生命周期内不可变,只解析一次。 */ + private JSONObject embeddedManifestCache; public XuqmBundleModule(ReactApplicationContext reactContext) { super(reactContext); @@ -105,8 +118,10 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { @ReactMethod public void getBundleState(String moduleId, Promise promise) { try { - ensureEmbeddedBundle(moduleId); - promise.resolve(readState(moduleId).toString()); + // 版本检查需要知道全部已交付模块的版本,但读取状态绝不能顺带安装它们。 + // 未访问的 buz 直接从小型内嵌 manifest 构造只读状态,真正进入插件时 + // getLaunchBundlePath(moduleId) 才按需恢复 Bundle 与资源。 + promise.resolve(readBundleStateWithoutInstalling(moduleId).toString()); } catch (Exception error) { promise.reject("BUNDLE_STATE_ERROR", error.getMessage(), error); } @@ -126,6 +141,45 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { } } + @ReactMethod + public void getAssetIndex(Promise promise) { + try { + JSONObject index = new JSONObject(); + File root = new File(getReactApplicationContext().getFilesDir(), BUNDLE_DIR); + // nativeApiLevel=1 曾把每个模块的 drawable-* 直接复制到模块根目录, + // 导致 app/szyx/miniapp 各自残留整套重复图片。新版资源只允许存在于 + // active-assets 事务目录;首次刷新索引时顺带清理旧目录,不安装任何 buz。 + cleanupLegacyAssetDirectories(root); + File[] modules = root.listFiles(); + if (modules != null) { + for (File module : modules) { + if (!module.isDirectory()) continue; + collectAssetIndex(new File(module, ACTIVE_ASSETS_DIR), "", index); + } + } + promise.resolve(index.toString()); + } catch (Exception error) { + promise.reject("ASSET_INDEX_ERROR", error.getMessage(), error); + } + } + + private void cleanupLegacyAssetDirectories(File root) throws IOException { + File[] modules = root.listFiles(); + if (modules == null) return; + for (File module : modules) { + if (!module.isDirectory()) continue; + File[] children = module.listFiles(); + if (children == null) continue; + for (File child : children) { + if (!child.isDirectory()) continue; + String name = child.getName(); + if (name.startsWith("drawable-") || "raw".equals(name)) { + deleteRecursively(child); + } + } + } + } + @ReactMethod public void writeManifest(String manifestJson, Promise promise) { try { @@ -158,17 +212,27 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { if (module == null) throw new IllegalArgumentException("Invalid release module at " + index); String moduleId = requiredString(module, "moduleId"); String version = requiredString(module, "version"); - String source = requiredString(module, "source"); + String archiveBase64 = requiredString(module, "archiveBase64"); String sha256 = requiredString(module, "sha256"); validateModuleId(moduleId); - verifyHash(source.getBytes(StandardCharsets.UTF_8), sha256); - - File staged = new File(getModuleDir(moduleId), STAGED_FILE); - atomicWrite(staged, source.getBytes(StandardCharsets.UTF_8)); + stagedModules.add(moduleId); + byte[] archiveBytes = Base64.decode(archiveBase64, Base64.DEFAULT); + File moduleDirectory = getModuleDir(moduleId); + JSONObject packageManifest = XuqmPluginPackageStager.stage( + moduleId, + archiveBytes, + sha256, + module, + new File(moduleDirectory, PACKAGE_TEMP_DIR), + new File(moduleDirectory, STAGED_FILE), + new File(moduleDirectory, STAGED_ASSETS_DIR)); JSONObject state = readState(moduleId); state.put("moduleId", moduleId); state.put("pendingVersion", version); - state.put("pendingHash", sha256.toLowerCase(Locale.ROOT)); + state.put("pendingHash", packageManifest.getString("bundleSha256").toLowerCase(Locale.ROOT)); + state.put("pendingPackageHash", sha256.toLowerCase(Locale.ROOT)); + state.put("pendingByteLength", packageManifest.getLong("bundleByteLength")); + state.put("pendingAssetFiles", assetPaths(packageManifest)); state.put("pendingType", requiredString(module, "type")); state.put("pendingAppVersionRange", requiredString(module, "appVersionRange")); copyOptionalString(module, "builtAgainstNativeBaselineId", state, "pendingBuiltAgainstNativeBaselineId"); @@ -177,7 +241,6 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { state.put("releaseId", releaseId); state.put("status", "staged"); writeState(moduleId, state); - stagedModules.add(moduleId); moduleIds.put(moduleId); } @@ -308,7 +371,9 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { private void activateModule(String moduleId, String releaseId) throws Exception { File directory = getModuleDir(moduleId); File staged = new File(directory, STAGED_FILE); + File stagedAssets = new File(directory, STAGED_ASSETS_DIR); if (!staged.exists()) throw new IOException("No staged bundle for " + moduleId); + if (!stagedAssets.isDirectory()) throw new IOException("No staged assets for " + moduleId); JSONObject state = readState(moduleId); if (!releaseId.equals(state.optString("releaseId"))) { throw new IOException("Staged bundle does not belong to release " + releaseId); @@ -316,15 +381,29 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { File active = new File(directory, ACTIVE_FILE); File rollback = new File(directory, ROLLBACK_FILE); + File activeAssets = new File(directory, ACTIVE_ASSETS_DIR); + File rollbackAssets = new File(directory, ROLLBACK_ASSETS_DIR); boolean hadActive = active.exists(); deleteIfExists(rollback); + deleteRecursively(rollbackAssets); if (hadActive && !active.renameTo(rollback)) { throw new IOException("Unable to preserve active bundle for " + moduleId); } + if (activeAssets.exists() && !activeAssets.renameTo(rollbackAssets)) { + if (rollback.exists()) rollback.renameTo(active); + throw new IOException("Unable to preserve active assets for " + moduleId); + } if (!staged.renameTo(active)) { if (rollback.exists()) rollback.renameTo(active); + if (rollbackAssets.exists()) rollbackAssets.renameTo(activeAssets); throw new IOException("Unable to activate staged bundle for " + moduleId); } + if (!stagedAssets.renameTo(activeAssets)) { + deleteIfExists(active); + if (rollback.exists()) rollback.renameTo(active); + if (rollbackAssets.exists()) rollbackAssets.renameTo(activeAssets); + throw new IOException("Unable to activate staged assets for " + moduleId); + } if (hadActive) copyActiveToRollbackMetadata(state); else clearRollbackMetadata(state); @@ -342,43 +421,194 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { state.put("status", "active"); state.put("launchAttempts", 0); deleteIfExists(new File(getModuleDir(moduleId), ROLLBACK_FILE)); + deleteRecursively(new File(getModuleDir(moduleId), ROLLBACK_ASSETS_DIR)); writeState(moduleId, state); } private File ensureEmbeddedBundle(String moduleId) throws Exception { validateModuleId(moduleId); - File active = new File(getModuleDir(moduleId), ACTIVE_FILE); - if (active.exists()) return active; - + File moduleDirectory = getModuleDir(moduleId); + File active = new File(moduleDirectory, ACTIVE_FILE); JSONObject manifest = readEmbeddedManifest(); JSONObject modules = manifest.optJSONObject("modules"); JSONObject entry = modules == null ? null : modules.optJSONObject(moduleId); if (entry == null) return active; - String bundleFile = entry.optString("bundleFile", moduleId + ".android.bundle"); - byte[] bytes = readAsset(EMBEDDED_DIR + "/" + bundleFile); - atomicWrite(active, bytes); + String bundleFile = entry.optString("bundleFile", moduleId + ".android.bundle"); + String embeddedHash = entry.optString("sha256", ""); + + if (active.exists()) { + JSONObject state = readState(moduleId); + if (canLaunchInstalledBundle(moduleId, active, state, manifest, entry, embeddedHash)) { + return active; + } + } + + // 只有首次安装、内嵌基线升级、完整性异常或本地插件不兼容时才读取 APK + // 中的大 Bundle。正常冷启动只解析一次小 manifest 和本地 state。 + byte[] bytes = readAsset(EMBEDDED_DIR + "/" + bundleFile); + if (embeddedHash.isEmpty()) embeddedHash = sha256(bytes); + else verifySha256(bytes, embeddedHash); + atomicWrite(active, bytes); + File activeAssets = new File(moduleDirectory, ACTIVE_ASSETS_DIR); + deleteRecursively(activeAssets); + restoreEmbeddedAssets(activeAssets, entry.optJSONArray("assetFiles")); + writeEmbeddedState(moduleId, entry, embeddedHash, bytes.length); + return active; + } + + /** + * 本地下载版本不低于 APK 内嵌版本且仍兼容当前原生基线时优先启动本地版本; + * 由 APK 自己安装的版本则用 manifest 摘要短路,允许开发期同版本新构建替换。 + */ + private boolean canLaunchInstalledBundle( + String moduleId, + File active, + JSONObject state, + JSONObject manifest, + JSONObject entry, + String embeddedHash) throws Exception { + boolean pendingConfirmation = "pending_confirmation".equals(state.optString("status")); + String metadataPrefix = pendingConfirmation ? "pending" : "active"; + long recordedLength = state.optLong(metadataPrefix + "ByteLength", -1L); + if (recordedLength >= 0L && active.length() != recordedLength) return false; + JSONArray assetFiles = state.optJSONArray(metadataPrefix + "AssetFiles"); + if (!areAssetsPresent(new File(getModuleDir(moduleId), ACTIVE_ASSETS_DIR), assetFiles)) return false; + + boolean ownsEmbeddedBundle = "embedded".equals(state.optString("status")); + if (ownsEmbeddedBundle) { + if (!areAssetsPresent( + new File(getModuleDir(moduleId), ACTIVE_ASSETS_DIR), + entry.optJSONArray("assetFiles"))) return false; + if (embeddedHash.isEmpty() + || !embeddedHash.equalsIgnoreCase(state.optString("activeHash", ""))) { + return false; + } + long embeddedLength = entry.optLong("byteLength", -1L); + if (embeddedLength >= 0L && active.length() != embeddedLength) return false; + if (!isEmbeddedStateCurrent(state, manifest, entry, embeddedHash, active.length())) { + // 新版 state 字段或兼容元数据变化时只刷新小状态文件,不读取/复制 Bundle。 + writeEmbeddedState(moduleId, entry, embeddedHash, active.length()); + } + return true; + } + + String installedVersion = state.optString(metadataPrefix + "Version", ""); + String embeddedVersion = entry.optString("moduleVersion", "0.0.0"); + if (!XuqmSemanticVersion.isAtLeast(installedVersion, embeddedVersion)) return false; + + String currentBaseline = manifest.optString("nativeBaselineId", ""); + String installedBaseline = state.optString(metadataPrefix + "BuiltAgainstNativeBaselineId", ""); + if (!currentBaseline.isEmpty() && !currentBaseline.equals(installedBaseline)) { + return false; + } + if (state.optInt(metadataPrefix + "MinNativeApiLevel", 1) > NATIVE_API_LEVEL) return false; + if (recordedLength < 0L) { + state.put(metadataPrefix + "ByteLength", active.length()); + writeState(moduleId, state); + } + return true; + } + + private boolean isEmbeddedStateCurrent( + JSONObject state, + JSONObject manifest, + JSONObject entry, + String embeddedHash, + long byteLength) { + return entry.optString("moduleVersion", "0.0.0").equals(state.optString("activeVersion", "")) + && embeddedHash.equalsIgnoreCase(state.optString("activeHash", "")) + && byteLength == state.optLong("activeByteLength", -1L) + && entry.optString("type", "buz").equals(state.optString("activeType", "")) + && entry.optString("appVersionRange", "").equals(state.optString("activeAppVersionRange", "")) + && entry.optString("commonVersionRange", "").equals(state.optString("activeCommonVersionRange", "")) + && manifest.optString("nativeBaselineId", "") + .equals(state.optString("activeBuiltAgainstNativeBaselineId", "")) + && entry.optInt("minNativeApiLevel", 1) == state.optInt("activeMinNativeApiLevel", 1) + && jsonArraysEqual(entry.optJSONArray("assetFiles"), state.optJSONArray("activeAssetFiles")); + } + + private void writeEmbeddedState( + String moduleId, + JSONObject entry, + String embeddedHash, + long byteLength) throws Exception { + writeState( + moduleId, + createEmbeddedState(moduleId, entry, embeddedHash, byteLength)); + } + + private JSONObject createEmbeddedState( + String moduleId, + JSONObject entry, + String embeddedHash, + long byteLength) throws Exception { JSONObject state = new JSONObject(); state.put("moduleId", moduleId); state.put("status", "embedded"); state.put("activeVersion", entry.optString("moduleVersion", "0.0.0")); + if (!embeddedHash.isEmpty()) { + state.put("activeHash", embeddedHash.toLowerCase(Locale.ROOT)); + } + if (byteLength >= 0L) state.put("activeByteLength", byteLength); state.put("activeType", entry.optString("type", "buz")); copyJsonValue(entry, "commonVersionRange", state, "activeCommonVersionRange"); copyJsonValue(entry, "appVersionRange", state, "activeAppVersionRange"); copyJsonValue(entry, "builtAgainstNativeBaselineId", state, "activeBuiltAgainstNativeBaselineId"); copyJsonValue(entry, "minNativeApiLevel", state, "activeMinNativeApiLevel"); - writeState(moduleId, state); - return active; + state.put("activeAssetFiles", entry.optJSONArray("assetFiles") == null + ? new JSONArray() + : entry.optJSONArray("assetFiles")); + return state; } - private JSONObject readEmbeddedManifest() throws Exception { - try { - return new JSONObject(new String(readAsset(EMBEDDED_DIR + "/manifest.json"), StandardCharsets.UTF_8)); - } catch (IOException missing) { - return new JSONObject(); + /** + * 动态 loadBundle(file) 后,React Native 会从该 Bundle 所在目录解析 + * drawable-* 资源;因此恢复 Bundle 与恢复其资源必须属于同一个原生职责。 + */ + private void restoreEmbeddedAssets(File assetDirectory, JSONArray assetFiles) throws Exception { + if (assetFiles == null) return; + for (int index = 0; index < assetFiles.length(); index += 1) { + String relativePath = assetFiles.getString(index); + validateAssetPath(relativePath); + atomicWrite( + new File(assetDirectory, relativePath), + readAsset(EMBEDDED_DIR + "/" + relativePath)); } } + private boolean jsonArraysEqual(JSONArray left, JSONArray right) { + JSONArray normalizedLeft = left == null ? new JSONArray() : left; + JSONArray normalizedRight = right == null ? new JSONArray() : right; + return normalizedLeft.toString().equals(normalizedRight.toString()); + } + + private void collectAssetIndex(File directory, String prefix, JSONObject index) throws Exception { + File[] children = directory.listFiles(); + if (children == null) return; + for (File child : children) { + String relative = prefix.isEmpty() ? child.getName() : prefix + "/" + child.getName(); + if (child.isDirectory()) { + collectAssetIndex(child, relative, index); + } else { + validateAssetPath(relative); + if (index.has(relative)) throw new IOException("Duplicate active plugin asset: " + relative); + index.put(relative, child.toURI().toString()); + } + } + } + + private synchronized JSONObject readEmbeddedManifest() throws Exception { + if (embeddedManifestCache != null) return embeddedManifestCache; + try { + embeddedManifestCache = new JSONObject( + new String(readAsset(EMBEDDED_DIR + "/manifest.json"), StandardCharsets.UTF_8)); + } catch (IOException missing) { + embeddedManifestCache = new JSONObject(); + } + return embeddedManifestCache; + } + private JSONObject requireRelease(String releaseId) throws Exception { validateReleaseId(releaseId); JSONObject release = readReleaseStateOrNull(); @@ -408,16 +638,24 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { File directory = getModuleDir(moduleId); File active = new File(directory, ACTIVE_FILE); File rollback = new File(directory, ROLLBACK_FILE); + File activeAssets = new File(directory, ACTIVE_ASSETS_DIR); + File rollbackAssets = new File(directory, ROLLBACK_ASSETS_DIR); JSONObject state = readState(moduleId); if (rollback.exists()) { deleteIfExists(active); if (!rollback.renameTo(active)) throw new IOException("Unable to restore " + moduleId); + deleteRecursively(activeAssets); + if (rollbackAssets.exists() && !rollbackAssets.renameTo(activeAssets)) { + throw new IOException("Unable to restore assets for " + moduleId); + } moveRollbackToActiveMetadata(state); } else if ("pending_confirmation".equals(state.optString("status"))) { deleteIfExists(active); + deleteRecursively(activeAssets); clearActiveMetadata(state); } deleteIfExists(new File(directory, STAGED_FILE)); + deleteRecursively(new File(directory, STAGED_ASSETS_DIR)); clearPendingMetadata(state); clearRollbackMetadata(state); state.remove("releaseId"); @@ -429,6 +667,8 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { private void cleanupStaged(String moduleId) { try { deleteIfExists(new File(getModuleDir(moduleId), STAGED_FILE)); + deleteRecursively(new File(getModuleDir(moduleId), STAGED_ASSETS_DIR)); + deleteRecursively(new File(getModuleDir(moduleId), PACKAGE_TEMP_DIR)); JSONObject state = readState(moduleId); clearPendingMetadata(state); state.remove("releaseId"); @@ -464,6 +704,28 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { : new JSONObject().put("moduleId", moduleId).put("status", "embedded"); } + /** Reads installed state, or describes the APK baseline without creating a module directory. */ + private JSONObject readBundleStateWithoutInstalling(String moduleId) throws Exception { + validateModuleId(moduleId); + File moduleDirectory = new File(new File(getReactApplicationContext().getFilesDir(), BUNDLE_DIR), moduleId); + File stateFile = new File(moduleDirectory, STATE_FILE); + if (stateFile.exists()) { + return new JSONObject(new String(readBytes(stateFile), StandardCharsets.UTF_8)); + } + + JSONObject manifest = readEmbeddedManifest(); + JSONObject modules = manifest.optJSONObject("modules"); + JSONObject entry = modules == null ? null : modules.optJSONObject(moduleId); + if (entry == null) { + return new JSONObject().put("moduleId", moduleId).put("status", "missing"); + } + return createEmbeddedState( + moduleId, + entry, + entry.optString("sha256", ""), + entry.optLong("byteLength", -1L)); + } + private void writeState(String moduleId, JSONObject state) throws Exception { atomicWrite(new File(getModuleDir(moduleId), STATE_FILE), state.toString().getBytes(StandardCharsets.UTF_8)); } @@ -513,31 +775,40 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { private void copyActiveToRollbackMetadata(JSONObject state) throws Exception { copyJsonValue(state, "activeVersion", state, "rollbackVersion"); copyJsonValue(state, "activeHash", state, "rollbackHash"); + copyJsonValue(state, "activeByteLength", state, "rollbackByteLength"); copyJsonValue(state, "activeType", state, "rollbackType"); copyJsonValue(state, "activeAppVersionRange", state, "rollbackAppVersionRange"); copyJsonValue(state, "activeBuiltAgainstNativeBaselineId", state, "rollbackBuiltAgainstNativeBaselineId"); copyJsonValue(state, "activeCommonVersionRange", state, "rollbackCommonVersionRange"); copyJsonValue(state, "activeMinNativeApiLevel", state, "rollbackMinNativeApiLevel"); + copyJsonValue(state, "activePackageHash", state, "rollbackPackageHash"); + copyJsonValue(state, "activeAssetFiles", state, "rollbackAssetFiles"); } private void movePendingToActiveMetadata(JSONObject state) throws Exception { moveJsonValue(state, "pendingVersion", "activeVersion"); moveJsonValue(state, "pendingHash", "activeHash"); + moveJsonValue(state, "pendingByteLength", "activeByteLength"); moveJsonValue(state, "pendingType", "activeType"); moveJsonValue(state, "pendingAppVersionRange", "activeAppVersionRange"); moveJsonValue(state, "pendingBuiltAgainstNativeBaselineId", "activeBuiltAgainstNativeBaselineId"); moveJsonValue(state, "pendingCommonVersionRange", "activeCommonVersionRange"); moveJsonValue(state, "pendingMinNativeApiLevel", "activeMinNativeApiLevel"); + moveJsonValue(state, "pendingPackageHash", "activePackageHash"); + moveJsonValue(state, "pendingAssetFiles", "activeAssetFiles"); } private void moveRollbackToActiveMetadata(JSONObject state) throws Exception { moveJsonValue(state, "rollbackVersion", "activeVersion"); moveJsonValue(state, "rollbackHash", "activeHash"); + moveJsonValue(state, "rollbackByteLength", "activeByteLength"); moveJsonValue(state, "rollbackType", "activeType"); moveJsonValue(state, "rollbackAppVersionRange", "activeAppVersionRange"); moveJsonValue(state, "rollbackBuiltAgainstNativeBaselineId", "activeBuiltAgainstNativeBaselineId"); moveJsonValue(state, "rollbackCommonVersionRange", "activeCommonVersionRange"); moveJsonValue(state, "rollbackMinNativeApiLevel", "activeMinNativeApiLevel"); + moveJsonValue(state, "rollbackPackageHash", "activePackageHash"); + moveJsonValue(state, "rollbackAssetFiles", "activeAssetFiles"); } private void moveJsonValue(JSONObject state, String source, String target) throws Exception { @@ -546,15 +817,15 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { } private void clearActiveMetadata(JSONObject state) { - for (String key : new String[]{"activeVersion", "activeHash", "activeType", "activeAppVersionRange", "activeBuiltAgainstNativeBaselineId", "activeCommonVersionRange", "activeMinNativeApiLevel"}) state.remove(key); + for (String key : new String[]{"activeVersion", "activeHash", "activeByteLength", "activeType", "activeAppVersionRange", "activeBuiltAgainstNativeBaselineId", "activeCommonVersionRange", "activeMinNativeApiLevel", "activePackageHash", "activeAssetFiles"}) state.remove(key); } private void clearPendingMetadata(JSONObject state) { - for (String key : new String[]{"pendingVersion", "pendingHash", "pendingType", "pendingAppVersionRange", "pendingBuiltAgainstNativeBaselineId", "pendingCommonVersionRange", "pendingMinNativeApiLevel"}) state.remove(key); + for (String key : new String[]{"pendingVersion", "pendingHash", "pendingByteLength", "pendingType", "pendingAppVersionRange", "pendingBuiltAgainstNativeBaselineId", "pendingCommonVersionRange", "pendingMinNativeApiLevel", "pendingPackageHash", "pendingAssetFiles"}) state.remove(key); } private void clearRollbackMetadata(JSONObject state) { - for (String key : new String[]{"rollbackVersion", "rollbackHash", "rollbackType", "rollbackAppVersionRange", "rollbackBuiltAgainstNativeBaselineId", "rollbackCommonVersionRange", "rollbackMinNativeApiLevel"}) state.remove(key); + for (String key : new String[]{"rollbackVersion", "rollbackHash", "rollbackByteLength", "rollbackType", "rollbackAppVersionRange", "rollbackBuiltAgainstNativeBaselineId", "rollbackCommonVersionRange", "rollbackMinNativeApiLevel", "rollbackPackageHash", "rollbackAssetFiles"}) state.remove(key); } private void validateModuleId(String moduleId) { @@ -569,23 +840,6 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { } } - private void verifyHash(byte[] bytes, String expected) throws Exception { - if (expected == null || !expected.matches("(?i)^[a-f0-9]{64}$")) { - throw new IOException("A valid SHA-256 is required"); - } - String actual = digest("SHA-256", bytes); - if (!actual.equalsIgnoreCase(expected)) { - throw new IOException("Bundle SHA-256 mismatch. Expected " + expected + " but got " + actual); - } - } - - private String digest(String algorithm, byte[] bytes) throws Exception { - byte[] result = MessageDigest.getInstance(algorithm).digest(bytes); - StringBuilder output = new StringBuilder(result.length * 2); - for (byte value : result) output.append(String.format(Locale.ROOT, "%02x", value)); - return output.toString(); - } - private byte[] readAsset(String path) throws IOException { try (InputStream input = getReactApplicationContext().getAssets().open(path); ByteArrayOutputStream output = new ByteArrayOutputStream()) { @@ -596,41 +850,4 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule { } } - private byte[] readBytes(File file) throws IOException { - try (FileInputStream input = new FileInputStream(file); - ByteArrayOutputStream output = new ByteArrayOutputStream((int) file.length())) { - byte[] buffer = new byte[8192]; - int count; - while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count); - return output.toByteArray(); - } - } - - private void atomicWrite(File target, byte[] bytes) throws IOException { - File parent = target.getParentFile(); - if (parent != null && !parent.exists() && !parent.mkdirs()) throw new IOException("Unable to create " + parent); - File temporary = new File(target.getAbsolutePath() + ".tmp"); - try (FileOutputStream output = new FileOutputStream(temporary)) { - output.write(bytes); - output.flush(); - output.getFD().sync(); - } - try { - Os.rename(temporary.getAbsolutePath(), target.getAbsolutePath()); - } catch (ErrnoException error) { - deleteIfExists(temporary); - throw new IOException("Unable to atomically replace " + target, error); - } - } - - private void deleteIfExists(File file) throws IOException { - if (file.exists() && !file.delete()) throw new IOException("Unable to delete " + file); - } - - private void deleteRecursively(File file) throws IOException { - if (!file.exists()) return; - File[] children = file.listFiles(); - if (children != null) for (File child : children) deleteRecursively(child); - deleteIfExists(file); - } } diff --git a/packages/update/android/src/main/java/com/xuqm/update/XuqmBundleStorage.java b/packages/update/android/src/main/java/com/xuqm/update/XuqmBundleStorage.java new file mode 100644 index 0000000..1f62f28 --- /dev/null +++ b/packages/update/android/src/main/java/com/xuqm/update/XuqmBundleStorage.java @@ -0,0 +1,91 @@ +package com.xuqm.update; + +import android.system.ErrnoException; +import android.system.Os; + +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.FileOutputStream; +import java.io.IOException; +import java.security.MessageDigest; +import java.util.Locale; + +/** + * Bundle 文件系统的唯一底层实现。 + * + *

发布事务、内嵌恢复和插件包暂存必须共用这里的摘要、路径与原子写入语义,避免不同流程 + * 各自维护一套看似相同但边界不同的文件操作。 + */ +final class XuqmBundleStorage { + + private XuqmBundleStorage() {} + + static void verifySha256(byte[] bytes, String expected) throws Exception { + if (expected == null || !expected.matches("(?i)^[a-f0-9]{64}$")) { + throw new IOException("A valid SHA-256 is required"); + } + String actual = sha256(bytes); + if (!actual.equalsIgnoreCase(expected)) { + throw new IOException("Bundle SHA-256 mismatch. Expected " + expected + " but got " + actual); + } + } + + static String sha256(byte[] bytes) throws Exception { + byte[] result = MessageDigest.getInstance("SHA-256").digest(bytes); + StringBuilder output = new StringBuilder(result.length * 2); + for (byte value : result) output.append(String.format(Locale.ROOT, "%02x", value)); + return output.toString(); + } + + static File safeFile(File root, String relativePath) throws IOException { + File file = new File(root, relativePath).getCanonicalFile(); + File canonicalRoot = root.getCanonicalFile(); + if (!file.getPath().startsWith(canonicalRoot.getPath() + File.separator)) { + throw new IOException("Plugin package path escapes staging directory: " + relativePath); + } + return file; + } + + static byte[] readBytes(File file) throws IOException { + try (FileInputStream input = new FileInputStream(file); + ByteArrayOutputStream output = new ByteArrayOutputStream((int) file.length())) { + byte[] buffer = new byte[8192]; + int count; + while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count); + return output.toByteArray(); + } + } + + static void atomicWrite(File target, byte[] bytes) throws IOException { + File parent = target.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IOException("Unable to create " + parent); + } + File temporary = new File(target.getAbsolutePath() + ".tmp"); + try (FileOutputStream output = new FileOutputStream(temporary)) { + output.write(bytes); + output.flush(); + output.getFD().sync(); + } + try { + Os.rename(temporary.getAbsolutePath(), target.getAbsolutePath()); + } catch (ErrnoException error) { + deleteIfExists(temporary); + throw new IOException("Unable to atomically replace " + target, error); + } + } + + static void deleteIfExists(File file) throws IOException { + if (file.exists() && !file.delete()) throw new IOException("Unable to delete " + file); + } + + static void deleteRecursively(File file) throws IOException { + if (!file.exists()) return; + File[] children = file.listFiles(); + if (children != null) { + for (File child : children) deleteRecursively(child); + } + deleteIfExists(file); + } +} diff --git a/packages/update/android/src/main/java/com/xuqm/update/XuqmPluginPackageStager.java b/packages/update/android/src/main/java/com/xuqm/update/XuqmPluginPackageStager.java new file mode 100644 index 0000000..95d6300 --- /dev/null +++ b/packages/update/android/src/main/java/com/xuqm/update/XuqmPluginPackageStager.java @@ -0,0 +1,243 @@ +package com.xuqm.update; + +import com.facebook.react.bridge.ReadableMap; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.io.ByteArrayInputStream; +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; + +/** 校验并暂存一个完整的 Android RN 插件包,不参与发布集的激活与回滚编排。 */ +final class XuqmPluginPackageStager { + + private static final int MAX_ARCHIVE_BYTES = 50 * 1024 * 1024; + private static final long MAX_UNCOMPRESSED_BYTES = 100L * 1024L * 1024L; + private static final int MAX_ARCHIVE_ENTRIES = 5000; + + private XuqmPluginPackageStager() {} + + static JSONObject stage( + String moduleId, + byte[] archiveBytes, + String expectedPackageHash, + ReadableMap candidate, + File packageDirectory, + File stagedBundle, + File stagedAssets) throws Exception { + if (archiveBytes.length == 0 || archiveBytes.length > MAX_ARCHIVE_BYTES) { + throw new IOException("Invalid plugin package size for " + moduleId); + } + XuqmBundleStorage.verifySha256(archiveBytes, expectedPackageHash); + XuqmBundleStorage.deleteRecursively(packageDirectory); + XuqmBundleStorage.deleteRecursively(stagedAssets); + if (!packageDirectory.mkdirs() || !stagedAssets.mkdirs()) { + throw new IOException("Unable to create plugin staging directories for " + moduleId); + } + + Set archiveEntries = extractArchive(moduleId, archiveBytes, packageDirectory); + JSONObject manifest = readAndValidateManifest(moduleId, packageDirectory, candidate); + stageBundle(moduleId, packageDirectory, stagedBundle, manifest); + stageAssets(packageDirectory, stagedAssets, archiveEntries, manifest); + XuqmBundleStorage.deleteRecursively(packageDirectory); + return manifest; + } + + static JSONArray assetPaths(JSONObject manifest) throws Exception { + JSONArray paths = new JSONArray(); + JSONArray assets = manifest.optJSONArray("assets"); + if (assets == null) return paths; + for (int index = 0; index < assets.length(); index += 1) { + paths.put(assets.getJSONObject(index).getString("path")); + } + return paths; + } + + static boolean areAssetsPresent(File root, JSONArray files) { + if (files == null) return true; + try { + for (int index = 0; index < files.length(); index += 1) { + String relativePath = files.getString(index); + validateAssetPath(relativePath); + if (!XuqmBundleStorage.safeFile(root, relativePath).isFile()) return false; + } + return true; + } catch (Exception error) { + return false; + } + } + + static void validateAssetPath(String relativePath) { + if (relativePath == null + || !relativePath.matches("^(?:drawable-[a-z0-9-]+|raw)/[a-z0-9_]+\\.[a-z0-9]+$")) { + throw new IllegalArgumentException("Invalid embedded asset path: " + relativePath); + } + } + + private static Set extractArchive( + String moduleId, + byte[] archiveBytes, + File packageDirectory) throws Exception { + Set archiveEntries = new LinkedHashSet<>(); + long uncompressedBytes = 0L; + int entryCount = 0; + try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(archiveBytes))) { + ZipEntry entry; + byte[] buffer = new byte[8192]; + while ((entry = input.getNextEntry()) != null) { + if (entry.isDirectory()) continue; + entryCount += 1; + if (entryCount > MAX_ARCHIVE_ENTRIES) { + throw new IOException("Plugin package has too many files"); + } + String name = entry.getName(); + validateArchiveEntry(moduleId, name); + if (!archiveEntries.add(name)) { + throw new IOException("Duplicate plugin package entry: " + name); + } + File destination = XuqmBundleStorage.safeFile(packageDirectory, name); + File parent = destination.getParentFile(); + if (parent != null && !parent.exists() && !parent.mkdirs()) { + throw new IOException("Unable to create package directory " + parent); + } + try (FileOutputStream output = new FileOutputStream(destination)) { + int count; + while ((count = input.read(buffer)) != -1) { + uncompressedBytes += count; + if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) { + throw new IOException("Plugin package exceeds the uncompressed size limit"); + } + output.write(buffer, 0, count); + } + } + } + } + return archiveEntries; + } + + private static JSONObject readAndValidateManifest( + String moduleId, + File packageDirectory, + ReadableMap candidate) throws Exception { + File manifestFile = new File(packageDirectory, "rn-manifest.json"); + if (!manifestFile.isFile()) throw new IOException("Plugin package manifest is missing"); + JSONObject manifest = new JSONObject( + new String(XuqmBundleStorage.readBytes(manifestFile), StandardCharsets.UTF_8)); + if (manifest.optInt("schemaVersion", 0) != 1) { + throw new IOException("Unsupported plugin package schema"); + } + if (!moduleId.equals(manifest.optString("moduleId")) + || !"android".equals(manifest.optString("platform"))) { + throw new IOException("Plugin package identity does not match " + moduleId + "/android"); + } + validateCandidate(candidate, manifest); + return manifest; + } + + private static void stageBundle( + String moduleId, + File packageDirectory, + File stagedBundle, + JSONObject manifest) throws Exception { + String bundleFile = manifest.getString("bundleFile"); + if (!bundleFile.equals(moduleId + ".android.bundle")) { + throw new IOException("Unexpected bundle file in plugin package: " + bundleFile); + } + File packagedBundle = XuqmBundleStorage.safeFile(packageDirectory, bundleFile); + if (!packagedBundle.isFile()) throw new IOException("Plugin bundle is missing: " + bundleFile); + byte[] bundleBytes = XuqmBundleStorage.readBytes(packagedBundle); + if (bundleBytes.length != manifest.getLong("bundleByteLength")) { + throw new IOException("Plugin bundle length does not match its manifest"); + } + XuqmBundleStorage.verifySha256(bundleBytes, manifest.getString("bundleSha256")); + XuqmBundleStorage.atomicWrite(stagedBundle, bundleBytes); + } + + private static void stageAssets( + File packageDirectory, + File stagedAssets, + Set archiveEntries, + JSONObject manifest) throws Exception { + JSONArray assets = manifest.optJSONArray("assets"); + if (assets == null) assets = new JSONArray(); + Set declaredAssets = new LinkedHashSet<>(); + for (int index = 0; index < assets.length(); index += 1) { + JSONObject asset = assets.getJSONObject(index); + String relativePath = asset.getString("path"); + validateAssetPath(relativePath); + if (!declaredAssets.add(relativePath)) { + throw new IOException("Duplicate plugin asset: " + relativePath); + } + File packagedAsset = XuqmBundleStorage.safeFile(packageDirectory, "assets/" + relativePath); + if (!packagedAsset.isFile()) throw new IOException("Plugin asset is missing: " + relativePath); + byte[] bytes = XuqmBundleStorage.readBytes(packagedAsset); + if (bytes.length != asset.getLong("byteLength")) { + throw new IOException("Plugin asset length mismatch: " + relativePath); + } + XuqmBundleStorage.verifySha256(bytes, asset.getString("sha256")); + XuqmBundleStorage.atomicWrite(new File(stagedAssets, relativePath), bytes); + } + for (String archiveEntry : archiveEntries) { + if (archiveEntry.startsWith("assets/") + && !declaredAssets.contains(archiveEntry.substring("assets/".length()))) { + throw new IOException("Plugin package contains an undeclared asset: " + archiveEntry); + } + } + } + + private static void validateCandidate(ReadableMap candidate, JSONObject manifest) throws Exception { + requireEqual(candidate, manifest, "moduleId"); + requireEqual(candidate, manifest, "type"); + requireEqual(candidate, manifest, "version"); + requireEqual(candidate, manifest, "appVersionRange"); + requireOptionalEqual(candidate, manifest, "builtAgainstNativeBaselineId"); + requireOptionalEqual(candidate, manifest, "commonVersionRange"); + if (candidate.hasKey("minNativeApiLevel") && !candidate.isNull("minNativeApiLevel") + && candidate.getInt("minNativeApiLevel") != manifest.optInt("minNativeApiLevel", 1)) { + throw new IOException("Plugin package minNativeApiLevel does not match the release candidate"); + } + } + + private static void requireEqual(ReadableMap candidate, JSONObject manifest, String key) throws Exception { + String expected = requiredString(candidate, key); + if (!expected.equals(manifest.optString(key))) { + throw new IOException("Plugin package " + key + " does not match the release candidate"); + } + } + + private static void requireOptionalEqual(ReadableMap candidate, JSONObject manifest, String key) + throws Exception { + String expected = candidate.hasKey(key) && !candidate.isNull(key) ? candidate.getString(key) : null; + String actual = manifest.has(key) && !manifest.isNull(key) ? manifest.getString(key) : null; + if (expected == null ? actual != null : !expected.equals(actual)) { + throw new IOException("Plugin package " + key + " does not match the release candidate"); + } + } + + private static String requiredString(ReadableMap map, String key) { + if (!map.hasKey(key) || map.isNull(key)) { + throw new IllegalArgumentException("Missing required field: " + key); + } + String value = map.getString(key); + if (value == null || value.trim().isEmpty()) { + throw new IllegalArgumentException("Invalid required field: " + key); + } + return value; + } + + private static void validateArchiveEntry(String moduleId, String name) { + if ("rn-manifest.json".equals(name) || (moduleId + ".android.bundle").equals(name)) return; + if (name != null && name.startsWith("assets/")) { + validateAssetPath(name.substring("assets/".length())); + return; + } + throw new IllegalArgumentException("Invalid plugin package entry: " + name); + } +} diff --git a/packages/update/android/src/main/java/com/xuqm/update/XuqmSemanticVersion.java b/packages/update/android/src/main/java/com/xuqm/update/XuqmSemanticVersion.java new file mode 100644 index 0000000..697db63 --- /dev/null +++ b/packages/update/android/src/main/java/com/xuqm/update/XuqmSemanticVersion.java @@ -0,0 +1,85 @@ +package com.xuqm.update; + +import java.math.BigInteger; + +/** + * Android 原生 Bundle 启动门禁使用的唯一 SemVer 2.0 优先级实现。 + * + *

版本判断属于存储协议,不应散落在 React Native Bridge 的安装流程中。构建元数据不参与 + * 优先级;非法版本由调用方按“不可信本地 Bundle”处理并恢复 APK 基线。 + */ +final class XuqmSemanticVersion { + + private XuqmSemanticVersion() {} + + static boolean isAtLeast(String installed, String baseline) { + try { + return compare(installed, baseline) >= 0; + } catch (IllegalArgumentException invalidVersion) { + return false; + } + } + + /** SemVer 2.0 precedence comparison; build metadata does not affect precedence. */ + static int compare(String left, String right) { + String[] leftVersion = parts(left); + String[] rightVersion = parts(right); + String[] leftCore = leftVersion[0].split("\\.", -1); + String[] rightCore = rightVersion[0].split("\\.", -1); + if (leftCore.length != 3 || rightCore.length != 3) { + throw new IllegalArgumentException("Semantic version requires major.minor.patch"); + } + for (int index = 0; index < 3; index += 1) { + int compared = numericIdentifier(leftCore[index]).compareTo(numericIdentifier(rightCore[index])); + if (compared != 0) return compared; + } + + String leftPreRelease = leftVersion[1]; + String rightPreRelease = rightVersion[1]; + if (leftPreRelease.isEmpty() && rightPreRelease.isEmpty()) return 0; + if (leftPreRelease.isEmpty()) return 1; + if (rightPreRelease.isEmpty()) return -1; + + String[] leftIdentifiers = leftPreRelease.split("\\.", -1); + String[] rightIdentifiers = rightPreRelease.split("\\.", -1); + int sharedLength = Math.min(leftIdentifiers.length, rightIdentifiers.length); + for (int index = 0; index < sharedLength; index += 1) { + String leftIdentifier = leftIdentifiers[index]; + String rightIdentifier = rightIdentifiers[index]; + if (!leftIdentifier.matches("[0-9A-Za-z-]+") + || !rightIdentifier.matches("[0-9A-Za-z-]+")) { + throw new IllegalArgumentException("Invalid semantic version pre-release identifier"); + } + boolean leftNumeric = leftIdentifier.matches("[0-9]+"); + boolean rightNumeric = rightIdentifier.matches("[0-9]+"); + int compared; + if (leftNumeric && rightNumeric) { + compared = numericIdentifier(leftIdentifier).compareTo(numericIdentifier(rightIdentifier)); + } else if (leftNumeric) { + compared = -1; + } else if (rightNumeric) { + compared = 1; + } else { + compared = leftIdentifier.compareTo(rightIdentifier); + } + if (compared != 0) return compared; + } + return Integer.compare(leftIdentifiers.length, rightIdentifiers.length); + } + + private static String[] parts(String version) { + if (version == null || version.trim().isEmpty()) { + throw new IllegalArgumentException("Semantic version is required"); + } + String withoutBuild = version.trim().split("\\+", 2)[0]; + String[] versionParts = withoutBuild.split("-", 2); + return new String[]{versionParts[0], versionParts.length == 2 ? versionParts[1] : ""}; + } + + private static BigInteger numericIdentifier(String value) { + if (value == null || !value.matches("0|[1-9][0-9]*")) { + throw new IllegalArgumentException("Invalid numeric semantic version identifier"); + } + return new BigInteger(value); + } +} diff --git a/packages/update/android/src/test/java/com/xuqm/update/XuqmBundleStorageTest.java b/packages/update/android/src/test/java/com/xuqm/update/XuqmBundleStorageTest.java new file mode 100644 index 0000000..70203d9 --- /dev/null +++ b/packages/update/android/src/test/java/com/xuqm/update/XuqmBundleStorageTest.java @@ -0,0 +1,26 @@ +package com.xuqm.update; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; + +public final class XuqmBundleStorageTest { + + @Test + public void sha256UsesTheCanonicalLowercaseEncoding() throws Exception { + assertEquals( + "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad", + XuqmBundleStorage.sha256("abc".getBytes(StandardCharsets.UTF_8))); + } + + @Test + public void safeFileRejectsTraversalOutsideTheStagingRoot() throws Exception { + File root = new File(System.getProperty("java.io.tmpdir"), "xuqm-storage-test"); + assertThrows(IOException.class, () -> XuqmBundleStorage.safeFile(root, "../outside.bundle")); + } +} diff --git a/packages/update/android/src/test/java/com/xuqm/update/XuqmPluginPackageStagerTest.java b/packages/update/android/src/test/java/com/xuqm/update/XuqmPluginPackageStagerTest.java new file mode 100644 index 0000000..222f066 --- /dev/null +++ b/packages/update/android/src/test/java/com/xuqm/update/XuqmPluginPackageStagerTest.java @@ -0,0 +1,21 @@ +package com.xuqm.update; + +import static org.junit.Assert.assertThrows; + +import org.junit.Test; + +public final class XuqmPluginPackageStagerTest { + + @Test + public void acceptsOnlyPackagedAndroidResourcePaths() { + XuqmPluginPackageStager.validateAssetPath("drawable-xhdpi/logo.png"); + XuqmPluginPackageStager.validateAssetPath("raw/plugin_data.json"); + + assertThrows( + IllegalArgumentException.class, + () -> XuqmPluginPackageStager.validateAssetPath("../drawable-xhdpi/logo.png")); + assertThrows( + IllegalArgumentException.class, + () -> XuqmPluginPackageStager.validateAssetPath("assets/free-form-name.png")); + } +} diff --git a/packages/update/android/src/test/java/com/xuqm/update/XuqmSemanticVersionTest.java b/packages/update/android/src/test/java/com/xuqm/update/XuqmSemanticVersionTest.java new file mode 100644 index 0000000..600dad7 --- /dev/null +++ b/packages/update/android/src/test/java/com/xuqm/update/XuqmSemanticVersionTest.java @@ -0,0 +1,42 @@ +package com.xuqm.update; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +import org.junit.Test; + +public final class XuqmSemanticVersionTest { + + @Test + public void followsSemVerPrereleasePrecedence() { + String[] ordered = { + "1.0.0-alpha", + "1.0.0-alpha.1", + "1.0.0-alpha.beta", + "1.0.0-beta", + "1.0.0-beta.2", + "1.0.0-beta.11", + "1.0.0-rc.1", + "1.0.0" + }; + for (int index = 1; index < ordered.length; index += 1) { + assertTrue(XuqmSemanticVersion.compare(ordered[index], ordered[index - 1]) > 0); + } + } + + @Test + public void ignoresBuildMetadataAndSupportsLargeIdentifiers() { + assertTrue(XuqmSemanticVersion.compare("1.2.3+app4", "1.2.3+sdk") == 0); + assertTrue( + XuqmSemanticVersion.compare( + "999999999999999999999.0.0", "999999999999999999998.999.999") + > 0); + } + + @Test + public void invalidInstalledVersionNeverPassesTheNativeGate() { + assertFalse(XuqmSemanticVersion.isAtLeast("", "1.0.0")); + assertFalse(XuqmSemanticVersion.isAtLeast("01.0.0", "1.0.0")); + assertFalse(XuqmSemanticVersion.isAtLeast("1.0", "1.0.0")); + } +} diff --git a/packages/update/android/xuqm-bundles.gradle b/packages/update/android/xuqm-bundles.gradle index 5a30536..c19363c 100644 --- a/packages/update/android/xuqm-bundles.gradle +++ b/packages/update/android/xuqm-bundles.gradle @@ -1,10 +1,13 @@ import javax.inject.Inject +import groovy.json.JsonSlurper import org.gradle.api.DefaultTask +import org.gradle.api.file.ConfigurableFileCollection import org.gradle.api.file.DirectoryProperty import org.gradle.api.file.RegularFileProperty import org.gradle.api.provider.Property import org.gradle.api.tasks.Input import org.gradle.api.tasks.InputFile +import org.gradle.api.tasks.InputFiles import org.gradle.api.tasks.Internal import org.gradle.api.tasks.OutputDirectory import org.gradle.api.tasks.PathSensitive @@ -37,6 +40,14 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask { @PathSensitive(PathSensitivity.RELATIVE) abstract RegularFileProperty getPackageFile() + /** + * RN/原生源码与打包配置共同决定 bundle、资源和 native baseline。 + * 显式建模后 Gradle 才能安全复用上次输出,禁止用永久 out-of-date 代替依赖声明。 + */ + @InputFiles + @PathSensitive(PathSensitivity.RELATIVE) + abstract ConfigurableFileCollection getSourceFiles() + @Input abstract Property getNodeExecutable() @@ -44,14 +55,18 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask { abstract Property getAppVersion() @OutputDirectory - abstract DirectoryProperty getOutputDirectory() + abstract DirectoryProperty getAssetsOutputDirectory() + + @OutputDirectory + abstract DirectoryProperty getResourcesOutputDirectory() @Inject abstract ExecOperations getExecOperations() @TaskAction void generate() { - def embeddedBundles = outputDirectory.get().dir("rn-bundles").asFile + def embeddedBundles = assetsOutputDirectory.get().dir("rn-bundles").asFile + def resources = resourcesOutputDirectory.get().asFile execOperations.exec { workingDir(projectRootDirectory.get().asFile) commandLine( @@ -65,6 +80,18 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask { appVersion.get(), ) }.assertNormalExitValue() + + // Android 内嵌 Bundle 通过 AssetManager 读取,但 require() 图片必须同时进入 + // aapt2 的 drawable 资源表。只把 drawable-* 放进 assets 会导致图片静默为空。 + project.delete(resources) + embeddedBundles.listFiles()?.findAll { + it.isDirectory() && it.name.startsWith("drawable-") + }?.each { drawableDirectory -> + project.copy { + from(drawableDirectory) + into(new File(resources, drawableDirectory.name)) + } + } } } @@ -73,6 +100,10 @@ if (!plugins.hasPlugin("com.android.application")) { } def xuqmProjectRoot = rootProject.projectDir.parentFile +def xuqmHostPackage = new JsonSlurper().parse(new File(xuqmProjectRoot, "package.json")) +def xuqmLocalDependencyDirectories = (xuqmHostPackage.dependencies ?: [:]) + .findAll { _, requested -> requested instanceof String && requested.startsWith("file:") } + .collect { _, requested -> new File(xuqmProjectRoot, requested.substring("file:".length())).canonicalFile } def xuqmUseMetro = providers.gradleProperty("USE_METRO") .orElse(providers.environmentVariable("USE_METRO")) .map { it.toBoolean() } @@ -88,6 +119,42 @@ def prepareXuqmBundles = tasks.register( cliFile.set(new File(xuqmProjectRoot, "node_modules/@xuqm/rn-update/scripts/xuqm-rn.mjs")) configFile.set(new File(xuqmProjectRoot, "xuqm.config.json")) packageFile.set(new File(xuqmProjectRoot, "package.json")) + sourceFiles.from(project.fileTree(xuqmProjectRoot) { + include("src/**") + include("assets/**") + include("android/**") + include("index.*") + include("babel.config.*") + include("metro.config.*") + include("react-native.config.*") + include("tsconfig*.json") + include("pnpm-lock.yaml") + include("yarn.lock") + exclude("android/**/build/**") + exclude("android/**/.cxx/**") + exclude("android/**/.gradle/**") + exclude("android/app/src/**/generated/**") + }) + sourceFiles.from(project.fileTree(new File( + xuqmProjectRoot, + "node_modules/@xuqm/rn-update/scripts", + )) { + include("*.mjs") + }) + // registry 依赖由不可变版本号进入 baseline;file: 依赖在版本不变时也可能修改 + // 原生源码,必须同时成为 Gradle 输入和 native baseline 内容。 + xuqmLocalDependencyDirectories.each { dependencyDirectory -> + sourceFiles.from(project.fileTree(dependencyDirectory) { + include("android/**") + include("ios/**") + include("*.podspec") + include("package.json") + exclude("**/build/**") + exclude("**/.cxx/**") + exclude("**/.gradle/**") + exclude("**/generated/**") + }) + } nodeExecutable.set(System.getenv("NODE_BINARY") ?: "node") appVersion.set(providers.provider { def version = android.defaultConfig.versionName @@ -96,15 +163,19 @@ def prepareXuqmBundles = tasks.register( } version.toString() }) - outputDirectory.set(layout.buildDirectory.dir("generated/xuqm/assets")) - outputs.upToDateWhen { false } + assetsOutputDirectory.set(layout.buildDirectory.dir("generated/xuqm/assets")) + resourcesOutputDirectory.set(layout.buildDirectory.dir("generated/xuqm/res")) } androidComponents.onVariants(androidComponents.selector().all()) { variant -> if (variant.buildType == "release" || !xuqmUseMetro.get()) { variant.sources.assets?.addGeneratedSourceDirectory( prepareXuqmBundles, - { task -> task.outputDirectory }, + { task -> task.assetsOutputDirectory }, + ) + variant.sources.res?.addGeneratedSourceDirectory( + prepareXuqmBundles, + { task -> task.resourcesOutputDirectory }, ) } } diff --git a/packages/update/metro/index.js b/packages/update/metro/index.js index f99bd2a..fbab9b4 100644 --- a/packages/update/metro/index.js +++ b/packages/update/metro/index.js @@ -38,6 +38,10 @@ function createModuleSerializer(environment = process.env) { if (environment.XUQM_RESET_MODULE_CACHE === 'true') writeCache(cacheFile, {}) const sharedCache = readCache(cacheFile) + // processModuleFilter 会在 createModuleIdFactory 分配编号之后执行。必须冻结进入 + // 当前 bundle 之前已经存在的共享模块;否则 common 新分配的模块刚写入缓存,随即 + // 又会被当作 startup 已提供模块过滤,生成只含 require(entryId) 的无效空包。 + const inheritedSharedModules = new Set(Object.keys(sharedCache)) const writesSharedCache = moduleType === 'startup' || moduleType === 'common' const moduleIndex = Number.parseInt(environment.XUQM_MODULE_INDEX ?? '0', 10) let nextId = writesSharedCache @@ -64,7 +68,7 @@ function createModuleSerializer(environment = process.env) { processModuleFilter(module) { if (moduleType === 'startup') return true if (isRuntimePrelude(module.path)) return false - return !hasModule(sharedCache, module.path) + return !inheritedSharedModules.has(module.path) }, } } diff --git a/packages/update/package.json b/packages/update/package.json index 300dd6f..33cc48d 100644 --- a/packages/update/package.json +++ b/packages/update/package.json @@ -8,6 +8,9 @@ "types": "src/index.ts", "exports": { ".": "./src/index.ts", + "./native-bundle": "./src/NativeBundle.ts", + "./plugin-assets": "./src/PluginAssets.ts", + "./plugin-registry": "./src/PluginRegistry.ts", "./metro": "./metro/index.js", "./package.json": "./package.json" }, @@ -33,12 +36,13 @@ "pack:check": "node scripts/verify-package.mjs" }, "dependencies": { + "fflate": "0.8.3", "semver": "7.8.5" }, "peerDependencies": { - "react-native": ">=0.76.0", "@react-native-async-storage/async-storage": ">=1.21.0", - "@xuqm/rn-common": "workspace:>=0.6.0-alpha.4" + "@xuqm/rn-common": "workspace:>=0.6.0-alpha.4", + "react-native": ">=0.76.0" }, "devDependencies": { "@types/semver": "7.7.1", diff --git a/packages/update/scripts/metro.test.mjs b/packages/update/scripts/metro.test.mjs index 7e8226c..19aec71 100644 --- a/packages/update/scripts/metro.test.mjs +++ b/packages/update/scripts/metro.test.mjs @@ -31,6 +31,11 @@ test('Metro module ranges share startup/common and isolate every app or buz', () const commonFactory = common.createModuleIdFactory() assert.equal(commonFactory('/runtime/prelude.js'), 0) assert.equal(commonFactory('/shared/common.ts'), 1) + assert.equal( + common.processModuleFilter({ path: '/shared/common.ts' }), + true, + 'common 本轮新分配的模块必须保留在当前 bundle 中', + ) const app = createModuleSerializer(context(cacheFile, 'app', 'app', 2)) const buz = createModuleSerializer(context(cacheFile, 'buz-a', 'buz', 3)) diff --git a/packages/update/scripts/native-baseline.mjs b/packages/update/scripts/native-baseline.mjs index 514c51e..12b8c9c 100644 --- a/packages/update/scripts/native-baseline.mjs +++ b/packages/update/scripts/native-baseline.mjs @@ -83,6 +83,30 @@ function runtimeDependencyVersions(root, hostPackage) { return runtime } +/** + * 开发期 file: 依赖不会因为源码变化自动提升 package version,因此必须把它们的 + * 原生源码纳入完整包基线;正式 registry 依赖仍由不可变版本号标识。 + */ +function localDependencyNativeFiles(root, hostPackage, platform) { + const dependencies = hostPackage.dependencies ?? {} + const files = [] + for (const [name, requested] of Object.entries(dependencies).sort(([left], [right]) => + left.localeCompare(right), + )) { + if (typeof requested !== 'string' || !requested.startsWith('file:')) continue + const dependencyRoot = path.resolve(root, requested.slice('file:'.length)) + for (const file of walkFiles( + path.join(dependencyRoot, platform), + dependencyRoot, + new Set([...NATIVE_EXTENSIONS, ...PACKAGED_ASSET_EXTENSIONS]), + new Set(platform === 'android' ? ['gradlew'] : ['Podfile']), + )) { + files.push({ ...file, relative: `local-dependency/${name}/${file.relative}` }) + } + } + return files +} + /** Deterministic identity of code and dependencies that can only change through a full APK. */ export function computeNativeBaseline(root = process.cwd(), platform = 'android') { if (platform !== 'android' && platform !== 'ios') { @@ -104,7 +128,8 @@ export function computeNativeBaseline(root = process.cwd(), platform = 'android' const sourceAssets = [path.join(root, 'src'), path.join(root, 'assets')].flatMap(directory => walkFiles(directory, root, PACKAGED_ASSET_EXTENSIONS), ) - for (const file of [...nativeFiles, ...sourceAssets].sort((a, b) => + const localDependencyFiles = localDependencyNativeFiles(root, hostPackage, platform) + for (const file of [...nativeFiles, ...sourceAssets, ...localDependencyFiles].sort((a, b) => a.relative.localeCompare(b.relative), )) { hash.update(`file\0${file.relative}\0`) diff --git a/packages/update/scripts/native-baseline.test.mjs b/packages/update/scripts/native-baseline.test.mjs index 144ca79..811d2cd 100644 --- a/packages/update/scripts/native-baseline.test.mjs +++ b/packages/update/scripts/native-baseline.test.mjs @@ -72,3 +72,25 @@ test('native baselines are platform-specific', () => { rmSync(root, { recursive: true, force: true }) } }) + +test('native source from a local file dependency changes the full-app baseline', () => { + const root = fixture() + try { + const dependencyRoot = path.join(root, 'local-update-sdk') + const nativeFile = path.join(dependencyRoot, 'android', 'src', 'main', 'java', 'Bridge.java') + mkdirSync(path.dirname(nativeFile), { recursive: true }) + writeFileSync(nativeFile, 'final class Bridge {}') + writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ + dependencies: { 'runtime-library': '^1.0.0', '@xuqm/local': 'file:./local-update-sdk' }, + }), + ) + + const initial = computeNativeBaseline(root, 'android') + writeFileSync(nativeFile, 'final class Bridge { int apiLevel = 2; }') + assert.notEqual(computeNativeBaseline(root, 'android'), initial) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/update/scripts/xuqm-config.mjs b/packages/update/scripts/xuqm-config.mjs index 008d8e6..47d5c3a 100644 --- a/packages/update/scripts/xuqm-config.mjs +++ b/packages/update/scripts/xuqm-config.mjs @@ -79,6 +79,25 @@ export function validateConfig(config, root = process.cwd()) { if (module.metroConfig && !existsSync(path.resolve(root, module.metroConfig))) { errors.push(`Metro config not found for ${module.id}: ${module.metroConfig}`) } + if (module.ownershipRoots !== undefined) { + if (module.type !== 'common') { + errors.push(`ownershipRoots is only supported by the common module: ${module.id}`) + } else if (!Array.isArray(module.ownershipRoots) || module.ownershipRoots.length === 0) { + errors.push(`ownershipRoots must be a non-empty string array for ${module.id}`) + } else { + for (const ownershipRoot of module.ownershipRoots) { + if ( + typeof ownershipRoot !== 'string' || + ownershipRoot.trim() === '' || + !existsSync(path.resolve(root, ownershipRoot)) + ) { + errors.push( + `ownership root not found for ${module.id}: ${ownershipRoot ?? ''}`, + ) + } + } + } + } } return errors } @@ -117,7 +136,7 @@ export function resolveVersionedModules(config, root = process.cwd(), appVersion ...(module.commonVersionRange === undefined && ['app', 'buz'].includes(module.type) ? { commonVersionRange: defaultCommonRange } : {}), - minNativeApiLevel: module.minNativeApiLevel ?? 1, + minNativeApiLevel: module.minNativeApiLevel ?? 2, })), } } diff --git a/packages/update/scripts/xuqm-config.test.mjs b/packages/update/scripts/xuqm-config.test.mjs index bb82014..1c451f3 100644 --- a/packages/update/scripts/xuqm-config.test.mjs +++ b/packages/update/scripts/xuqm-config.test.mjs @@ -1,5 +1,5 @@ import assert from 'node:assert/strict' -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' import test from 'node:test' @@ -34,7 +34,7 @@ test('plugins start at the configured plugin version independently from the app assert.equal(resolved.modules[0].moduleVersion, '1.0.0') assert.equal(resolved.modules[1].moduleVersion, '1.0.0') assert.equal(resolved.modules[1].commonVersionRange, '>=1.0.0 <2.0.0') - assert.equal(resolved.modules[1].minNativeApiLevel, 1) + assert.equal(resolved.modules[1].minNativeApiLevel, 2) } finally { rmSync(root, { recursive: true, force: true }) } @@ -63,3 +63,53 @@ test('an explicitly independently released module keeps its own version', () => rmSync(root, { recursive: true, force: true }) } }) + +test('only common can own existing shared source roots', () => { + const root = fixture() + try { + mkdirSync(path.join(root, 'src', 'common'), { recursive: true }) + const base = { + appId: 'example', + mainModuleName: 'Example', + pluginVersion: '1.0.0', + appVersionRange: '>=1.0.0 <2.0.0', + metroConfig: './metro.config.js', + } + assert.deepEqual( + validateConfig( + { + ...base, + modules: [ + { + id: 'common', + type: 'common', + entry: './common.ts', + ownershipRoots: ['./src/common'], + }, + ], + }, + root, + ), + [], + ) + assert.match( + validateConfig( + { + ...base, + modules: [ + { + id: 'app', + type: 'app', + entry: './app.ts', + ownershipRoots: ['./src/common'], + }, + ], + }, + root, + ).join('\n'), + /only supported by the common module/, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) diff --git a/packages/update/scripts/xuqm-rn.mjs b/packages/update/scripts/xuqm-rn.mjs index 38b5942..271a1fa 100755 --- a/packages/update/scripts/xuqm-rn.mjs +++ b/packages/update/scripts/xuqm-rn.mjs @@ -1,6 +1,7 @@ #!/usr/bin/env node import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { copyFileSync, existsSync, @@ -14,6 +15,7 @@ import { import { createRequire } from 'node:module' import path from 'node:path' import process from 'node:process' +import { strToU8, zipSync } from 'fflate' import { CONFIG_FILE_NAME, SUPPORTED_PLATFORMS, @@ -253,12 +255,67 @@ function resolveBuildModules(config, selectedModules) { .sort((left, right) => priorities[left.type] - priorities[right.type]) } +const OWNED_SOURCE_EXTENSION = /\.(?:js|jsx|ts|tsx)$/ +const EXCLUDED_OWNERSHIP_SOURCE = + /(?:^|\/)(?:__tests__|tests?)(?:\/|$)|\.(?:spec|test)\.[^.]+$|\.d\.ts$/ + +function ownershipSourceFiles(directory, platform) { + const selected = new Map() + for (const relative of walkFiles(directory)) { + if (!OWNED_SOURCE_EXTENSION.test(relative) || EXCLUDED_OWNERSHIP_SOURCE.test(relative)) continue + const platformMatch = relative.match(/\.(android|ios|native|web)(?=\.[^.]+$)/) + const variant = platformMatch?.[1] + if (variant && variant !== platform && variant !== 'native') continue + const key = platformMatch ? relative.replace(`.${variant}`, '') : relative + const priority = variant === platform ? 3 : variant === 'native' ? 2 : 1 + const existing = selected.get(key) + if (!existing || priority > existing.priority) { + selected.set(key, { absolute: path.join(directory, relative), priority }) + } + } + return [...selected.values()].map(item => item.absolute).sort() +} + +/** + * common 可声明若干唯一所有权目录。CLI 为当前平台生成一个临时入口,把这些目录 + * 纳入 common 的依赖图但不执行模块;后续 app/buz 会按共享模块表过滤它们。 + * 目录规则由 SDK 统一实现,宿主不再维护易遗漏的 require 清单。 + */ +function buildEntryFile(module, platform) { + if (module.type !== 'common' || !module.ownershipRoots?.length) return module.entry + const generatedDirectory = path.join(root, '.xuqm-cache', 'ownership') + const generatedFile = path.join(generatedDirectory, `${module.id}.${platform}.js`) + const ownedFiles = module.ownershipRoots.flatMap(ownershipRoot => + ownershipSourceFiles(path.resolve(root, ownershipRoot), platform), + ) + const importPath = file => { + const relative = path.relative(generatedDirectory, file).split(path.sep).join('/') + return relative.startsWith('.') ? relative : `./${relative}` + } + const lines = [ + `require(${JSON.stringify(importPath(path.resolve(root, module.entry)))})`, + '', + 'function retainXuqmCommonOwnership() {', + ...ownedFiles.map(file => ` require(${JSON.stringify(importPath(file))})`), + '}', + '', + 'void retainXuqmCommonOwnership', + '', + ] + mkdirSync(generatedDirectory, { recursive: true }) + writeFileSync(generatedFile, lines.join('\n')) + return generatedFile +} + function build(platform, optionArgs = []) { assertPlatform(platform) const config = doctor() - const modules = parseSelectedModules(config, optionArgs) - const buildModules = resolveBuildModules(config, modules) + const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION) + const versionedConfig = { ...config, modules: versioned.modules } + const modules = parseSelectedModules(versionedConfig, optionArgs) + const buildModules = resolveBuildModules(versionedConfig, modules) const useHermes = platform === 'android' && androidUsesHermes() + const nativeBaselineId = computeNativeBaseline(root, platform) for (const [buildIndex, module] of buildModules.entries()) { const outputDirectory = bundleOutputDirectory(root, config, platform, module.id) rmSync(outputDirectory, { force: true, recursive: true }) @@ -271,7 +328,7 @@ function build(platform, optionArgs = []) { '--dev', 'false', '--entry-file', - module.entry, + buildEntryFile(module, platform), '--bundle-output', bundleFile(root, config, platform, module.id), '--assets-dest', @@ -293,6 +350,18 @@ function build(platform, optionArgs = []) { }) if (useHermes) compileHermesBundle(bundleFile(root, config, platform, module.id), module.sourceMap === true) + // startup 是随宿主 APK 固化的最小引导代码,不属于可在线发布插件。为它生成 + // 发布包既没有安装入口,也容易让发布人员误认为它可以脱离整包升级。 + if (module.type !== 'startup') { + createModulePackage( + config, + platform, + module, + versioned.appVersion, + nativeBaselineId, + useHermes ? 'hermes-bytecode' : 'javascript', + ) + } } return { config, modules } } @@ -310,10 +379,65 @@ function walkFiles(directory, relativePrefix = '') { function moduleAssetFiles(rootDirectory, bundleName) { return walkFiles(rootDirectory).filter( - file => file !== bundleName && file !== 'raw/keep.xml' && !file.endsWith('.map'), + file => + file !== bundleName && + file !== 'raw/keep.xml' && + !file.endsWith('.map') && + !file.endsWith('.xuqm.zip'), ) } +function modulePackageFile(config, platform, moduleId) { + return path.join( + bundleOutputDirectory(root, config, platform, moduleId), + `${moduleId}.${platform}.xuqm.zip`, + ) +} + +/** + * 线上插件与 APK 内嵌插件共用同一份模块元数据。发布物始终是二进制 ZIP,避免 + * Hermes 字节码经过 UTF-8 转换损坏,并让插件自己的图片、字体等资源原子到达。 + */ +function createModulePackage(config, platform, module, appVersion, nativeBaselineId, bundleFormat) { + const directory = bundleOutputDirectory(root, config, platform, module.id) + const bundleName = `${module.id}.${platform}.bundle` + const bundleBytes = readFileSync(path.join(directory, bundleName)) + const assetFiles = moduleAssetFiles(directory, bundleName) + const assets = assetFiles.map(relative => { + const bytes = readFileSync(path.join(directory, relative)) + return { + path: relative, + byteLength: bytes.byteLength, + sha256: createHash('sha256').update(bytes).digest('hex'), + } + }) + const manifest = { + schemaVersion: 1, + moduleId: module.id, + type: module.type, + platform, + version: module.moduleVersion, + appVersion, + appVersionRange: module.appVersionRange, + builtAgainstNativeBaselineId: nativeBaselineId, + bundleFormat, + bundleFile: bundleName, + bundleByteLength: bundleBytes.byteLength, + bundleSha256: createHash('sha256').update(bundleBytes).digest('hex'), + ...(module.commonVersionRange ? { commonVersionRange: module.commonVersionRange } : {}), + minNativeApiLevel: module.minNativeApiLevel, + assets, + } + const archive = { + 'rn-manifest.json': strToU8(`${JSON.stringify(manifest, null, 2)}\n`), + [bundleName]: new Uint8Array(bundleBytes), + } + for (const asset of assets) { + archive[`assets/${asset.path}`] = new Uint8Array(readFileSync(path.join(directory, asset.path))) + } + writeFileSync(modulePackageFile(config, platform, module.id), zipSync(archive, { level: 9 })) +} + function copyWithoutConflict(source, target, owners, owner) { const relative = path.relative(path.dirname(target), target) if (existsSync(target)) { @@ -334,11 +458,14 @@ function createManifest(config, platform, modules, appVersion, nativeBaselineId) for (const module of modules) { const directory = bundleOutputDirectory(root, config, platform, module.id) const bundleName = `${module.id}.${platform}.bundle` + const bundleBytes = readFileSync(path.join(directory, bundleName)) entries[module.id] = { id: module.id, type: module.type, directory: module.directory ?? module.id, bundleFile: bundleName, + sha256: createHash('sha256').update(bundleBytes).digest('hex'), + byteLength: bundleBytes.byteLength, moduleVersion: module.moduleVersion, appVersionRange: module.appVersionRange, builtAgainstNativeBaselineId: nativeBaselineId, @@ -402,11 +529,22 @@ function embed(platform, optionArgs = []) { rmSync(target, { force: true, recursive: true }) mkdirSync(target, { recursive: true }) const owners = new Map() + const assetOwners = new Map() const nativeBaselineId = computeNativeBaseline(root, platform) for (const module of versioned.modules) { const sourceRoot = bundleOutputDirectory(root, config, platform, module.id) const bundleName = `${module.id}.${platform}.bundle` - for (const relative of [bundleName, ...moduleAssetFiles(sourceRoot, bundleName)]) { + const assets = moduleAssetFiles(sourceRoot, bundleName) + for (const relative of assets) { + const existingOwner = assetOwners.get(relative) + if (existingOwner) { + throw new Error( + `asset ownership collision: ${relative} belongs to both ${existingOwner} and ${module.id}; move shared assets into common ownershipRoots`, + ) + } + assetOwners.set(relative, module.id) + } + for (const relative of [bundleName, ...assets]) { copyWithoutConflict( path.join(sourceRoot, relative), path.join(target, relative), diff --git a/packages/update/scripts/xuqm-rn.test.mjs b/packages/update/scripts/xuqm-rn.test.mjs index c9b7412..841276c 100644 --- a/packages/update/scripts/xuqm-rn.test.mjs +++ b/packages/update/scripts/xuqm-rn.test.mjs @@ -1,8 +1,10 @@ import assert from 'node:assert/strict' import { execFileSync } from 'node:child_process' +import { createHash } from 'node:crypto' import { chmodSync, copyFileSync, + existsSync, mkdirSync, mkdtempSync, readFileSync, @@ -14,6 +16,7 @@ import { tmpdir } from 'node:os' import path from 'node:path' import test from 'node:test' import { fileURLToPath } from 'node:url' +import { strFromU8, unzipSync } from 'fflate' const cli = fileURLToPath(new URL('./xuqm-rn.mjs', import.meta.url)) const workspaceRequire = createRequire(import.meta.url) @@ -54,6 +57,10 @@ test('embed creates a complete versioned manifest from a host fixture', () => { const directory = path.join(root, 'bundle', 'android', moduleId) mkdirSync(directory, { recursive: true }) writeFileSync(path.join(directory, `${moduleId}.android.bundle`), moduleId) + if (moduleId === 'app') { + mkdirSync(path.join(directory, 'drawable-mdpi'), { recursive: true }) + writeFileSync(path.join(directory, 'drawable-mdpi', 'fixture_icon.png'), 'png') + } } execFileSync(process.execPath, [cli, 'embed', 'android', '--skip-build', '--output', output], { @@ -67,9 +74,16 @@ test('embed creates a complete versioned manifest from a host fixture', () => { assert.equal(manifest.modules.common.moduleVersion, '1.0.0') assert.equal(manifest.modules.app.moduleVersion, '1.0.0') assert.equal(manifest.modules.app.commonVersionRange, '>=1.0.0 <2.0.0') - assert.equal(manifest.modules.app.minNativeApiLevel, 1) + assert.equal(manifest.modules.app.minNativeApiLevel, 2) assert.equal(manifest.modules.app.appVersionRange, '>=7.2.14 <8.0.0') + assert.equal(manifest.modules.app.sha256, createHash('sha256').update('app').digest('hex')) + assert.equal(manifest.modules.app.byteLength, 3) + assert.deepEqual(manifest.modules.app.assetFiles, ['drawable-mdpi/fixture_icon.png']) assert.equal(readFileSync(path.join(output, 'app.android.bundle'), 'utf8'), 'app') + assert.equal( + readFileSync(path.join(output, 'drawable-mdpi', 'fixture_icon.png'), 'utf8'), + 'png', + ) } finally { rmSync(root, { recursive: true, force: true }) } @@ -186,6 +200,59 @@ test('Android Hermes hosts compile plugin bundles to bytecode', () => { } }) +test('embed rejects assets owned by more than one module', () => { + const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-asset-ownership-')) + try { + writeFileSync( + path.join(root, 'package.json'), + JSON.stringify({ + name: 'fixture', + version: '1.0.0', + dependencies: { '@xuqm/rn-update': '0.5.0-alpha.10' }, + }), + ) + for (const moduleId of ['common', 'app']) { + writeFileSync(path.join(root, `${moduleId}.ts`), '') + const directory = path.join(root, 'bundle', 'android', moduleId, 'drawable-mdpi') + mkdirSync(directory, { recursive: true }) + writeFileSync( + path.join(root, 'bundle', 'android', moduleId, `${moduleId}.android.bundle`), + moduleId, + ) + writeFileSync(path.join(directory, 'shared.png'), 'png') + } + writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n') + writeFileSync( + path.join(root, 'xuqm.config.json'), + JSON.stringify({ + schemaVersion: 3, + appId: 'fixture.app', + mainModuleName: 'Fixture', + pluginVersion: '1.0.0', + appVersionRange: '>=1.0.0 <2.0.0', + outputDir: './bundle', + metroConfig: './metro.config.js', + embeddedOutput: { android: './generated' }, + modules: [ + { id: 'common', type: 'common', entry: './common.ts' }, + { id: 'app', type: 'app', entry: './app.ts' }, + ], + }), + ) + + assert.throws( + () => + execFileSync(process.execPath, [cli, 'embed', 'android', '--skip-build'], { + cwd: root, + stdio: 'pipe', + }), + /Command failed/, + ) + } finally { + rmSync(root, { recursive: true, force: true }) + } +}) + test('build invokes the host-local React Native CLI with a consistent color environment', () => { const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-local-rn-')) try { @@ -259,6 +326,9 @@ test('building one buz first rebuilds startup and common module maps', () => { for (const entry of ['startup.ts', 'common.ts', 'app.ts', 'orders.ts']) { writeFileSync(path.join(root, entry), '') } + mkdirSync(path.join(root, 'shared'), { recursive: true }) + writeFileSync(path.join(root, 'shared', 'shared.ts'), 'export const shared = true\n') + writeFileSync(path.join(root, 'shared', 'ignored.test.ts'), 'throw new Error()\n') writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n') writeFileSync( path.join(root, 'xuqm.config.json'), @@ -275,7 +345,12 @@ test('building one buz first rebuilds startup and common module maps', () => { modules: [ { id: 'orders', type: 'buz', entry: './orders.ts' }, { id: 'app', type: 'app', entry: './app.ts' }, - { id: 'common', type: 'common', entry: './common.ts' }, + { + id: 'common', + type: 'common', + entry: './common.ts', + ownershipRoots: ['./shared'], + }, { id: 'startup', type: 'startup', entry: './startup.ts' }, ], }), @@ -289,14 +364,15 @@ test('building one buz first rebuilds startup and common module maps', () => { const path = require('node:path'); const callsFile = path.join(process.cwd(), 'module-calls.json'); const calls = fs.existsSync(callsFile) ? JSON.parse(fs.readFileSync(callsFile, 'utf8')) : []; + const args = process.argv.slice(2); calls.push({ + entry: args[args.indexOf('--entry-file') + 1], id: process.env.XUQM_MODULE_ID, index: process.env.XUQM_MODULE_INDEX, reset: process.env.XUQM_RESET_MODULE_CACHE, type: process.env.XUQM_MODULE_TYPE, }); fs.writeFileSync(callsFile, JSON.stringify(calls)); - const args = process.argv.slice(2); const output = args[args.indexOf('--bundle-output') + 1]; fs.mkdirSync(path.dirname(output), { recursive: true }); fs.writeFileSync(output, 'bundle'); @@ -308,11 +384,37 @@ test('building one buz first rebuilds startup and common module maps', () => { stdio: 'pipe', }) - assert.deepEqual(JSON.parse(readFileSync(path.join(root, 'module-calls.json'), 'utf8')), [ - { id: 'startup', index: '3', reset: 'true', type: 'startup' }, - { id: 'common', index: '2', reset: 'false', type: 'common' }, - { id: 'orders', index: '0', reset: 'false', type: 'buz' }, - ]) + const calls = JSON.parse(readFileSync(path.join(root, 'module-calls.json'), 'utf8')) + assert.equal(calls[0].entry, './startup.ts') + assert.match(calls[1].entry, /\.xuqm-cache\/ownership\/common\.android\.js$/) + assert.equal(calls[2].entry, './orders.ts') + assert.deepEqual( + calls.map(({ entry: _entry, ...call }) => call), + [ + { id: 'startup', index: '3', reset: 'true', type: 'startup' }, + { id: 'common', index: '2', reset: 'false', type: 'common' }, + { id: 'orders', index: '0', reset: 'false', type: 'buz' }, + ], + ) + const ownershipEntry = readFileSync(calls[1].entry, 'utf8') + assert.match(ownershipEntry, /shared\/shared\.ts/) + assert.doesNotMatch(ownershipEntry, /ignored\.test\.ts/) + assert.equal( + existsSync(path.join(root, 'bundle/android/startup/startup.android.xuqm.zip')), + false, + ) + + const commonPackage = unzipSync( + new Uint8Array( + readFileSync(path.join(root, 'bundle/android/common/common.android.xuqm.zip')), + ), + ) + const packageManifest = JSON.parse(strFromU8(commonPackage['rn-manifest.json'])) + assert.equal(packageManifest.schemaVersion, 1) + assert.equal(packageManifest.moduleId, 'common') + assert.equal(packageManifest.bundleFormat, 'javascript') + assert.deepEqual(packageManifest.assets, []) + assert.equal(strFromU8(commonPackage['common.android.bundle']), 'bundle') } finally { rmSync(root, { recursive: true, force: true }) } diff --git a/packages/update/scripts/xuqm_release.mjs b/packages/update/scripts/xuqm_release.mjs index a28e075..8a54702 100644 --- a/packages/update/scripts/xuqm_release.mjs +++ b/packages/update/scripts/xuqm_release.mjs @@ -6,7 +6,7 @@ import { createInterface } from 'node:readline' import process from 'node:process' import { SUPPORTED_PLATFORMS, - bundleFile, + bundleOutputDirectory, parseModuleOptions, readConfig, resolveVersionedModules, @@ -38,7 +38,12 @@ if (!SUPPORTED_PLATFORMS.has(platform)) throw new Error('platform must be androi const config = readConfig(root) const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION) -const modules = selectModules({ ...config, modules: versioned.modules }, moduleIds) +const selectedModules = selectModules({ ...config, modules: versioned.modules }, moduleIds) +if (moduleIds.length > 0 && selectedModules.some(module => module.type === 'startup')) { + throw new Error('startup is embedded in the host APK and cannot be published independently') +} +const modules = selectedModules.filter(module => module.type !== 'startup') +if (modules.length === 0) throw new Error('no publishable common/app/buz module selected') const nativeBaselineId = computeNativeBaseline(root, platform) const serverUrl = process.env.XUQM_SERVER_URL ?? config.release?.serverUrl const appKey = process.env.XUQM_APP_KEY ?? config.release?.appKey @@ -65,8 +70,11 @@ function apiHeaders() { } async function uploadBundle(module, note, minNativeApiLevel) { - const file = bundleFile(root, config, platform, module.id) - if (!existsSync(file)) throw new Error(`bundle is missing: ${file}`) + const file = path.join( + bundleOutputDirectory(root, config, platform, module.id), + `${module.id}.${platform}.xuqm.zip`, + ) + if (!existsSync(file)) throw new Error(`plugin package is missing: ${file}`) const form = new FormData() form.append('appKey', appKey) form.append('moduleId', module.id) @@ -79,7 +87,7 @@ async function uploadBundle(module, note, minNativeApiLevel) { form.append('minNativeApiLevel', String(module.minNativeApiLevel ?? minNativeApiLevel)) form.append('note', note) if (config.packageName) form.append('packageName', config.packageName) - form.append('bundle', new Blob([readFileSync(file)]), `${module.id}.${platform}.bundle`) + form.append('bundle', new Blob([readFileSync(file)]), `${module.id}.${platform}.xuqm.zip`) const response = await fetch(`${serverUrl}/api/v1/rn/upload`, { method: 'POST', headers: apiHeaders(), @@ -92,7 +100,7 @@ async function uploadBundle(module, note, minNativeApiLevel) { async function main() { const note = noteOption ?? (await ask('Release notes: ')) const minNativeApiLevel = Number( - minNativeApiLevelOption ?? config.release?.minNativeApiLevel ?? 1, + minNativeApiLevelOption ?? config.release?.minNativeApiLevel ?? 2, ) if (!Number.isInteger(minNativeApiLevel) || minNativeApiLevel < 1) { throw new Error('minNativeApiLevel must be a positive integer') diff --git a/packages/update/src/NativeBundle.ts b/packages/update/src/NativeBundle.ts index 66ab988..6e7de46 100644 --- a/packages/update/src/NativeBundle.ts +++ b/packages/update/src/NativeBundle.ts @@ -6,6 +6,7 @@ export interface BundleModuleState { status: 'embedded' | 'active' | 'staged' | 'pending_confirmation' activeVersion?: string activeHash?: string + activeByteLength?: number activeType?: PluginModuleType activeAppVersionRange?: string activeBuiltAgainstNativeBaselineId?: string @@ -20,7 +21,8 @@ export interface NativeReleaseModule { moduleId: string type: PluginModuleType version: string - source: string + /** Base64-encoded .xuqm.zip bytes; never decode Hermes bytecode as text. */ + archiveBase64: string sha256: string appVersionRange: string builtAgainstNativeBaselineId?: string @@ -51,6 +53,7 @@ interface XuqmBundleModuleInterface { getPendingRelease(): Promise preparePendingRelease(): Promise getLaunchBundlePath(moduleId: string): Promise + getAssetIndex(): Promise } function getModule(): XuqmBundleModuleInterface { @@ -130,6 +133,10 @@ export const NativeBundle = { return getModule().getLaunchBundlePath(moduleId) }, + async getAssetIndex(): Promise> { + return parseJson(await getModule().getAssetIndex(), 'asset index') + }, + getFileName(moduleId: string): string { return `${moduleId}.${Platform.OS}.bundle` }, diff --git a/packages/update/src/PluginAssets.ts b/packages/update/src/PluginAssets.ts new file mode 100644 index 0000000..945bc80 --- /dev/null +++ b/packages/update/src/PluginAssets.ts @@ -0,0 +1,54 @@ +import { Platform } from 'react-native' +import { NativeBundle } from './NativeBundle' + +type ResolvedAsset = { + __packager_asset: boolean + width?: number | null + height?: number | null + uri: string + scale: number +} + +type AssetResolver = { + drawableFolderInBundle(): ResolvedAsset +} + +type ResolveAssetSource = { + addCustomSourceTransformer(transformer: (resolver: AssetResolver) => ResolvedAsset | null): void +} + +// React Native 暂未为该扩展点提供公开 TypeScript 声明;SDK 将唯一的一处内部 +// 引用隔离在本文件,升级 RN 时只需验证这里,不让宿主项目各自实现资源解析。 +const resolveAssetSource = require('react-native/Libraries/Image/resolveAssetSource') + .default as ResolveAssetSource + +let assetIndex: Readonly> = {} +let transformerRegistered = false + +function assetPath(uri: string): string | null { + const drawable = uri.lastIndexOf('drawable-') + const raw = uri.lastIndexOf('raw/') + const start = Math.max(drawable, raw) + return start >= 0 ? uri.slice(start) : null +} + +function registerTransformerOnce() { + if (transformerRegistered || Platform.OS !== 'android') return + resolveAssetSource.addCustomSourceTransformer(resolver => { + const resolved = resolver.drawableFolderInBundle() + const relativePath = assetPath(resolved.uri) + const localUri = relativePath ? assetIndex[relativePath] : undefined + return localUri ? { ...resolved, uri: localUri } : null + }) + transformerRegistered = true +} + +/** + * 刷新当前已激活插件资源的只读索引。未被线上插件覆盖的资源继续由 Android + * resource table 解析;被下载包携带的资源则直接读取事务激活后的本地文件。 + */ +export async function refreshPluginAssetResolver(): Promise { + if (Platform.OS !== 'android') return + registerTransformerOnce() + assetIndex = Object.freeze(await NativeBundle.getAssetIndex()) +} diff --git a/packages/update/src/PluginRegistry.ts b/packages/update/src/PluginRegistry.ts new file mode 100644 index 0000000..a6a8c34 --- /dev/null +++ b/packages/update/src/PluginRegistry.ts @@ -0,0 +1,34 @@ +import type { XuqmPluginDefinition } from './PluginRuntime' + +/** + * 进程内唯一插件定义注册表。 + * + * 多 bundle 模式下 app 与 buz 拥有不同模块编号区间;本模块必须由 common bundle + * 预先持有,使宿主读取和插件写入命中同一个 Map。不要把注册表重新放回 + * PluginRuntime,否则每个业务 bundle 都会静默创建一份平行状态。 + */ +const definitions = new Map() + +/** Called by a plugin bundle when it is evaluated. */ +export function definePlugin(definition: XuqmPluginDefinition): void { + if (!definition.moduleId.trim()) { + throw new Error('[XuqmRuntime] moduleId is required') + } + const existing = definitions.get(definition.moduleId) + if (existing && existing !== definition) { + throw new Error(`[XuqmRuntime] Plugin "${definition.moduleId}" is already defined`) + } + definitions.set(definition.moduleId, definition) +} + +export function getPluginDefinition(moduleId: string): XuqmPluginDefinition | undefined { + return definitions.get(moduleId) +} + +export function removePluginDefinition(moduleId: string): void { + definitions.delete(moduleId) +} + +export function getDefinedPluginIds(): string[] { + return Array.from(definitions.keys()) +} diff --git a/packages/update/src/PluginRuntime.ts b/packages/update/src/PluginRuntime.ts index 0bafaf8..7c869f9 100644 --- a/packages/update/src/PluginRuntime.ts +++ b/packages/update/src/PluginRuntime.ts @@ -1,4 +1,10 @@ import { UpdateSDK, type PluginRegistration } from './UpdateSDK' +import { + definePlugin, + getDefinedPluginIds, + getPluginDefinition, + removePluginDefinition, +} from './PluginRegistry' export interface XuqmPluginContext { navigate(route: string, params?: unknown): void @@ -24,19 +30,7 @@ export interface XuqmPluginActivationOptions { reloadBundle?: boolean } -const definitions = new Map() - -/** Called by a plugin bundle when it is evaluated. */ -export function definePlugin(definition: XuqmPluginDefinition): void { - if (!definition.moduleId.trim()) { - throw new Error('[XuqmRuntime] moduleId is required') - } - const existing = definitions.get(definition.moduleId) - if (existing && existing !== definition) { - throw new Error(`[XuqmRuntime] Plugin "${definition.moduleId}" is already defined`) - } - definitions.set(definition.moduleId, definition) -} +export { definePlugin } from './PluginRegistry' export const XuqmRuntime = (() => { let options: XuqmRuntimeOptions | null = null @@ -68,17 +62,17 @@ export const XuqmRuntime = (() => { if (active.has(moduleId) && !activationOptions.reloadBundle) return if (activationOptions.reloadBundle) { - const previous = definitions.get(moduleId) + const previous = getPluginDefinition(moduleId) if (previous?.deactivate) await previous.deactivate() active.delete(moduleId) - definitions.delete(moduleId) + removePluginDefinition(moduleId) } - let definition = definitions.get(moduleId) + let definition = getPluginDefinition(moduleId) if (!definition) { const bundlePath = await UpdateSDK.getLaunchBundlePath(moduleId) await options.loadBundle(moduleId, bundlePath) - definition = definitions.get(moduleId) + definition = getPluginDefinition(moduleId) } if (!definition) { await UpdateSDK.reportPluginLaunchFailure(moduleId, 'Plugin did not call definePlugin()') @@ -99,13 +93,13 @@ export const XuqmRuntime = (() => { }, async deactivate(moduleId: string): Promise { - const definition = definitions.get(moduleId) + const definition = getPluginDefinition(moduleId) if (definition?.deactivate) await definition.deactivate() active.delete(moduleId) }, getDefinedPlugins(): string[] { - return Array.from(definitions.keys()) + return getDefinedPluginIds() }, } })() diff --git a/packages/update/src/UpdateSDK.ts b/packages/update/src/UpdateSDK.ts index 3a40680..382adde 100644 --- a/packages/update/src/UpdateSDK.ts +++ b/packages/update/src/UpdateSDK.ts @@ -3,8 +3,8 @@ import { Linking, Platform } from 'react-native' import { apiRequest, awaitInitialization, + bytesToBase64, downloadBytes, - downloadText, getConfig, getDeviceInfo, getUserId, @@ -143,6 +143,20 @@ function requireRegistration(moduleId: string): PluginRegistration { return registration } +function registeredNativeBaselineId( + registrations: readonly PluginRegistration[], +): string | undefined { + const baselines = new Set( + registrations + .map(registration => registration.builtAgainstNativeBaselineId?.trim()) + .filter((value): value is string => Boolean(value)), + ) + if (baselines.size > 1) { + throw new Error('[UpdateSDK] Registered plugins disagree on native baseline') + } + return baselines.values().next().value +} + async function installedModule(registration: PluginRegistration): Promise { const state = await NativeBundle.getState(registration.moduleId) return { @@ -305,7 +319,10 @@ export const UpdateSDK = { }, /** Checks only the requested app/buz entry and its common dependency closure. */ - async checkPluginRelease(targetModuleId: string): Promise { + async checkPluginRelease( + targetModuleId: string, + options: { signal?: AbortSignal; timeoutMs?: number } = {}, + ): Promise { await awaitInitialization() const target = requireRegistration(targetModuleId) if (target.type === 'common') { @@ -327,6 +344,8 @@ export const UpdateSDK = { const raw = await apiRequest('/api/v1/rn/release-set/check', { method: 'POST', skipAuth: true, + signal: options.signal, + timeoutMs: options.timeoutMs, body: { appKey: config.appKey, targetModuleId, @@ -355,6 +374,7 @@ export const UpdateSDK = { installed, candidates, nativeApiLevel: await NativeBundle.getNativeApiLevel(), + nativeBaselineId: registeredNativeBaselineId(registrations), appVersion: getAppVersionName() ?? '0.0.0', releaseId: raw.releaseId, }) @@ -381,6 +401,7 @@ export const UpdateSDK = { installed, candidates: plan.modules, nativeApiLevel: await NativeBundle.getNativeApiLevel(), + nativeBaselineId: registeredNativeBaselineId(registrations), appVersion: getAppVersionName() ?? '0.0.0', releaseId: plan.releaseId, }) @@ -388,16 +409,16 @@ export const UpdateSDK = { const nativeModules: NativeReleaseModule[] = [] for (const module of verifiedPlan.modules) { - const source = await downloadText(module.downloadUrl, { + const archive = await downloadBytes(module.downloadUrl, { signal: options.signal, onProgress: progress => options.onProgress?.(module.moduleId, progress), }) - assertSha256(source, module.sha256, `${module.moduleId} bundle`) + assertSha256(archive, module.sha256, `${module.moduleId} plugin package`) nativeModules.push({ moduleId: module.moduleId, type: module.type, version: module.version, - source, + archiveBase64: bytesToBase64(archive), sha256: module.sha256, commonVersionRange: module.commonVersionRange, appVersionRange: module.appVersionRange!, @@ -416,10 +437,14 @@ export const UpdateSDK = { targetModuleId: string, options: { signal?: AbortSignal + checkTimeoutMs?: number onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void } = {}, ): Promise { - const plan = await UpdateSDK.checkPluginRelease(targetModuleId) + const plan = await UpdateSDK.checkPluginRelease(targetModuleId, { + signal: options.signal, + timeoutMs: options.checkTimeoutMs, + }) return plan ? UpdateSDK.installPluginRelease(plan, options) : null }, diff --git a/packages/update/src/index.ts b/packages/update/src/index.ts index 54af458..1680c12 100644 --- a/packages/update/src/index.ts +++ b/packages/update/src/index.ts @@ -8,6 +8,7 @@ export type { UpdateDownloadProgress, } from './UpdateSDK' export { NativeBundle } from './NativeBundle' +export { refreshPluginAssetResolver } from './PluginAssets' export { AppInstallError, NativeAppUpdate } from './NativeAppUpdate' export type { AppInstallErrorCode } from './NativeAppUpdate' export type { BundleModuleState, NativeReleaseModule, PendingReleaseState } from './NativeBundle' diff --git a/packages/update/src/releaseSet.ts b/packages/update/src/releaseSet.ts index 8025540..5cc1e86 100644 --- a/packages/update/src/releaseSet.ts +++ b/packages/update/src/releaseSet.ts @@ -40,6 +40,7 @@ export class ReleaseSetCompatibilityError extends Error { | 'COMMON_RANGE_MISSING' | 'COMMON_INCOMPATIBLE' | 'APP_INCOMPATIBLE' + | 'NATIVE_BASELINE_INCOMPATIBLE' | 'NATIVE_API_INCOMPATIBLE' | 'INVALID_CANDIDATE', ) { @@ -51,6 +52,7 @@ export interface PlanReleaseSetOptions { installed: readonly InstalledPluginModule[] candidates: readonly PluginReleaseCandidate[] nativeApiLevel: number + nativeBaselineId?: string appVersion: string releaseId?: string } @@ -171,6 +173,16 @@ export function planReleaseSet(options: PlanReleaseSetOptions): ReleaseSetPlan | 'NATIVE_API_INCOMPATIBLE', ) } + if ( + options.nativeBaselineId && + module.builtAgainstNativeBaselineId !== options.nativeBaselineId + ) { + throw new ReleaseSetCompatibilityError( + `${module.moduleId}@${module.version} requires native baseline ${module.builtAgainstNativeBaselineId ?? 'missing'}, current ${options.nativeBaselineId}`, + module.moduleId, + 'NATIVE_BASELINE_INCOMPATIBLE', + ) + } if (module.type !== 'common' && !satisfiesVersion(commonVersion, module.commonVersionRange!)) { throw new ReleaseSetCompatibilityError( `${module.moduleId}@${module.version} requires common ${module.commonVersionRange}, target is ${commonVersion}`, diff --git a/packages/update/tests/pluginRegistry.test.ts b/packages/update/tests/pluginRegistry.test.ts new file mode 100644 index 0000000..741f9b2 --- /dev/null +++ b/packages/update/tests/pluginRegistry.test.ts @@ -0,0 +1,29 @@ +import assert from 'node:assert/strict' +import test from 'node:test' + +import { + definePlugin, + getDefinedPluginIds, + getPluginDefinition, + removePluginDefinition, +} from '../src/PluginRegistry' + +test('plugin definition is visible through the process-wide registry', () => { + const definition = { moduleId: 'registry-test', activate() {} } + definePlugin(definition) + + assert.equal(getPluginDefinition('registry-test'), definition) + assert.ok(getDefinedPluginIds().includes('registry-test')) + + removePluginDefinition('registry-test') + assert.equal(getPluginDefinition('registry-test'), undefined) +}) + +test('rejects two different definitions for the same module', () => { + definePlugin({ moduleId: 'duplicate-test', activate() {} }) + assert.throws( + () => definePlugin({ moduleId: 'duplicate-test', activate() {} }), + /already defined/, + ) + removePluginDefinition('duplicate-test') +}) diff --git a/packages/update/tests/releaseSet.test.ts b/packages/update/tests/releaseSet.test.ts index c840d53..f7aec08 100644 --- a/packages/update/tests/releaseSet.test.ts +++ b/packages/update/tests/releaseSet.test.ts @@ -121,6 +121,35 @@ test('rejects candidates that require a newer native API', () => { ) }) +test('rejects a plugin built against a different native baseline', () => { + const nativeBaselineId = 'android-sha256:current' + const installedForBaseline = installed.map(module => ({ + ...module, + builtAgainstNativeBaselineId: nativeBaselineId, + })) + + assert.throws( + () => + planReleaseSet({ + installed: installedForBaseline, + appVersion: '8.0.0', + nativeApiLevel: 1, + nativeBaselineId, + candidates: [ + candidate({ + moduleId: 'common', + type: 'common', + version: '1.0.1', + builtAgainstNativeBaselineId: 'android-sha256:old', + }), + ], + }), + (error: unknown) => + error instanceof ReleaseSetCompatibilityError && + error.reason === 'NATIVE_BASELINE_INCOMPATIBLE', + ) +}) + test('rejects a plugin outside the current full-app version range', () => { assert.throws( () => diff --git a/packages/xwebview/README.md b/packages/xwebview/README.md index 11ec417..62bbdca 100644 --- a/packages/xwebview/README.md +++ b/packages/xwebview/README.md @@ -1,5 +1,7 @@ # @xuqm/rn-xwebview +`XWebViewView` 是 WebView、Bridge、下载、权限和加载错误的唯一内核;`XWebViewScreen` 只组合全屏导航栏与系统返回。两者不得分别维护请求、下载、权限或错误状态机。 + ## 摄像头与麦克风权限 Android 宿主按实际业务在 `AndroidManifest.xml` 声明权限: @@ -9,7 +11,7 @@ Android 宿主按实际业务在 `AndroidManifest.xml` 声明权限: ``` -H5 请求摄像头或麦克风时,XWebView 默认统一申请 Android 运行时权限,只向 WebView 放行系统已授权的资源。宿主无需在每个页面重复实现。需要额外来源校验或自定义提示时可提供 `onPermissionRequest`,此时授权决策完全由宿主回调负责。 +H5 请求摄像头或麦克风时,XWebView 默认统一申请 Android 运行时权限,只向 WebView 放行系统已授权的摄像头或麦克风资源。无法识别的 WebView 资源一律拒绝,不因 Android 权限恰好已授予而扩大授权范围。宿主无需在每个页面重复实现。需要额外来源校验或自定义提示时可提供 `onPermissionRequest`,此时授权决策完全由宿主回调负责。 统一 WebView、JSBridge 与文件下载模块。它复用 `@xuqm/rn-common` 的配置、会话、文件和 MIME 能力,不提供第二套网络或用户状态。 diff --git a/packages/xwebview/src/XWebViewScreen.tsx b/packages/xwebview/src/XWebViewScreen.tsx index 1b1e209..f1259e1 100644 --- a/packages/xwebview/src/XWebViewScreen.tsx +++ b/packages/xwebview/src/XWebViewScreen.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react' +import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { ActionSheetIOS, Alert, @@ -14,52 +14,20 @@ import { } from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' import { SafeAreaView } from 'react-native-safe-area-context' -import WebView from 'react-native-webview' -import type { - ShouldStartLoadRequest, - WebViewErrorEvent, - WebViewMessageEvent, - WebViewNavigation, - WebViewOpenWindowEvent, - WebViewProgressEvent, -} from 'react-native-webview/lib/WebViewTypes' import { useNavigation } from '@react-navigation/native' +import { + getXWebViewConfig, + XWebViewControl, + type XWebViewConfig, + type XWebViewNavigationState, +} from '@xuqm/rn-common' -import { getXWebViewConfig, setXWebViewController, type XWebViewConfig } from '@xuqm/rn-common' - -import XWebViewProgress from './XWebViewProgress' -import { fetchDownloadInfo, saveBase64File, startDownload } from './XWebViewDownload' -import { useXWebViewPermissionRequest } from './XWebViewPermissions' +import { XWebViewView } from './XWebViewView' import IconBack from './icons/IconBack' import IconClose from './icons/IconClose' import IconMenu from './icons/IconMenu' -const WebViewAny = WebView as unknown as React.ComponentType -const DOWNLOAD_EXTENSIONS = [ - '.pdf', - '.zip', - '.rar', - '.tar', - '.gz', - '.apk', - '.ipa', - '.doc', - '.docx', - '.xls', - '.xlsx', - '.ppt', - '.pptx', - '.mp4', - '.mp3', - '.mov', - '.exe', - '.dmg', -] - -function isDownloadUrl(url: string): boolean { - const lower = url.toLowerCase().split('?')[0] - return DOWNLOAD_EXTENSIONS.some(ext => lower.endsWith(ext)) -} +const HIT_SLOP = { top: 10, bottom: 10, left: 10, right: 10 } function HeaderBackClose({ canGoBack, @@ -84,37 +52,19 @@ function HeaderBackClose({ ) } -function MenuButton({ - config, - currentUrl, - onRefresh, -}: { - config: XWebViewConfig - currentUrl: string - onRefresh: () => void -}) { - const handleDefaultMenu = useCallback(() => { +function MenuButton({ config, currentUrl }: { config: XWebViewConfig; currentUrl: string }) { + const openDefaultMenu = useCallback(() => { const actions: { label: string; handler: () => void }[] = [ - { label: '刷新', handler: onRefresh }, - { - label: '复制链接', - handler: () => { - Clipboard.setString(currentUrl) - }, - }, + { label: '刷新', handler: XWebViewControl.refresh }, + { label: '复制链接', handler: () => Clipboard.setString(currentUrl) }, ] - - if (config.openForBrowser) { - actions.push({ - label: '浏览器打开', - handler: () => Linking.openURL(currentUrl), - }) + if (config.openForBrowser && currentUrl) { + actions.push({ label: '浏览器打开', handler: () => void Linking.openURL(currentUrl) }) } - if (Platform.OS === 'ios') { ActionSheetIOS.showActionSheetWithOptions( { - options: [...actions.map(a => a.label), '取消'], + options: [...actions.map(action => action.label), '取消'], cancelButtonIndex: actions.length, }, index => { @@ -123,171 +73,76 @@ function MenuButton({ ) return } - Alert.alert( '操作', undefined, [ - ...actions.map(a => ({ text: a.label, onPress: a.handler })), + ...actions.map(action => ({ text: action.label, onPress: action.handler })), { text: '取消', style: 'cancel' as const }, ], { cancelable: true }, ) - }, [config, currentUrl, onRefresh]) + }, [config.openForBrowser, currentUrl]) if (config.clickMenu) { - const { clickMenu } = config return ( - - {clickMenu.view ?? } + + {config.clickMenu.view ?? } ) } - return ( - + ) } -const DOWNLOAD_EXT_RE = - /\.(exe|apk|ipa|zip|rar|tar|gz|dmg|pkg|deb|rpm|msi|pdf|doc|docx|xls|xlsx|ppt|pptx|mp4|mp3|mov|avi|mkv)(\?|#|$)/i - -const DIALOG_OVERRIDE_JS = ` -(function() { - function post(obj) { - window.ReactNativeWebView.postMessage(JSON.stringify(obj)); - } - - window.alert = function(msg) { post({ __xwv: 'alert', msg: String(msg) }); }; - window.confirm = function(msg) { post({ __xwv: 'confirm', msg: String(msg) }); return true; }; - window.prompt = function(msg, def) { post({ __xwv: 'prompt', msg: String(msg), def: def || '' }); return def || ''; }; - - window.open = function(url) { - post({ __xwv: 'log', msg: '[XWV] window.open: ' + url }); - if (url) { window.location.href = url; } - return null; - }; - - URL.revokeObjectURL = function() {}; - - function readBlobAndPost(blobUrl, filename) { - fetch(blobUrl) - .then(function(r) { return r.blob(); }) - .then(function(blob) { - var reader = new FileReader(); - reader.onloadend = function() { - var b64 = reader.result.split(',')[1]; - post({ __xwv: 'blobdownload', url: blobUrl, filename: filename, data: b64 }); - }; - reader.readAsDataURL(blob); - }) - .catch(function(err) { - post({ __xwv: 'bloberror', msg: String(err) }); - }); - } - - var DL_RE = ${JSON.stringify(DOWNLOAD_EXT_RE.source)}; - var dlRe = new RegExp(DL_RE, 'i'); - - function tryInterceptAnchor(el, e) { - if (!el || el.tagName !== 'A') return false; - var href = el.href || el.getAttribute('href') || ''; - if (!href || href.indexOf('javascript') === 0) return false; - var hasDownloadAttr = el.hasAttribute('download'); - var dlName = el.getAttribute('download') || ''; - var isDL = hasDownloadAttr || dlRe.test(href); - post({ __xwv: 'log', msg: '[XWV] anchor href=' + href + ' download=' + hasDownloadAttr + ' dlName=' + dlName + ' isDL=' + isDL }); - if (isDL) { - if (e) { e.preventDefault(); e.stopPropagation(); } - if (href.startsWith('blob:')) { - readBlobAndPost(href, dlName); - } else { - post({ __xwv: 'download', url: href, filename: dlName }); - } - return true; - } - return false; - } - - document.addEventListener('click', function(e) { - var el = e.target; - while (el && el.tagName !== 'A') { el = el.parentElement; } - tryInterceptAnchor(el, e); - }, true); - - var _origClick = HTMLAnchorElement.prototype.click; - HTMLAnchorElement.prototype.click = function() { - post({ __xwv: 'log', msg: '[XWV] HTMLAnchorElement.click() href=' + this.href }); - if (!tryInterceptAnchor(this, null)) { - _origClick.call(this); - } - }; - - post({ __xwv: 'log', msg: '[XWV] injected scripts ready' }); -})(); -true; -` - +/** + * 带统一导航栏的全屏容器。WebView、下载、权限、Bridge 和错误态全部复用 + * XWebViewView,避免 Screen/View 各维护一套浏览器内核。 + */ export function XWebViewScreen() { const navigation = useNavigation() - const webViewRef = useRef(null) - const lastBackPressRef = useRef(0) - const config = getXWebViewConfig() const { - showTopBar = true, - showStatusBar = true, - doubleBackExit = false, - showTitle = true, autoTitle = false, + doubleBackExit = false, showMenu = true, + showStatusBar = true, + showTitle = true, + showTopBar = true, title: initialTitle = '', - url, - headers, - content, - injectedJavaScript, - onMessage: userOnMessage, - onNavigationStateChange, - onPermissionRequest, - autoDownload = true, - downloadConflict = 'rename', - downloadDestination = 'sandbox', - downloadNotificationTitle, - onDownloadStart, - onDownloadProgress, - onDownloadComplete, - onDownloadError, - onDownloadDecide, - onClose, } = config + const [navigationState, setNavigationState] = useState({ + canGoBack: false, + canGoForward: false, + title: initialTitle, + url: config.url ?? '', + }) + const lastBackPressRef = useRef(0) - const [progress, setProgress] = useState(0) - const [showProgress, setShowProgress] = useState(false) - const [canGoBack, setCanGoBack] = useState(false) - const [canGoForward, setCanGoForward] = useState(false) - const [currentUrl, setCurrentUrl] = useState(url ?? '') - const [currentTitle, setCurrentTitle] = useState(initialTitle) - const [loadError, setLoadError] = useState(null) - const handlePermissionRequest = useXWebViewPermissionRequest(onPermissionRequest) + const close = useCallback(() => { + config.onClose?.() + navigation.goBack() + }, [config, navigation]) - useEffect(() => { - setXWebViewController({ - refresh: () => webViewRef.current?.reload(), - close: () => { - onClose?.() - navigation.goBack() - }, - goBack: () => webViewRef.current?.goBack(), - goForward: () => webViewRef.current?.goForward(), - copyUrl: () => Clipboard.setString(currentUrl), - postMessageToWeb: (js: string) => webViewRef.current?.injectJavaScript(js), - getTitle: () => currentTitle, - getNavigationState: () => ({ canGoBack, canGoForward, title: currentTitle, url: currentUrl }), - }) - return () => setXWebViewController(null) - }, [canGoBack, canGoForward, currentUrl, currentTitle, navigation, onClose]) + const handleNavigationStateChange = useCallback( + (state: XWebViewNavigationState) => { + setNavigationState(state) + config.onNavigationStateChange?.(state) + }, + [config], + ) + + const screenConfig = useMemo( + () => ({ + ...config, + onClose: close, + onNavigationStateChange: handleNavigationStateChange, + }), + [close, config, handleNavigationStateChange], + ) useLayoutEffect(() => { navigation.setOptions({ headerShown: false }) @@ -295,295 +150,56 @@ export function XWebViewScreen() { useEffect(() => { if (Platform.OS !== 'android') return - const subscription = BackHandler.addEventListener('hardwareBackPress', () => { - if (canGoBack) { - webViewRef.current?.goBack() + if (navigationState.canGoBack) { + XWebViewControl.goBack() return true } - - if (!doubleBackExit) { - onClose?.() - navigation.goBack() - return true + if (doubleBackExit) { + const now = Date.now() + if (now - lastBackPressRef.current > 2000) { + lastBackPressRef.current = now + ToastAndroid.show('再按一次退出', ToastAndroid.SHORT) + return true + } } - - const now = Date.now() - if (now - lastBackPressRef.current < 2000) { - BackHandler.exitApp() - return true - } - - lastBackPressRef.current = now - ToastAndroid.show('再按一次退出应用', ToastAndroid.SHORT) + close() return true }) - return () => subscription.remove() - }, [canGoBack, doubleBackExit, navigation, onClose]) + }, [close, doubleBackExit, navigationState.canGoBack]) - const handleNavigationStateChange = useCallback( - (state: WebViewNavigation) => { - setCanGoBack(state.canGoBack) - setCanGoForward(state.canGoForward) - if (state.url) setCurrentUrl(state.url) - if (autoTitle && state.title) setCurrentTitle(state.title) - onNavigationStateChange?.({ - canGoBack: state.canGoBack, - canGoForward: state.canGoForward, - title: state.title ?? '', - url: state.url ?? '', - }) - }, - [autoTitle, onNavigationStateChange], - ) - - const handleLoadProgress = useCallback((event: WebViewProgressEvent) => { - const p = event.nativeEvent.progress - setProgress(p) - if (p >= 0.96) setShowProgress(false) - }, []) - - const processBlobDownload = useCallback( - async (blobUrl: string, hintFilename: string, base64: string) => { - const info = { url: blobUrl, suggestedFilename: hintFilename || 'download' } - - let finalFilename = info.suggestedFilename - let finalSavePath: string | undefined - - if (!autoDownload) { - if (!onDownloadDecide) return - const decision = await Promise.resolve(onDownloadDecide(info)) - if (!decision.allowed) return - if (decision.filename) finalFilename = decision.filename - if (decision.savePath) finalSavePath = decision.savePath - } - - onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) - - try { - const filePath = await saveBase64File( - base64, - finalFilename, - finalSavePath, - downloadConflict, - downloadDestination, - ) - onDownloadComplete?.({ url: blobUrl, filename: finalFilename, filePath, fileSize: 0 }) - } catch (err) { - onDownloadError?.(blobUrl, String(err)) - } - }, - [ - autoDownload, - downloadConflict, - downloadDestination, - onDownloadDecide, - onDownloadStart, - onDownloadComplete, - onDownloadError, - ], - ) - - const processDownload = useCallback( - async (downloadUrl: string, hintFilename?: string) => { - const info = await fetchDownloadInfo(downloadUrl, hintFilename) - - let finalFilename = info.suggestedFilename - let finalSavePath: string | undefined - - if (!autoDownload) { - if (!onDownloadDecide) return - const decision = await Promise.resolve(onDownloadDecide(info)) - if (!decision.allowed) return - if (decision.filename) finalFilename = decision.filename - if (decision.savePath) finalSavePath = decision.savePath - } - - onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) - - startDownload( - downloadUrl, - finalFilename, - finalSavePath, - downloadConflict, - p => onDownloadProgress?.(p), - r => onDownloadComplete?.(r), - err => onDownloadError?.(downloadUrl, err), - { destination: downloadDestination, notificationTitle: downloadNotificationTitle }, - ) - }, - [ - autoDownload, - downloadConflict, - downloadDestination, - downloadNotificationTitle, - onDownloadDecide, - onDownloadStart, - onDownloadProgress, - onDownloadComplete, - onDownloadError, - ], - ) - - const handleMessage = useCallback( - (event: WebViewMessageEvent) => { - const raw = event.nativeEvent.data - try { - const parsed = JSON.parse(raw) - - if (parsed.__xwv === 'download') { - processDownload(String(parsed.url), parsed.filename ? String(parsed.filename) : undefined) - return - } - if (parsed.__xwv === 'blobdownload') { - processBlobDownload( - String(parsed.url), - String(parsed.filename ?? 'download'), - String(parsed.data ?? ''), - ) - return - } - if (parsed.__xwv === 'bloberror') { - onDownloadError?.(String(parsed.url ?? ''), String(parsed.msg ?? 'blob read failed')) - return - } - if (parsed.__xwv === 'alert') { - Alert.alert('提示', String(parsed.msg ?? '')) - return - } - if (parsed.__xwv === 'confirm') { - Alert.alert('确认', String(parsed.msg ?? ''), [{ text: '知道了' }]) - return - } - if (parsed.__xwv === 'prompt') { - Alert.alert('输入', `${parsed.msg}${parsed.def ? `\n默认值: ${parsed.def}` : ''}`) - return - } - } catch { - // not an internal message - } - userOnMessage?.(event) - }, - [processDownload, processBlobDownload, onDownloadError, userOnMessage], - ) - - const handleShouldStartLoad = useCallback( - (request: ShouldStartLoadRequest): boolean => { - const isDL = isDownloadUrl(request.url) - if (isDL) { - processDownload(request.url) - return false - } - return true - }, - [processDownload], - ) - - const handleError = useCallback((event: WebViewErrorEvent) => { - setLoadError(event.nativeEvent.description || '页面加载失败') - setShowProgress(false) - }, []) - - const handleOpenWindow = useCallback((event: WebViewOpenWindowEvent) => { - const targetUrl = event.nativeEvent.targetUrl - if (targetUrl) { - webViewRef.current?.injectJavaScript( - `window.location.href = ${JSON.stringify(targetUrl)}; true;`, - ) - } - }, []) - - const source = url - ? ({ uri: url, headers } as const) - : ({ html: content ?? '' } as const) - - const injected = DIALOG_OVERRIDE_JS + '\n' + (injectedJavaScript ?? '') + '\ntrue;' const shouldShowStatusBar = showTopBar || showStatusBar const ContentContainer = shouldShowStatusBar ? SafeAreaView : View + const displayedTitle = autoTitle ? navigationState.title : initialTitle return ( ) } -const HIT_SLOP = { top: 10, bottom: 10, left: 10, right: 10 } - const styles = StyleSheet.create({ - container: { - flex: 1, - backgroundColor: '#ffffff', - }, + container: { flex: 1, backgroundColor: '#ffffff' }, topBar: { height: 48, paddingHorizontal: 10, @@ -600,15 +216,9 @@ const styles = StyleSheet.create({ fontSize: 16, fontWeight: '700', color: '#111827', + textAlign: 'center', }, - webview: { - flex: 1, - }, - headerLeft: { - flexDirection: 'row', - alignItems: 'center', - gap: 5, - }, + headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 5 }, headerBtn: { paddingHorizontal: 0, paddingVertical: 0, @@ -616,37 +226,5 @@ const styles = StyleSheet.create({ alignItems: 'center', minWidth: 22, }, - headerBtnPlaceholder: { - minWidth: 22, - }, - errorContainer: { - flex: 1, - alignItems: 'center', - justifyContent: 'center', - paddingHorizontal: 32, - gap: 12, - }, - errorTitle: { - fontSize: 18, - fontWeight: '800', - color: '#b91c1c', - }, - errorMessage: { - fontSize: 13, - color: '#7f1d1d', - textAlign: 'center', - lineHeight: 20, - }, - retryBtn: { - marginTop: 8, - backgroundColor: '#3b82f6', - borderRadius: 8, - paddingHorizontal: 24, - paddingVertical: 10, - }, - retryText: { - color: '#ffffff', - fontWeight: '700', - fontSize: 15, - }, + headerBtnPlaceholder: { minWidth: 22 }, }) diff --git a/packages/xwebview/src/XWebViewView.tsx b/packages/xwebview/src/XWebViewView.tsx index 328cfbf..e77c1dc 100644 --- a/packages/xwebview/src/XWebViewView.tsx +++ b/packages/xwebview/src/XWebViewView.tsx @@ -124,12 +124,23 @@ const DIALOG_OVERRIDE_JS = ` true; ` -export function XWebViewView({ config: configProp }: { config?: XWebViewConfig } = {}) { +export function XWebViewView({ + config: configProp, + manageSystemBars = true, + registerController, +}: { + config?: XWebViewConfig + /** 外层 Screen 自己管理安全区和状态栏时关闭。 */ + manageSystemBars?: boolean + /** 组合式 Screen 传入 config 时仍可显式注册全局控制器。 */ + registerController?: boolean +} = {}) { const webViewRef = useRef(null) // configProp 用于内联嵌入(不依赖全局单例 config,可同屏多实例); // 不传时回退到 openXWebView 设置的全局 config(全屏/miniapp 用法)。 const inline = configProp != null + const shouldRegisterController = registerController ?? !inline const config = configProp ?? getXWebViewConfig() const { showStatusBar = true, @@ -161,22 +172,33 @@ export function XWebViewView({ config: configProp }: { config?: XWebViewConfig } const [currentTitle, setCurrentTitle] = useState(initialTitle) const [loadError, setLoadError] = useState(null) const handlePermissionRequest = useXWebViewPermissionRequest(onPermissionRequest) + const reload = useCallback(() => { + setLoadError(null) + setShowProgress(true) + webViewRef.current?.reload() + }, []) useEffect(() => { // 内联模式不注册全局 controller,避免与全屏 XWebView 实例互相覆盖。 - if (inline) return + if (!shouldRegisterController) return setXWebViewController({ - refresh: () => webViewRef.current?.reload(), + refresh: reload, close: () => onClose?.(), - goBack: () => webViewRef.current?.goBack(), - goForward: () => webViewRef.current?.goForward(), + goBack: () => { + setLoadError(null) + webViewRef.current?.goBack() + }, + goForward: () => { + setLoadError(null) + webViewRef.current?.goForward() + }, copyUrl: () => Clipboard.setString(currentUrl), postMessageToWeb: (js: string) => webViewRef.current?.injectJavaScript(js), getTitle: () => currentTitle, getNavigationState: () => ({ canGoBack, canGoForward, title: currentTitle, url: currentUrl }), }) return () => setXWebViewController(null) - }, [canGoBack, canGoForward, inline, currentUrl, currentTitle, onClose]) + }, [canGoBack, canGoForward, currentUrl, currentTitle, onClose, reload, shouldRegisterController]) const handleNavigationStateChange = useCallback( (state: WebViewNavigation) => { @@ -343,31 +365,19 @@ export function XWebViewView({ config: configProp }: { config?: XWebViewConfig } : ({ html: content ?? '' } as const) const injected = DIALOG_OVERRIDE_JS + '\n' + (injectedJavaScript ?? '') - const ContentContainer = showStatusBar ? SafeAreaView : View + const ContentContainer = manageSystemBars && showStatusBar ? SafeAreaView : View return ( - ) } @@ -400,9 +422,17 @@ const styles = StyleSheet.create({ webview: { flex: 1, }, - errorContainer: { + content: { flex: 1, + }, + errorContainer: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, alignItems: 'center', + backgroundColor: '#ffffff', justifyContent: 'center', paddingHorizontal: 32, gap: 12, diff --git a/packages/xwebview/src/permissionPolicy.ts b/packages/xwebview/src/permissionPolicy.ts index 003451d..972a73b 100644 --- a/packages/xwebview/src/permissionPolicy.ts +++ b/packages/xwebview/src/permissionPolicy.ts @@ -16,13 +16,16 @@ export function webPermissionKind(resource: string): WebPermissionKind | null { return null } -/** 只向 WebView 放行已取得系统授权的受控资源;未知资源保持原生默认语义。 */ +/** + * 只向 WebView 放行已取得系统授权的摄像头或麦克风资源。 + * 未识别资源必须拒绝,避免 WebView 内核新增资源类型后被宿主无条件授权。 + */ export function allowedWebPermissionResources( resources: string[], grantedKinds: ReadonlySet, ): string[] { return resources.filter(resource => { const kind = webPermissionKind(resource) - return kind === null || grantedKinds.has(kind) + return kind !== null && grantedKinds.has(kind) }) } diff --git a/packages/xwebview/tests/permissionPolicy.test.ts b/packages/xwebview/tests/permissionPolicy.test.ts index e9f44d2..c95bc2c 100644 --- a/packages/xwebview/tests/permissionPolicy.test.ts +++ b/packages/xwebview/tests/permissionPolicy.test.ts @@ -17,12 +17,9 @@ test('only system-authorized camera or microphone resources are granted', () => 'android.webkit.resource.AUDIO_CAPTURE', 'protected-media-id', ] - assert.deepEqual(allowedWebPermissionResources(resources, new Set(['camera'])), [ - resources[0], - resources[2], - ]) + assert.deepEqual(allowedWebPermissionResources(resources, new Set(['camera'])), [resources[0]]) assert.deepEqual(allowedWebPermissionResources(resources, new Set(['microphone'])), [ resources[1], - resources[2], ]) + assert.deepEqual(allowedWebPermissionResources([resources[2]], new Set()), []) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 29d2380..931550f 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -121,6 +121,9 @@ importers: packages/update: dependencies: + fflate: + specifier: 0.8.3 + version: 0.8.3 semver: specifier: 7.8.5 version: 7.8.5 @@ -984,6 +987,9 @@ packages: picomatch: optional: true + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + fill-range@7.1.1: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} @@ -2423,6 +2429,8 @@ snapshots: optionalDependencies: picomatch: 4.0.5 + fflate@0.8.3: {} + fill-range@7.1.1: dependencies: to-regex-range: 5.0.1