feat(update): release-set 事务制品与原生层职责拆分
- 插件包改为 .xuqm.zip 事务制品,manifest/bundle/资源/SHA-256 统一校验 - 原生层拆分为桥接编排、插件包暂存、底层存储三个单一职责类 - 状态查询改为纯读取,不再误触发安装;新增 NATIVE_BASELINE_INCOMPATIBLE 拒绝 - SemVer 解析拆分为独立原生类并补原生测试 - XWebView 收口唯一内核,错误态统一中文层与重试 - common 增加请求头/运行时契约收口与 http 测试
这个提交包含在:
父节点
d677a9ee52
当前提交
507ce3d2ee
@ -1,6 +1,6 @@
|
||||
# RN SDK 重构实施接管文档
|
||||
|
||||
> 状态更新时间:2026-07-18
|
||||
> 状态更新时间:2026-07-19
|
||||
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
|
||||
> 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。
|
||||
|
||||
@ -39,7 +39,7 @@
|
||||
- 本地已使用 JDK 21 + Gradle 9.3.1 编译通过;RN Bridge 明确输出 Java 17 字节码,不再隐式回落到 Java 8。
|
||||
- RN 包不再内置第二份 AGP buildscript;宿主负责插件版本,独立夹具使用与 Android SDK 一致的 AGP 9.1.0。
|
||||
- `pnpm validate`:通过。
|
||||
- common:19 个测试通过。
|
||||
- common:20 个测试通过。
|
||||
- bugcollect:5 个独立测试通过。
|
||||
- xwebview:4 个独立测试通过。
|
||||
- update CLI/Metro:11 个测试通过;release-set:6 个测试通过。
|
||||
@ -113,6 +113,22 @@ pnpm --dir packages/update test
|
||||
method、相对 path、HTTP 状态、错误码和 Zod 字段路径。
|
||||
- 新增测试使用带手机号、sessionId、userId、token 的伪响应,强制验证
|
||||
序列化后的诊断结果不包含这些敏感值。
|
||||
|
||||
### 2026-07-19 / common Bundle 模块过滤修复
|
||||
|
||||
- App4 模拟器首次加载内嵌插件时出现 `Requiring unknown module "726"`。定位结果不是宿主页面问题,而是 Metro 的模块编号工厂把本轮首次发现的 common 模块写入共享缓存后,过滤器又读取同一份可变缓存并把该模块从当前 Bundle 删除。
|
||||
- `withXuqmModuleConfig()` 现在只使用构建开始前的共享模块快照做去重:common 本轮新分配的模块保留在 common Bundle;后续 app/buz 仍过滤 startup/common 已有模块。宿主不增加补丁、模块白名单或第二套编号逻辑。
|
||||
- Metro 测试新增“common 本轮新模块必须保留”断言;格式、全部 workspace 类型检查及 48 项测试通过。
|
||||
- 根工作区脚本统一通过 Corepack 调用锁定的 pnpm,校验不再依赖开发机预装全局 pnpm。
|
||||
- 当前下一步:刷新 App4 的 `file:` SDK 联调副本,重建并安装 debug APK;确认六个 Hermes Bundle 可连续加载后继续双模拟器像素校准。修复尚未通过 Jenkins 发布,不得把相邻源码依赖作为正式发版基线。
|
||||
|
||||
### 2026-07-19 / Android 插件图片资源恢复
|
||||
|
||||
- App4 common/app 连续加载成功后,登录 logo 和单选框仍为空。根因是 Metro 生成的 `drawable-*` 仅位于 `assets/rn-bundles`,同时原生恢复只复制 `active.bundle`;动态 `loadBundle(file)` 会相对当前 Bundle 文件目录解析图片。
|
||||
- Gradle 生成任务现在公开 assets 与 res 两个独立 `DirectoryProperty`:Bundle/manifest 进入 APK assets,drawable 同时进入 aapt2 资源表,符合 AGP 生成源 API 的类型约束。
|
||||
- `XuqmBundleModule` 恢复内嵌 Bundle 时同步读取该模块 manifest 的 `assetFiles`,校验路径只允许标准 `drawable-*/resource_name.ext`,再原子写入同一模块目录;图片恢复不由宿主页面逐个处理。
|
||||
- App4 arm64 debug 已真实编译该 Java 实现并安装验证;app 模块私有目录共恢复 289 个 Bundle/状态/资源文件,登录 logo 与单选框显示正常。
|
||||
- 当前只取得内嵌基线恢复证据;远程 release set 的二进制 Bundle 与资源下载、校验、事务激活仍需作为同一组实现和验证,不能把本次结果扩大为 OTA 资源已完成。
|
||||
- 全局 API 错误回调不再交付带 Axios cause 的 `RequestError`,只交付不可逆的安全诊断报告;宿主无法误把请求体、headers 或响应上传到采集平台。
|
||||
- bugcollect 的全局 fetch 拦截器只上报 method、相对 path 和 HTTP status;完整 host、query、请求体、headers 及原始网络异常对象全部丢弃,测试覆盖敏感查询参数和鉴权头。
|
||||
|
||||
@ -215,3 +231,83 @@ pnpm --dir packages/update test
|
||||
- Jenkins Windows 节点直接访问外部 Android 仓库会出现无输出的长时间依赖等待;`native-test` 的 plugin management 已与 AndroidSDK 对齐,统一优先使用 Nexus `android` 聚合仓库,再回退官方仓库。
|
||||
- Windows CLI 测试路径统一使用 `fileURLToPath`,禁止把 `file:` URL 的 pathname 直接作为本机路径。
|
||||
- native baseline 现在按 Android/iOS 分别计算,并覆盖全部运行时依赖的实际安装版本、平台原生源码/资源、`src`/`assets` 内会随安装包打入的图片、字体和媒体文件;这些内容变化后必须先发布完整 App,不能只发布 JS 插件。
|
||||
|
||||
### 2026-07-19 / 同版本内嵌基线覆盖修复
|
||||
|
||||
- App4 覆盖安装新 debug APK 后仍加载手机内旧 `active.bundle`。根因是 `ensureEmbeddedBundle()` 只判断文件存在,插件版本仍为 `1.0.0` 时无法识别整包内的代码已经变化。
|
||||
- `xuqm-rn embed` 现在为每个模块记录实际 bundle 的 SHA-256;Android state 同步记录 `activeHash`。状态仍为 `embedded` 且摘要变化时,SDK 原子替换 bundle、清理该模块旧 drawable 目录并按 manifest 恢复新资源。
|
||||
- 状态为 `active` 或事务中的远程插件不会被覆盖安装改写;线上更新仍只能经过 stage/activate/confirm/rollback。该分支避免以开发便利破坏生产事务边界。
|
||||
- RN SDK 全量门禁通过。App4 重新编译 Java Bridge 与六模块共 450 tasks 成功;第一次不清 `rn-bundles` 覆盖安装把旧 state 自动写入新 manifest 摘要,第二次相同 APK 覆盖后 bundle mtime 未变化,证明变化替换和相同复用均生效。下一步为 Jenkins 发布新 alpha 后恢复 App4 精确 Nexus 版本。
|
||||
|
||||
### 2026-07-19 / 平台地址与更新检查超时收敛
|
||||
|
||||
- `XuqmConfig` 新增职责明确的 `platformUrl`;配置文件 `serverUrl/baseUrl` 只在解密入口归一为该字段。租户配置、整包和插件更新统一访问 `platformUrl`,远程 `apiUrl` 只表示业务扩展服务,禁止两者混用。
|
||||
- common HTTP 增加唯一 30 秒默认超时,并将调用方 `AbortSignal` 与内部超时合并;定时器在成功、失败和取消后统一清理,不要求每个 SDK 复制 `Promise.race`。
|
||||
- `checkPluginRelease()` 支持 `signal/timeoutMs`;`checkAndInstallPlugin()` 支持 `signal/checkTimeoutMs`,宿主可为“进入插件前检查”设置更短交互边界,同时保留纯检查、确认后安装和自动检查安装三种既有 API 语义。
|
||||
- App4 使用 5 秒检查边界:平台暂时不可达且本地 Bundle 健康时继续进入本地插件,网络异常不被误报为 Bundle 崩溃;真实 `szyx/active.bundle` 已在 Android 模拟器加载成功。
|
||||
- common 新增停滞请求超时测试,并扩展生命周期/初始化测试覆盖 `platformUrl` 与 `apiUrl` 分离;全量 `pnpm validate` 再次通过:六个 workspace 类型检查、common 20、bugcollect 5、xwebview 4、IM 3、update CLI/Metro 11 与 release-set 6 项测试全部无回归。
|
||||
|
||||
### 2026-07-20 / 启动壳轻量原生分包入口
|
||||
|
||||
- `@xuqm/rn-update` 新增公开子路径 `@xuqm/rn-update/native-bundle`,只导出原生 Bundle 状态与路径桥,不加载 AsyncStorage、HTTP、SemVer、整包更新或 release-set 编排。
|
||||
- App4 startup 已改用 `NativeBundle.preparePendingRelease()` / `getLaunchPath()`;完整 `UpdateSDK` 仍只在 app 更新和插件激活阶段加载,不存在第二套状态实现。
|
||||
- App4 的 startup Android Hermes bytecode 从此前约 2.2 MB 降至约 1.27 MB。该数据只证明启动入口依赖收敛;common/app 动态装载和模拟器交互首屏仍需独立计时,不能只用 startup 文件大小宣称启动性能完成。
|
||||
- `packages/update/package.json` 的 exports、README 与 App4 实际调用已同步。该变更仍是 main 本地源码联调状态,未通过 Jenkins 发布新 alpha,外部项目不得提前写入一个尚不存在的 Nexus 版本号。
|
||||
|
||||
### 2026-07-20 / common 构建期配置解析与 XWebView 权限收口
|
||||
|
||||
- App4 冷启动日志证明 common 请求与 app 请求之间曾固定阻塞约 10.6 秒;直接读取原生 Bundle 仅约 1ms。根因是 common 自动初始化在 Hermes 主线程执行 120,000 次纯 JavaScript PBKDF2,而不是 Bundle IO 或 App 页面渲染。
|
||||
- `withXuqmConfig()` 现在使用 Node `crypto` 在 Metro 构建期完成 PBKDF2、AES-256-GCM 解密和 Schema 校验,虚拟模块只导出解析后的只读配置。设备端自动初始化不再执行解密;临时网络失败重试复用同一份解析结果,不恢复第二套初始化入口。
|
||||
- App4 重新生成六模块并安装后,common 请求到 app 请求的间隔由约 10.6 秒降至约 405ms;一次 Android 冷启动 `Displayed` 为 783ms。该结果是 API 34 ARM64 软件模拟器观测值,只作为本轮相对回归证据,不替代真机性能验收。
|
||||
- XWebView 默认权限策略只接受摄像头和麦克风两类已知 WebView 资源,并逐项核对 Android 运行时授权;未知资源明确拒绝。单测覆盖摄像头、麦克风、混合资源、拒绝和未知资源场景。
|
||||
- common 18 项 TypeScript 测试、2 项 Metro 测试、类型检查,以及 xwebview 4 项测试和类型检查均通过。App4 当前使用相邻 `file:` 源码联调;完成设备 H5 权限闭环后仍需由 Jenkins 发布新 alpha,再恢复精确 Nexus 版本。
|
||||
|
||||
### 2026-07-20 / 多 Bundle 插件注册表单例收口
|
||||
|
||||
- App4 内嵌 `miniapp` 设备验证暴露 `[XuqmRuntime] Plugin did not call definePlugin()`。bundle 摘要与内嵌 manifest 完全一致,插件入口也确实调用 `definePlugin()`;根因是 app/buz 模块区间隔离后,各自复制的 `PluginRuntime` 创建了不同 `definitions` Map。
|
||||
- update SDK 将插件定义 Map 拆到轻量 `PluginRegistry.ts`,并导出 `@xuqm/rn-update/plugin-registry` 子路径。宿主 common 入口必须副作用导入该子路径,使 app 与所有 buz 通过共享模块编号读写同一个注册表;不得在 App 内写兼容 Map 或改成全局变量。
|
||||
- 新增注册、读取、删除和重复定义测试;RNSDK 完整 `pnpm validate` 已通过:common 20、bugcollect 5、xwebview 4、IM 3、update CLI/Metro 11 与 update domain 8 项测试全部通过。
|
||||
- App4 重建六模块并进入真实“医信课堂”后,注册表错误消失;设备随后进一步暴露并修复两类相同边界缺陷:app/miniapp 重复执行 `BVLinearGradient` 原生 View 注册,以及 miniapp 反向导入 host 后复制 `UpdateContext`。宿主 common 现统一持有跨 Bundle 原生依赖;插件通过 common 更新命令端口调用 host 实现,不再导入宿主 Context,也未增加全局变量兼容层。
|
||||
- 最终 `emulator-5556` 的 common/app/miniapp 均加载成功,日志无插件注册、重复 View、Context 或 Java/React FATAL;XWebView 容器已显示真实服务标题和 DNS 错误页。该结果只证明多 Bundle 与容器运行时通过,远端 H5 内容及摄像头/麦克风授权仍需网络正常的开发环境设备验收。
|
||||
|
||||
### 2026-07-20 / XWebView 浏览器内核与错误态唯一实现
|
||||
|
||||
- 设备 DNS 失败复测发现 Android 系统英文错误页会在 `onError` 后再次触发加载事件,旧实现因此清空自定义错误状态;同时错误分支卸载 WebView,按钮持有的 ref 已为空,重试实际无效。
|
||||
- `XWebViewView` 现在始终保留 WebView,并以白底中文错误层覆盖系统页;刷新、前进、后退和重试统一先清理错误状态再操作同一控制器。错误页不再由原生事件先后顺序随机决定。
|
||||
- 删除 `XWebViewScreen` 内 600 余行重复的 WebView、下载、Bridge、权限和错误处理。Screen 现在只组合统一导航栏、系统返回与 `XWebViewView`;全屏和内嵌场景共享同一个浏览器内核实现,不再修复一处、遗漏另一处。
|
||||
- xwebview 类型检查与 4 项下载/权限测试通过;RNSDK 全量 `pnpm validate` 同步通过。App4 已刷新相邻 `file:` 依赖并完成 457 tasks 构建:首次 DNS 失败与点击重试后都稳定显示统一中文错误层,截图为 `/tmp/app4-xwebview-error-unified.png`、`/tmp/app4-xwebview-retry-final.png`,日志无 Java/React FATAL。
|
||||
|
||||
### 2026-07-20 / Android 内嵌 Bundle 启动短路
|
||||
|
||||
- 审计确认旧 `ensureEmbeddedBundle()` 每次查询模块路径或状态都会先从 APK assets 读取整份 Bundle,再用摘要判断是否需要覆盖;虽然不会每次写盘,但六模块冷启动仍产生无效的大文件读取和摘要对象创建。
|
||||
- Android 原生模块现将 APK `manifest.json` 按进程缓存,并先读取本地 `state.json`:下载版本 SemVer 大于等于内嵌版本、原生 baseline/API 等级兼容且记录长度正常时直接加载;APK 自有 embedded 状态则使用 manifest SHA-256 短路,同版本新开发构建仍能正确替换。
|
||||
- 恢复 API 始终按传入的单个 moduleId 工作,不预装 manifest 中的全部模块。宿主冷启动只请求 common、app;buz 必须在用户进入相应入口后,通过目标插件的检查/安装与 `XuqmRuntime.activate()` 按需恢复和执行。
|
||||
- 设备清数据复核进一步发现,启动期 app/common 更新检查会收集全部已登记模块版本,而旧 `getBundleState()` 内部错误调用 `ensureEmbeddedBundle()`,导致“查询状态”顺带把三个 buz 全部释放到私有目录。该副作用已删除:状态存在时只读 state,未访问模块从 manifest 构造只读内嵌状态;只有 `getLaunchBundlePath(moduleId)` 能触发恢复。
|
||||
- manifest 新增 `byteLength`;远程 staging 同步记录 pending/active/rollback 长度。首次安装、本地落后、baseline 不匹配、长度异常或摘要变化时才读取内嵌 Bundle,且写盘前强制校验 manifest SHA-256,再原子恢复 Bundle 与 drawable 资源。
|
||||
- release-set 规划器新增 `NATIVE_BASELINE_INCOMPATIBLE`,候选缺失或不匹配当前 `builtAgainstNativeBaselineId` 时拒绝安装,避免修改原生依赖后仍向旧完整包发布 JS 插件。
|
||||
- RNSDK 全量格式、六 workspace TypeScript、common 20、bugcollect 5、xwebview 4、IM 3、update CLI/Metro 11 与 update domain 9 项测试通过;App4 宿主真实执行 `:xuqm_rn-update:compileDebugJavaWithJavac`,19 tasks 构建成功,完整六模块 Debug APK 457 tasks 构建成功。
|
||||
- `emulator-5556` 覆盖安装后的第一次启动只为旧状态补写 `activeByteLength` 等小型元数据,未重写已有 common `active.bundle`;第二次 `force-stop` 冷启动前后,common/app 的 `active.bundle` 与 `state.json` mtime、长度均完全不变,日志确认两者直接从应用私有目录 `rn-bundles/*/active.bundle` 装载,common 到 app 请求间隔约 15ms,且无 Java/React FATAL。两次 `am start -W` TotalTime 分别为 1052ms、1085ms,只作为模拟器回归记录;系统首帧耗时不能单独归因于 Bundle 短路,也不替代真机性能验收。
|
||||
- 修复状态查询副作用后重新构建 457 tasks APK,并在 `emulator-5556` 清除数据验证:首次启动私有目录只有 `rn-bundles/common`、`rn-bundles/app`,不再出现 szyx/miniapp/workbench;第二次冷启动仍只有这两个目录,四个 bundle/state 文件的 mtime 与长度完全一致。两次清数据/二次启动 TotalTime 分别为 1137ms、1028ms,只作为正确性和相对回归记录。
|
||||
|
||||
### 2026-07-20 / 本地原生依赖 baseline 与原生 SemVer 收口
|
||||
|
||||
- `XuqmBundleModule` 不再内嵌一套 80 余行 SemVer 算法。Android 启动门禁统一调用包内 `XuqmSemanticVersion`;原生单测覆盖 SemVer 官方预发布顺序、构建元数据、超大数字标识和非法旧状态,非法状态固定返回“不可信”并恢复 APK 基线。
|
||||
- 完整包 baseline 原先只记录宿主 Android/iOS 源码和依赖版本;相邻 `file:` SDK 在版本号未变时修改 Java/Kotlin 会被漏掉。`computeNativeBaseline()` 现在额外哈希每个 `file:` 运行时依赖的对应平台原生源码与资源,registry 依赖仍由不可变安装版本标识,不扫描整个 `node_modules`。
|
||||
- Gradle `prepareXuqmEmbeddedBundles` 同步把所有 `file:` 依赖的 Android/iOS 原生源码、资源、podspec 与 package manifest 声明为输入,并排除 build/.cxx/.gradle/generated。baseline 计算和 Gradle 增量使用同一边界,不再出现脚本认为变化而 Gradle 跳过、或 Gradle 重建但 baseline 不变的分裂状态。
|
||||
- 新增 Node fixture 证明本地依赖版本不变、只修改原生源码也会改变 Android baseline;RNSDK 全量格式、六 workspace 类型检查与全部测试通过。update CLI/Metro 测试增至 14 项,domain 仍为 9 项。
|
||||
- App4 刷新本地依赖后,旧 baseline `android-sha256:4567…7501` 正确触发六模块重建,新 baseline 为 `android-sha256:eae4…99df`;紧接着复跑 998ms,11 tasks 全部 `UP-TO-DATE`。原生 SemVer 单测、update Java 编译与 App4 457 tasks Debug 构建均通过。
|
||||
- 新 APK 在 `emulator-5556` 不清用户数据覆盖安装后,common/app 状态都原子刷新为 1.0.0、native API 2 和新 baseline;私有目录仍只有 common/app,无 buz,也无 Java/React FATAL。宿主同时运行 DevEco Studio/Harmony 模拟器导致本轮三次冷启动约 2.27–2.47s、fully-drawn 约 3.28–3.55s;该受竞争负载样本只保留为功能回归证据,不覆盖独立性能基准。
|
||||
|
||||
### 2026-07-20 / Android 插件包暂存与存储职责拆分
|
||||
|
||||
- `XuqmBundleModule` 不再同时承担 RN Bridge、发布事务、ZIP 解压、manifest 校验、摘要和文件系统实现。下载包大小/SHA-256、ZIP 条目与解压上限、manifest/候选版本一致性、Bundle/资源完整性统一归入 `XuqmPluginPackageStager`;桥接模块只编排 stage/activate/confirm/rollback。
|
||||
- `XuqmBundleStorage` 成为插件更新原生层文件读取、安全相对路径、SHA-256、原子替换和递归删除的唯一实现;内嵌 Bundle 恢复与远端插件暂存共用同一摘要和写盘语义,没有保留旧私有方法或补丁兼容层。
|
||||
- `XuqmBundleModule` 从 1081 行降至 853 行。新增原生单测覆盖标准 SHA-256、目录穿越拒绝、Android 资源路径白名单,并与既有 SemVer 测试一起在 App4 宿主执行:3 个测试类、6 项测试、0 failure/error。
|
||||
- App4 刷新相邻 `file:` 依赖后,原生源码变化按设计触发六模块重建,native baseline 更新为 `android-sha256:1a95…5cf0`;紧接着复跑 `:app:prepareXuqmEmbeddedBundles` 为 11 tasks 全部 `UP-TO-DATE`。完整 `:app:assembleDebug` 与 `:xuqm_rn-update:testDebugUnitTest` 同轮构建成功。
|
||||
|
||||
### 2026-07-20 / Gradle 9.3.1 宿主兼容复核
|
||||
|
||||
- update Android 仓库声明已改为 Gradle 9 持续支持的 `url = uri(...)` 赋值形式,不再由 Xuqm 自有脚本产生旧 Groovy property-space assignment warning;不在 SDK 内覆盖宿主 AGP、Kotlin、Gradle 或 React Native 版本。
|
||||
- App4 按 RN 0.86 官方模板切换到 Gradle 9.3.1 后,真实执行 `:xuqm_rn-update:testDebugUnitTest` 成功,并完成 451 tasks 六模块 Debug APK 构建;原生存储、暂存、SemVer 与 Bridge 拆分均进入宿主编译,不只是独立源码测试。
|
||||
- 本轮 SDK 定向复核结果:common 21 tests、update CLI/Metro/native-baseline 14 tests、release-set/plugin-registry 9 tests、bugcollect 5 tests、xwebview 4 tests;根工作区及各 package TypeScript、Prettier 和 update 发布包 35 文件校验均通过。App4 完整门禁同步达到 20 suites / 87 tests。
|
||||
- Gradle 9 构建仍报告的弃用项来自 React Native/三方插件,不通过 SDK 关闭 warning。后续升级必须继续以宿主完整构建和原生单测为门禁,不能只验证 TypeScript 或 npm 打包。
|
||||
|
||||
@ -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 依赖由不可变版本号进入 baseline;file: 依赖在版本不变时也可能修改
|
||||
// 原生源码,必须同时成为 Gradle 输入和 native baseline 内容。
|
||||
xuqmLocalDependencyDirectories.each { dependencyDirectory ->
|
||||
sourceFiles.from(project.fileTree(dependencyDirectory) {
|
||||
include("android/**")
|
||||
include("ios/**")
|
||||
include("*.podspec")
|
||||
include("package.json")
|
||||
exclude("**/build/**")
|
||||
exclude("**/.cxx/**")
|
||||
exclude("**/.gradle/**")
|
||||
exclude("**/generated/**")
|
||||
})
|
||||
}
|
||||
nodeExecutable.set(System.getenv("NODE_BINARY") ?: "node")
|
||||
appVersion.set(providers.provider {
|
||||
def version = android.defaultConfig.versionName
|
||||
@ -96,15 +163,19 @@ def prepareXuqmBundles = tasks.register(
|
||||
}
|
||||
version.toString()
|
||||
})
|
||||
outputDirectory.set(layout.buildDirectory.dir("generated/xuqm/assets"))
|
||||
outputs.upToDateWhen { false }
|
||||
assetsOutputDirectory.set(layout.buildDirectory.dir("generated/xuqm/assets"))
|
||||
resourcesOutputDirectory.set(layout.buildDirectory.dir("generated/xuqm/res"))
|
||||
}
|
||||
|
||||
androidComponents.onVariants(androidComponents.selector().all()) { variant ->
|
||||
if (variant.buildType == "release" || !xuqmUseMetro.get()) {
|
||||
variant.sources.assets?.addGeneratedSourceDirectory(
|
||||
prepareXuqmBundles,
|
||||
{ task -> task.outputDirectory },
|
||||
{ task -> task.assetsOutputDirectory },
|
||||
)
|
||||
variant.sources.res?.addGeneratedSourceDirectory(
|
||||
prepareXuqmBundles,
|
||||
{ task -> task.resourcesOutputDirectory },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
`
|
||||
|
||||
/**
|
||||
* 带统一导航栏的全屏容器。WebView、下载、权限、Bridge 和错误态全部复用
|
||||
* 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
自动生成的
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
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户