feat(update): release-set 事务制品与原生层职责拆分

- 插件包改为 .xuqm.zip 事务制品,manifest/bundle/资源/SHA-256 统一校验
- 原生层拆分为桥接编排、插件包暂存、底层存储三个单一职责类
- 状态查询改为纯读取,不再误触发安装;新增 NATIVE_BASELINE_INCOMPATIBLE 拒绝
- SemVer 解析拆分为独立原生类并补原生测试
- XWebView 收口唯一内核,错误态统一中文层与重试
- common 增加请求头/运行时契约收口与 http 测试
这个提交包含在:
XuqmGroup 2026-07-20 19:31:43 +08:00
父节点 d677a9ee52
当前提交 507ce3d2ee
共有 52 个文件被更改,包括 2054 次插入751 次删除

查看文件

@ -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`:通过。
- common19 个测试通过。
- common20 个测试通过。
- bugcollect5 个独立测试通过。
- xwebview4 个独立测试通过。
- update CLI/Metro11 个测试通过;release-set6 个测试通过。
@ -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 打包。

查看文件

@ -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` 或第二套超时器。

查看文件

@ -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`

查看文件

@ -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",

查看文件

@ -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 非法会直接阻断构建,禁止打出运行时才失败的安装包。
## 扩展包的唯一登录入口

查看文件

@ -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',
)

查看文件

@ -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()

查看文件

@ -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,

查看文件

@ -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(/=+$/, '')
}
/**

查看文件

@ -44,6 +44,10 @@ export async function apiRequest<T>(
body?: unknown
params?: Record<string, string>
skipAuth?: boolean
/** 调用方取消信号;版本检查等可在页面退出时立即停止。 */
signal?: AbortSignal
/** 默认 30 秒,必须为正数;防止网络异常永久阻塞扩展入口。 */
timeoutMs?: number
} = {},
): Promise<T> {
let url = path.startsWith('http://') || path.startsWith('https://') ? path : `${_baseUrl}${path}`
@ -84,11 +88,31 @@ export async function apiRequest<T>(
})
}
const res = await fetch(url, {
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 }))

查看文件

@ -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'

查看文件

@ -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<void> | null = null
let _configFileInitPromise: Promise<void> | null = null
let _configFileRequest: {
encryptedContent: string
let _resolvedConfigInitPromise: Promise<void> | 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<string, unknown>
const remote = (json.data ?? json) as Record<string, unknown>
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<void> {
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<void> {
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<void> {
_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
}

查看文件

@ -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)

查看文件

@ -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
}
})

查看文件

@ -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()
})

查看文件

@ -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 })
}

查看文件

@ -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
}

查看文件

@ -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

查看文件

@ -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")
}

查看文件

@ -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);
}
}

查看文件

@ -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 文件系统的唯一底层实现
*
* <p>发布事务内嵌恢复和插件包暂存必须共用这里的摘要路径与原子写入语义避免不同流程
* 各自维护一套看似相同但边界不同的文件操作
*/
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);
}
}

查看文件

@ -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<String> 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<String> extractArchive(
String moduleId,
byte[] archiveBytes,
File packageDirectory) throws Exception {
Set<String> 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<String> archiveEntries,
JSONObject manifest) throws Exception {
JSONArray assets = manifest.optJSONArray("assets");
if (assets == null) assets = new JSONArray();
Set<String> 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);
}
}

查看文件

@ -0,0 +1,85 @@
package com.xuqm.update;
import java.math.BigInteger;
/**
* Android 原生 Bundle 启动门禁使用的唯一 SemVer 2.0 优先级实现
*
* <p>版本判断属于存储协议不应散落在 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);
}
}

查看文件

@ -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"));
}
}

查看文件

@ -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"));
}
}

查看文件

@ -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"));
}
}

查看文件

@ -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<String> getNodeExecutable()
@ -44,14 +55,18 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask {
abstract Property<String> 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 baselinefile:
// 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 },
)
}
}

查看文件

@ -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)
},
}
}

查看文件

@ -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",

查看文件

@ -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))

查看文件

@ -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`)

查看文件

@ -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 })
}
})

查看文件

@ -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 ?? '<missing>'}`,
)
}
}
}
}
}
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,
})),
}
}

查看文件

@ -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 })
}
})

查看文件

@ -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),

查看文件

@ -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')), [
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 })
}

查看文件

@ -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')

查看文件

@ -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<string | null>
preparePendingRelease(): Promise<string[]>
getLaunchBundlePath(moduleId: string): Promise<string>
getAssetIndex(): Promise<string>
}
function getModule(): XuqmBundleModuleInterface {
@ -130,6 +133,10 @@ export const NativeBundle = {
return getModule().getLaunchBundlePath(moduleId)
},
async getAssetIndex(): Promise<Record<string, string>> {
return parseJson(await getModule().getAssetIndex(), 'asset index')
},
getFileName(moduleId: string): string {
return `${moduleId}.${Platform.OS}.bundle`
},

查看文件

@ -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<Record<string, string>> = {}
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<void> {
if (Platform.OS !== 'android') return
registerTransformerOnce()
assetIndex = Object.freeze(await NativeBundle.getAssetIndex())
}

查看文件

@ -0,0 +1,34 @@
import type { XuqmPluginDefinition } from './PluginRuntime'
/**
*
*
* bundle app buz common bundle
* 使宿 Map
* PluginRuntime bundle
*/
const definitions = new Map<string, XuqmPluginDefinition>()
/** 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())
}

查看文件

@ -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<string, XuqmPluginDefinition>()
/** 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<void> {
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()
},
}
})()

查看文件

@ -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<InstalledPluginModule> {
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<ReleaseSetPlan | null> {
async checkPluginRelease(
targetModuleId: string,
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<ReleaseSetPlan | null> {
await awaitInitialization()
const target = requireRegistration(targetModuleId)
if (target.type === 'common') {
@ -327,6 +344,8 @@ export const UpdateSDK = {
const raw = await apiRequest<RawPluginReleaseSet>('/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<ReleaseSetPlan | null> {
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
},

查看文件

@ -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'

查看文件

@ -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}`,

查看文件

@ -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')
})

查看文件

@ -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(
() =>

查看文件

@ -1,5 +1,7 @@
# @xuqm/rn-xwebview
`XWebViewView` 是 WebView、Bridge、下载、权限和加载错误的唯一内核;`XWebViewScreen` 只组合全屏导航栏与系统返回。两者不得分别维护请求、下载、权限或错误状态机。
## 摄像头与麦克风权限
Android 宿主按实际业务在 `AndroidManifest.xml` 声明权限:
@ -9,7 +11,7 @@ Android 宿主按实际业务在 `AndroidManifest.xml` 声明权限:
<uses-permission android:name="android.permission.RECORD_AUDIO" />
```
H5 请求摄像头或麦克风时,XWebView 默认统一申请 Android 运行时权限,只向 WebView 放行系统已授权的资源。宿主无需在每个页面重复实现。需要额外来源校验或自定义提示时可提供 `onPermissionRequest`,此时授权决策完全由宿主回调负责。
H5 请求摄像头或麦克风时,XWebView 默认统一申请 Android 运行时权限,只向 WebView 放行系统已授权的摄像头或麦克风资源。无法识别的 WebView 资源一律拒绝,不因 Android 权限恰好已授予而扩大授权范围。宿主无需在每个页面重复实现。需要额外来源校验或自定义提示时可提供 `onPermissionRequest`,此时授权决策完全由宿主回调负责。
统一 WebView、JSBridge 与文件下载模块。它复用 `@xuqm/rn-common` 的配置、会话、文件和 MIME 能力,不提供第二套网络或用户状态。

查看文件

@ -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<any>
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 (
<Pressable style={styles.headerBtn} onPress={clickMenu.onClick} hitSlop={HIT_SLOP}>
{clickMenu.view ?? <IconMenu size={22} />}
<Pressable style={styles.headerBtn} onPress={config.clickMenu.onClick} hitSlop={HIT_SLOP}>
{config.clickMenu.view ?? <IconMenu size={22} />}
</Pressable>
)
}
return (
<Pressable style={styles.headerBtn} onPress={handleDefaultMenu} hitSlop={HIT_SLOP}>
<Pressable style={styles.headerBtn} onPress={openDefaultMenu} hitSlop={HIT_SLOP}>
<IconMenu size={22} />
</Pressable>
)
}
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;
`
/**
* WebViewBridge
* XWebViewView Screen/View
*/
export function XWebViewScreen() {
const navigation = useNavigation()
const webViewRef = useRef<WebView>(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 [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<string | null>(null)
const handlePermissionRequest = useXWebViewPermissionRequest(onPermissionRequest)
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 }),
const [navigationState, setNavigationState] = useState<XWebViewNavigationState>({
canGoBack: false,
canGoForward: false,
title: initialTitle,
url: config.url ?? '',
})
return () => setXWebViewController(null)
}, [canGoBack, canGoForward, currentUrl, currentTitle, navigation, onClose])
const lastBackPressRef = useRef(0)
const close = useCallback(() => {
config.onClose?.()
navigation.goBack()
}, [config, navigation])
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) {
BackHandler.exitApp()
return true
}
if (now - lastBackPressRef.current > 2000) {
lastBackPressRef.current = now
ToastAndroid.show('再按一次退出应用', ToastAndroid.SHORT)
ToastAndroid.show('再按一次退出', ToastAndroid.SHORT)
return true
}
}
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 ?? '<html><body></body></html>' } 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 (
<ContentContainer style={styles.container}>
<StatusBar hidden={!shouldShowStatusBar} />
<XWebViewProgress progress={progress} visible={showProgress} />
{showTopBar ? (
<View style={styles.topBar}>
<HeaderBackClose
canGoBack={canGoBack}
onBack={() => webViewRef.current?.goBack()}
onClose={() => {
onClose?.()
navigation.goBack()
}}
canGoBack={navigationState.canGoBack}
onBack={XWebViewControl.goBack}
onClose={close}
/>
<Text style={styles.title} numberOfLines={1}>
{showTitle ? (autoTitle ? currentTitle : initialTitle) : ''}
{showTitle ? displayedTitle : ''}
</Text>
{showMenu ? (
<MenuButton
config={config}
currentUrl={currentUrl}
onRefresh={() => webViewRef.current?.reload()}
/>
<MenuButton config={config} currentUrl={navigationState.url} />
) : (
<View style={styles.headerBtnPlaceholder} />
)}
</View>
) : null}
{loadError ? (
<View style={styles.errorContainer}>
<Text style={styles.errorTitle}></Text>
<Text style={styles.errorMessage}>{loadError}</Text>
<Pressable
style={styles.retryBtn}
onPress={() => {
setLoadError(null)
webViewRef.current?.reload()
}}
>
<Text style={styles.retryText}></Text>
</Pressable>
</View>
) : (
<WebViewAny
ref={webViewRef}
source={source}
style={styles.webview}
onLoadStart={() => {
setShowProgress(true)
setLoadError(null)
}}
onLoadProgress={handleLoadProgress}
onLoadEnd={() => setShowProgress(false)}
onError={handleError}
onNavigationStateChange={handleNavigationStateChange}
onMessage={handleMessage}
onShouldStartLoadWithRequest={handleShouldStartLoad}
onPermissionRequest={handlePermissionRequest}
injectedJavaScript={injected}
onOpenWindow={handleOpenWindow}
javaScriptEnabled
domStorageEnabled
allowFileAccess
allowsInlineMediaPlayback
sharedCookiesEnabled
mediaPlaybackRequiresUserAction={false}
setSupportMultipleWindows={false}
webviewDebuggingEnabled={__DEV__}
/>
)}
<XWebViewView config={screenConfig} manageSystemBars={false} registerController />
</ContentContainer>
)
}
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 },
})

查看文件

@ -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<WebView>(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<string | null>(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 ?? '<html><body></body></html>' } as const)
const injected = DIALOG_OVERRIDE_JS + '\n' + (injectedJavaScript ?? '')
const ContentContainer = showStatusBar ? SafeAreaView : View
const ContentContainer = manageSystemBars && showStatusBar ? SafeAreaView : View
return (
<ContentContainer style={styles.container}>
<StatusBar hidden={!showStatusBar} />
{manageSystemBars ? <StatusBar hidden={!showStatusBar} /> : null}
<XWebViewProgress progress={progress} visible={showProgress} />
{loadError ? (
<View style={styles.errorContainer}>
<Text style={styles.errorTitle}></Text>
<Text style={styles.errorMessage}>{loadError}</Text>
<Pressable
onPress={() => webViewRef.current?.reload()}
style={({ pressed }) => [styles.retryBtn, pressed && { opacity: 0.7 }]}
>
<Text style={styles.retryText}></Text>
</Pressable>
</View>
) : (
<View style={styles.content}>
<WebViewAny
ref={webViewRef}
source={source}
style={styles.webview}
onLoadStart={() => {
setShowProgress(true)
setLoadError(null)
}}
onLoadProgress={handleLoadProgress}
onLoadEnd={() => setShowProgress(false)}
@ -387,7 +397,19 @@ export function XWebViewView({ config: configProp }: { config?: XWebViewConfig }
setSupportMultipleWindows={false}
webviewDebuggingEnabled={__DEV__}
/>
)}
{loadError ? (
<View style={styles.errorContainer}>
<Text style={styles.errorTitle}></Text>
<Text style={styles.errorMessage}>{loadError}</Text>
<Pressable
onPress={reload}
style={({ pressed }) => [styles.retryBtn, pressed && { opacity: 0.7 }]}
>
<Text style={styles.retryText}></Text>
</Pressable>
</View>
) : null}
</View>
</ContentContainer>
)
}
@ -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,

查看文件

@ -16,13 +16,16 @@ export function webPermissionKind(resource: string): WebPermissionKind | null {
return null
}
/** 只向 WebView 放行已取得系统授权的受控资源;未知资源保持原生默认语义。 */
/**
* WebView
* WebView 宿
*/
export function allowedWebPermissionResources(
resources: string[],
grantedKinds: ReadonlySet<WebPermissionKind>,
): string[] {
return resources.filter(resource => {
const kind = webPermissionKind(resource)
return kind === null || grantedKinds.has(kind)
return kind !== null && grantedKinds.has(kind)
})
}

查看文件

@ -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()), [])
})

8
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