比较提交
6 次代码提交
a2d3a27d80
...
daa96302dd
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
daa96302dd | ||
|
|
3d2134e08a | ||
|
|
10977341d1 | ||
|
|
d009c3b616 | ||
|
|
507ce3d2ee | ||
|
|
d677a9ee52 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,7 @@ target/
|
||||
build/
|
||||
.gradle/
|
||||
.native-gradle/
|
||||
.xuqm-cache/
|
||||
*.iml
|
||||
.idea/
|
||||
*.log
|
||||
|
||||
33
README.md
33
README.md
@ -5,7 +5,7 @@ XuqmGroup 通用 React Native SDK 工作区。`@xuqm/rn-common` 是唯一基础
|
||||
## 包结构
|
||||
|
||||
| 包 | 用途 | 可独立使用 |
|
||||
| --- | --- | --- |
|
||||
| --------------------- | ------------------------------------------------------ | ---------------------- |
|
||||
| `@xuqm/rn-common` | 可选 SDK 上下文、网络、文件、时间、加密、设备与通用 UI | 是,且无需初始化或登录 |
|
||||
| `@xuqm/rn-update` | App 更新、RN 插件注册、下载、校验与运行 | 否,依赖 common |
|
||||
| `@xuqm/rn-xwebview` | WebView、JSBridge、权限与下载 | 否,依赖 common |
|
||||
@ -43,37 +43,34 @@ pnpm add @xuqm/rn-update @xuqm/rn-xwebview @xuqm/rn-bugcollect
|
||||
|
||||
## 扩展包一次初始化、一次登录
|
||||
|
||||
把平台生成的 `config.xuqmconfig` 放在 `src/assets/config/`,然后只包装一次 Metro 配置:
|
||||
把平台生成的 `config.xuqmconfig` 放在 `src/assets/config/`,然后只包装一次 Metro 配置。使用 update 插件化时采用 update 组合器(它已包含 common 自动初始化):
|
||||
|
||||
```js
|
||||
const {getDefaultConfig} = require('@react-native/metro-config');
|
||||
const {withXuqmConfig} = require('@xuqm/rn-common/metro');
|
||||
const { getDefaultConfig } = require('@react-native/metro-config')
|
||||
const { withXuqmModuleConfig } = require('@xuqm/rn-update/metro')
|
||||
|
||||
module.exports = withXuqmConfig(getDefaultConfig(__dirname));
|
||||
module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname))
|
||||
```
|
||||
|
||||
`withXuqmConfig()` 会把初始化模块注册为 Metro pre-main module,避免 `inlineRequires` 延迟扩展包加载。应用登录成功后只更新一次公共会话:
|
||||
不使用 update 时仍调用 `@xuqm/rn-common/metro` 的 `withXuqmConfig()`。两种组合器都会把初始化模块注册为 Metro pre-main module,避免 `inlineRequires` 延迟扩展包加载。应用登录成功后只更新一次公共会话:
|
||||
|
||||
```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: 'http-access-token',
|
||||
userSig: 'optional-im-credential',
|
||||
});
|
||||
})
|
||||
|
||||
await XuqmSDK.logout();
|
||||
await XuqmSDK.logout()
|
||||
```
|
||||
|
||||
扩展包通过 `@xuqm/rn-common/internal` 接收同一次会话变化。宿主业务代码不得导入该内部子路径,也不得逐级传递公共 token 或用户状态。
|
||||
|
||||
无法使用配置文件自动初始化时,才调用一次:
|
||||
|
||||
```ts
|
||||
await XuqmSDK.initialize({appKey: 'app-key'});
|
||||
```
|
||||
平台签发配置与 Metro 自动初始化是扩展 SDK 的唯一初始化入口;不提供手动
|
||||
`initialize`。common-only 宿主不放配置,也不初始化。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
@ -84,11 +81,7 @@ corepack pnpm install
|
||||
corepack pnpm validate
|
||||
```
|
||||
|
||||
`validate` 会执行全部包的 TypeScript 校验和测试。开发版本发布到 Nexus hosted 仓库:
|
||||
|
||||
```bash
|
||||
corepack pnpm --filter @xuqm/rn-common publish --tag next --no-git-checks
|
||||
```
|
||||
`validate` 会执行全部包的 TypeScript 校验和测试。开发 alpha 及正式包都只能由 Jenkins 发布到 Nexus,开发机不执行 npm publish。
|
||||
|
||||
集成 update 的宿主由 `xuqm-rn start` 和 `xuqm-rn run android` 统一代理 Metro 与 Android Debug 启动;宿主只保留简短的 `start/android` package script。
|
||||
|
||||
|
||||
@ -1,9 +1,118 @@
|
||||
# RN SDK 重构实施接管文档
|
||||
|
||||
> 状态更新时间:2026-07-18
|
||||
> 状态更新时间:2026-07-27
|
||||
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
|
||||
> 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。
|
||||
|
||||
## 2026-07-26 / 当前实施状态
|
||||
|
||||
- common 已增加 `idle / initializing / ready / degraded / failed` 初始化状态、
|
||||
`XuqmError` 结构化错误、七天最后成功配置缓存,以及最多三次的指数退避抖动。
|
||||
平台暂不可用时优先进入 degraded;没有缓存时扩展返回 `XUQM_NOT_READY`,不向宿主
|
||||
制造未处理异常。最后成功配置使用 AES-GCM 完整性加密,密钥由签发配置和
|
||||
app/package/environment 上下文派生且不写入缓存;这里是应用沙箱内防明文与防篡改
|
||||
边界,不宣称具备 Android Keystore/iOS Keychain 的硬件保密性。
|
||||
- 扩展 SDK 的唯一正式初始化入口是平台签发 `.xuqmconfig` 与 Metro 自动初始化;
|
||||
`XuqmSDK.initialize(options)` 及公开 `XuqmInitOptions` 已删除,不保留兼容壳。
|
||||
common-only 不初始化。未使用 `withXuqmConfig` 时,公开
|
||||
`@xuqm/rn-common/auto-init-config` 空实现保证 Metro 能完成静态解析;真实 Metro
|
||||
fixture 已覆盖无配置构建。
|
||||
- 唯一隐私入口为 `XuqmSDK.setPrivacyConsent(boolean)`。BugCollect 只有在平台启用、
|
||||
用户已同意且非 Debug 时自动注册采集;Debug 单次验证使用
|
||||
`BugCollect.sendTestErrorAndFlush()`,宿主不再手动 `startCapture()`。
|
||||
- BugCollect 收到 `BUGCOLLECT_DISABLED` 会立即停用并清除队列;上传请求不进入自身
|
||||
HTTP 拦截链。部署中的 `{code:403,status:'1',message:'BUGCOLLECT_DISABLED'}` 同样
|
||||
识别。持久化事件最多 500 条、保留七天,复用 common AES-GCM 沙箱加密,失败不会
|
||||
先删除队列。冷启动首次获得“平台关闭”配置时,即使尚未创建内存 LogQueue,也会
|
||||
直接删除该 appKey 的遗留加密队列;Debug 单次测试上传同样要求隐私同意。
|
||||
- update 从原生 manifest/state 提供当前 Bundle 精确身份;BugCollect 仅在 stack 唯一
|
||||
命中模块时附带 `buildId/moduleId/moduleVersion/bundleHash`。无法唯一归属时标记
|
||||
`symbolicationStatus=unavailable`,不伪造 latest 身份。
|
||||
- Update 使用平台动态 `updateRequiresLogin`。需要登录时未登录正常跳过;登录后自动
|
||||
补检一次并通过 `onAutomaticCheck` 交给宿主决定 UI。账号变化会取消旧下载授权和
|
||||
灰度缓存;后台补检失败不影响宿主登录。服务端权威字段
|
||||
`allowAnonymousUpdateCheck` 缺失时安全默认禁止匿名检查。
|
||||
- Update 服务开关统一解析 `features.update`(兼容平铺 `updateEnabled`),缺失安全
|
||||
默认关闭并进入最后成功配置缓存。所有显式检查/下载/安装入口在网络和原生调用前
|
||||
抛出 `UPDATE_DISABLED`;自动启动路径正常跳过。
|
||||
- 插件候选已实现 Ed25519 不可变 manifest 验签:common 集中维护 canonical JSON、
|
||||
可信 keyId 与 Hermes 运行时验签,Metro 构建期复用同一规则和密钥集合。候选在计划
|
||||
前与安装前双重验签,Android stager 进一步绑定 buildId、bundleFormat 和
|
||||
bundleSha256;响应 ZIP `sha256` 以 `archiveSha256` 纳入同一签名,下载后仍使用
|
||||
同值校验归档。未知 keyId、签名错误和字段篡改测试均已覆盖。
|
||||
common 加密、Ed25519 与 secureCache 复用 common/security 唯一纯 JS
|
||||
Base64/Base64URL codec,不依赖 Hermes 全局 `atob/btoa`;非法字母表、长度、填充和
|
||||
尾随位直接拒绝。canonical
|
||||
JSON 对函数、symbol、非有限数、非普通对象、数组空位/undefined 确定性失败,避免
|
||||
Node、Hermes 与服务端实现各自容错产生不同签名正文。
|
||||
Server 的 `update-service/src/test/resources/rn-release-signature-vector.json` 是
|
||||
唯一规范源。RN 测试只读明确标记 GENERATED/DO NOT EDIT 的仓内生成副本,保证独立
|
||||
Git/Jenkins checkout 可运行;`scripts/sync-server-signature-vector.mjs
|
||||
--check|--write` 负责跨仓字节级同步。相邻 Server 缺失时常规单仓 validate 跳过跨仓
|
||||
比较,发布前必须提供 Server checkout 并执行 `--check`。测试会逐字节比较 canonical
|
||||
UTF-8 正文,并以同一 keyId、原始公钥和 Base64URL 签名完成验签。
|
||||
- release-set 检查已强制携带 appVersion、nativeApiLevel、nativeBaselineId;缺少
|
||||
baseline 在发请求前失败。发布 Token 只读取 Jenkins 注入的 XUQM_API_TOKEN,
|
||||
`release.apiToken` 配置会被构建校验拒绝。
|
||||
- `xuqm.modules.json` 使用 `packageName` 保存 Android applicationId、使用
|
||||
`iosBundleId` 保存 iOS Bundle Identifier;相应字段仅在目标平台
|
||||
build/package/release 时必填。唯一 `resolveHostPackageId(config, platform)` 被
|
||||
构建、在线配置验证、ZIP 期望身份和发布上传共同复用。wire 仍使用
|
||||
`packageName`,但不会再把 Android 身份写入 iOS ZIP。
|
||||
- 插件 ZIP v1 协议限制已与 Server 对齐:归档 50 MiB、单 entry 100 MiB、总解压
|
||||
100 MiB、最多 4096 entries。Android stager 不再保留 5000 上限,4096/4097 和单
|
||||
entry 100 MiB 边界已有原生单测;CLI `zipSync` 不生成目录 entry,Server 与 stager
|
||||
均明确拒绝目录 entry。
|
||||
- Debug 的 `XuqmRuntime.start()` 不做远程整包或插件检查;开发页仍可显式调用公开
|
||||
check/install API 做一次性验证。
|
||||
- 插件构建清单已从 `xuqm.config.json` 更名为 `xuqm.modules.json`;租户平台签发的
|
||||
`.xuqmconfig` 仍是唯一初始化源。Metro 使用 `src/assets/config/` 内唯一文件,
|
||||
保留平台下载文件名;零个保持 common-only,多个构建失败。Android/iOS 生成目录
|
||||
中的构建副本才固定命名为 `config.xuqmconfig`。
|
||||
- Metro 只接受严格 `XUQM-CONFIG-V2`:使用内置 `keyId` 公钥先做 Ed25519 验签,再
|
||||
解密并校验 canonical JSON、Schema、UUID、revision 和有效期。V1、未知 keyId 与
|
||||
签名失败在构建期阻断。当前内置开发公钥为 `xuqm-config-dev-2026-07`,私钥未入仓。
|
||||
- Release 每个模块始终生成独立 Source Map,删除 `sourcesContent` 并清理绝对路径。
|
||||
`--bugcollect=disabled` 只归档不上传;启用时按完整 Bundle 身份上传,失败阻断发布。
|
||||
上传契约固定为 `POST /bugcollect/v1/artifacts/upload` 且
|
||||
`artifactType=RN_SOURCEMAP`;旧 Metro serializer 上传器已删除。发布登记和
|
||||
Source Map 上传的 `buildId/moduleId/moduleVersion/bundleHash` 均从待上传 ZIP 的
|
||||
`rn-manifest.json` 和真实 Bundle 校验后取得,禁止根据环境变量二次推导身份;
|
||||
artifact 上传与插件发布使用同一 Jenkins 发布 Bearer,匿名请求不会发出。
|
||||
- 整包和插件发布前调用 `/api/sdk/build/config/validate`。服务不可达、配置过期、
|
||||
revision 撤销或包名不匹配均阻断新 Release;已安装 App 运行时不查询吊销状态。
|
||||
- 相同 versionName 的连续 Android Release 自动生成递增 versionCode 和独立 buildId;
|
||||
- 2026-07-27 本地全量 `pnpm validate` 通过:六个 workspace TypeScript、common 42、
|
||||
bugcollect 11、xwebview 7、IM 3、update CLI/Metro 28 与 update domain 18 项测试
|
||||
全部通过;update 发布包内容校验为 51 个文件。common npm dry-run 未包含测试目录及
|
||||
生成签名向量。Server 相邻仓库存在时签名向量逐字节检查通过,模拟 Server checkout
|
||||
缺失的单仓 CI 也按契约独立通过。
|
||||
同一工作区通过 `.xuqm-cache/release-build.json` 保证单调递增,跨工作区 CI 通过
|
||||
`XUQM_VERSION_CODE` 和 `XUQM_BUILD_ID` 注入全局唯一流水线身份。
|
||||
- Gradle 内嵌任务把 `buildId` 与 `bugCollectMode` 声明为显式 `@Input` 并传入 embed
|
||||
子进程;测试验证 APK 内嵌 manifest 和模块 ZIP 使用同一 Release identity。
|
||||
`package` 与 `publish` 共用唯一 `--bugcollect` 参数解析器,等号和空格写法一致。
|
||||
- Android 相同模块版本使用 SHA-256 识别新版 APK 内容;真正更高且兼容 native
|
||||
baseline 的远程插件继续保留。新 release 允许两次进程启动握手,失败 releaseId
|
||||
本机隔离,服务端内容未变化前不重复安装。
|
||||
- 原生最终恢复入口只删除 SDK 私有 `files/rn-bundles`,并递增 reset generation;
|
||||
common/app 初始化后仅清除 `xuqm_update_*` 状态并确认该 generation,重试不会误清
|
||||
宿主、BugCollect 或签名 SDK 数据。
|
||||
- 已执行 `pnpm validate`:全部包类型检查、common 42 项、bugcollect 11 项、update
|
||||
CLI 28 项、update 领域 18 项、xwebview 7 项和 IM 3 项测试均通过;update 包内容
|
||||
校验通过,发布包为 51 个文件;Prettier 和 `git diff --check` 通过。
|
||||
独立 `native-test` 的系统 Gradle 仍在任务配置前因本机
|
||||
`libnative-platform.dylib` 无法加载;改用 App4 Gradle 9.3.1 wrapper 后,
|
||||
`:xuqm_rn-update:testDebugUnitTest --tests
|
||||
com.xuqm.update.XuqmPluginPackageStagerTest` 共 25 tasks 构建并测试成功。
|
||||
尚未执行完整宿主 Android 构建、设备运行、开发环境接口和真实 Source Map 上传;
|
||||
这些结果不得从当前门禁推断。
|
||||
- License 离线激活 SDK 已审计:RNSDK 不存在对应包、API、依赖、脚本、文档或空目录;
|
||||
npm 的 `license: UNLICENSED` 元数据和 RN 插件事务 `activation` 不属于离线授权。
|
||||
- 配置中的 `signingKey` 明确是客户端可提取的请求完整性/缓存派生材料,不是 License、
|
||||
用户授权或服务端秘密;敏感 API 仍依赖登录 `accessToken`,构建制品上传依赖独立
|
||||
Jenkins Bearer,并由服务端执行授权。
|
||||
|
||||
## 1. 本轮目标
|
||||
|
||||
1. `rn-common` 可以完全独立使用公共工具,不要求初始化或登录。
|
||||
@ -13,6 +122,28 @@
|
||||
5. update SDK 统一承担插件 manifest、兼容性、原子激活、启动确认、崩溃回滚和内嵌恢复;宿主不得再实现平行版本管理器。
|
||||
6. Android 整包更新必须具备应用内下载、SHA-256 校验、安装权限处理、进度、取消、重试和确定性错误码。
|
||||
7. 宿主通过最少配置完成插件创建、开发运行、打包、随 APK 内嵌、发布和回滚。
|
||||
8. XWebView 收敛为 `XWebViewHost + openWebView` 的 SDK 自管页面栈;宿主不再注册路由或传 navigate 回调。
|
||||
|
||||
## 0. 2026-07-26 / XWebView 1.0 实施状态
|
||||
|
||||
- 已取得用户实施授权,目标为 `@xuqm/rn-xwebview@1.0.0`。
|
||||
- 旧 `openXWebView(navigate, config)`、common 内全局 `_config/_controller` 和公开
|
||||
`XWebViewScreen` 已删除,不保留转发兼容层;迁移规则已写入包 README。
|
||||
- 新公开入口固定为根部一次挂载 `XWebViewHost`、业务直接调用
|
||||
`openWebView(config)`,并返回页面级句柄和 `closed` Promise。
|
||||
- 配置改为 `source/navigationBar/statusBar/behavior/permissions/downloads/bridge`
|
||||
分组强类型结构;URL 与 HTML 互斥。
|
||||
- 页面栈、Host 等待/卸载、页面级动态导航、系统返回、外链、下载、权限和 Bridge
|
||||
异常已收口到 xwebview;common 中错误归属的 WebView 状态与业务 Bridge 已删除。
|
||||
- H5 媒体权限由页面白名单、可选宿主决策和 Android 系统权限三层控制;SDK 不向
|
||||
Manifest 注入摄像头或麦克风权限。
|
||||
- 下载自动复用 URI 首屏鉴权 headers;Bridge 原始 payload 不写日志。
|
||||
- `react-native-svg` 已收敛为 peer;宿主必须直接声明这一原生依赖以参与 autolink,
|
||||
禁止由 xwebview 私自安装第二份。App4 运行验收曾据此捕获并修复
|
||||
`RNSVGSvgViewAndroid` 未注册问题。
|
||||
- xwebview 类型检查和 7 项独立测试已通过。App4 在 Android 36.1
|
||||
`emulator-5554` 通过“移动报销”真实 H5 页面验证:页面打开、SDK 初始化、
|
||||
`userInfo`、右上角菜单和关闭返回均正常,窗口日志无 Java/React/Fabric FATAL。
|
||||
|
||||
## 2. 已冻结的设计约束
|
||||
|
||||
@ -39,10 +170,10 @@
|
||||
- 本地已使用 JDK 21 + Gradle 9.3.1 编译通过;RN Bridge 明确输出 Java 17 字节码,不再隐式回落到 Java 8。
|
||||
- RN 包不再内置第二份 AGP buildscript;宿主负责插件版本,独立夹具使用与 Android SDK 一致的 AGP 9.1.0。
|
||||
- `pnpm validate`:通过。
|
||||
- common:16 个测试通过。
|
||||
- bugcollect:4 个独立测试通过。
|
||||
- xwebview:4 个独立测试通过。
|
||||
- update CLI:9 个测试通过;release-set:6 个测试通过。
|
||||
- common:20 个测试通过。
|
||||
- bugcollect:5 个独立测试通过。
|
||||
- xwebview:7 个独立测试通过。
|
||||
- update CLI/Metro:11 个测试通过;release-set:6 个测试通过。
|
||||
- IM 现有测试:3 个通过;IM 不在本轮开发范围。
|
||||
- 所有 workspace TypeScript typecheck:通过。
|
||||
|
||||
@ -53,15 +184,17 @@
|
||||
- common-only 宿主缺少配置文件时不会触发自动初始化,已有测试覆盖。
|
||||
- 配置文件自动初始化遇到临时网络失败时会清理失败 Promise;扩展下一次等待初始化时使用同一加密配置重试,不要求宿主重新传 appKey/URL,也不会产生未处理 Promise 或重复错误日志。
|
||||
- bugcollect、update、xwebview 通过 `@xuqm/rn-common/internal` 触发一次自动初始化入口。
|
||||
- update CLI 已能从 schema v3 `xuqm.config.json` 构建 startup/common/app/buz,生成携带 SemVer 兼容范围和原生 API 等级的内嵌 manifest。
|
||||
- Android bundle 引擎不在 `xuqm.config.json` 重复声明:CLI 直接读取宿主 `android/gradle.properties` 的 `hermesEnabled`。启用时所有插件统一编译为 Hermes bytecode、组合 source map,并在 manifest/上传表单记录 `bundleFormat`。
|
||||
- update CLI 已能从 schema v3 `xuqm.modules.json` 构建 startup/common/app/buz,生成携带 SemVer 兼容范围和原生 API 等级的内嵌 manifest。
|
||||
- update 的 `withXuqmModuleConfig()` 是多 Bundle 模块编号的唯一实现:日常 Metro 不变,CLI 构建时自动先生成 startup/common 共享模块表,并按配置序号为每个 app/buz 分配独立区间;宿主不再维护多份 Metro 配置或业务名硬编码 offset。
|
||||
- Android bundle 引擎不在 `xuqm.modules.json` 重复声明:CLI 直接读取宿主 `android/gradle.properties` 的 `hermesEnabled`。启用时所有插件统一编译为 Hermes bytecode、组合 source map,并在 manifest/上传表单记录 `bundleFormat`。
|
||||
- common 的唯一 XWebView 契约新增实时导航状态;全屏与内嵌容器统一提供 `canGoBack`、`canGoForward`、当前标题和 URL,并支持宿主配置回调。Bridge 不再重复声明第二份 controller 类型。
|
||||
- RN update 已有 release-set 纯领域规划器;Android 原生模块已改为整组 staging/激活/确认/回滚,并从 APK assets 恢复内嵌基线。
|
||||
|
||||
### 3.3 已确认缺口
|
||||
|
||||
- RN update 已编码 `XuqmAppUpdateModule`,只桥接 XuqmGroup Android `sdk-update:2.0.0-SNAPSHOT`;Android Snapshot、Bridge 编译和 App4 完整 debug 构建已通过,模拟器安装事务仍待验证。
|
||||
- release-set 已统一使用 SHA-256,但签名 manifest 尚待实现。
|
||||
- release-set 已统一使用 SHA-256 和 Ed25519 签名 manifest;服务端仍需按同一
|
||||
canonical 字段契约签发 `keyId/signature` 并完成联调。
|
||||
- 原生 state 已成为 Bundle 版本与事务状态唯一真相;App 更新提示缓存仍可使用 AsyncStorage,但不保存 Bundle 版本。
|
||||
- 客户端已改为 `/api/v1/rn/release-set/check` 单次请求目标入口依赖闭包;租户平台服务端接口尚待同步实现。
|
||||
- `nativeBaselineId` 已自动计算并进入内嵌 manifest/插件上传:覆盖 Android 原生源码、Gradle 配置、RN 与含原生代码的 npm 依赖,普通业务 JS 不影响指纹;Jenkins/服务端登记校验尚待实现。
|
||||
@ -73,16 +206,16 @@
|
||||
## 4. 实施进度
|
||||
|
||||
| 工作项 | 状态 | 说明 |
|
||||
| ----------------------------- | -------- | ------------------------------------------------------ |
|
||||
| ----------------------------- | ------ | ----------------------------------------------------- |
|
||||
| 基线审计与门禁 | 完成 | 2026-07-17 本地 validate 全通过 |
|
||||
| 实时接管文档 | 进行中 | 本文件为唯一实施状态入口 |
|
||||
| common 公共上下文收敛 | 完成 | 公共基础与共享生命周期 16 测试 |
|
||||
| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本与策略已统一,4 测试 |
|
||||
| xwebview 文件/权限能力与测试 | 完成 | 文件下沉 common,H5 摄像头/麦克风统一授权,4 测试通过 |
|
||||
| common 公共上下文收敛 | 完成 | 公共基础、共享生命周期与安全 API 诊断 19 测试 |
|
||||
| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本、策略与 HTTP 脱敏已统一,5 测试 |
|
||||
| xwebview 1.0 Host/权限/下载 | 完成 | 页面栈、分组配置、7 项测试及 App4 Android 运行通过 |
|
||||
| update release set 与原生事务 | 进行中 | Android Snapshot、Bridge、App4 构建通过;待事务 E2E |
|
||||
| package 内容校验 | 待实施 | 四包完成后执行 |
|
||||
| package 内容校验 | 完成 | update 包 42 个发布文件已校验 |
|
||||
| Jenkins alpha 发布 | 完成 | `#59/#60` 已发布 common、xwebview、update |
|
||||
| App4 接入 | 完成构建 | 精确 alpha 已接入,六 Hermes 插件与 debug APK 构建通过 |
|
||||
| App4 接入 | 完成 | V5 Host、8 模块、Debug APK 与真实 H5 Android 链路通过 |
|
||||
|
||||
## 5. 下一步操作
|
||||
|
||||
@ -90,7 +223,7 @@
|
||||
2. 在 Android 宿主补充 release-set 安装、冷启动确认和崩溃回滚仪器测试。
|
||||
3. 收敛 Jenkins 四包依赖发布顺序和 package 内容检查。
|
||||
4. 后续租户平台实现 `/api/v1/rn/release-set/check`,服务端只返回目标入口与 common 的兼容闭包。
|
||||
5. App4 保持使用 Jenkins 精确版本,禁止链接 SDK 源码、手改 `node_modules` 或本地发布正式制品。
|
||||
5. App4 当前使用相邻源码联调本轮未发布变更;验证完成后必须由 Jenkins 发布新 alpha,再恢复 Nexus 精确版本。禁止手改 `node_modules` 或本地发布正式制品。
|
||||
|
||||
## 6. 常用验证命令
|
||||
|
||||
@ -113,6 +246,31 @@ pnpm --dir packages/update test
|
||||
- 新增测试使用带手机号、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 及原始网络异常对象全部丢弃,测试覆盖敏感查询参数和鉴权头。
|
||||
|
||||
### 2026-07-18 / 多 Bundle Metro 能力下沉
|
||||
|
||||
- 新增公开入口 `@xuqm/rn-update/metro`,`withXuqmModuleConfig()` 内部复用 common 的 `withXuqmConfig()`,宿主只保留一份 Metro 配置。
|
||||
- `xuqm-rn build/embed/publish` 为每个模块注入 id、type、配置序号和共享缓存位置;模块区间不再依赖 `szyx/miniapp` 等业务目录名称。
|
||||
- 独立构建 app/buz 时 CLI 自动先构建 startup/common 依赖以重建共享模块表,但仍只发布用户选择的模块,适用于全新 Jenkins 工作区。
|
||||
- 测试覆盖 0 号模块存在性、startup/common 去重、app/buz 唯一区间,以及单独构建 buz 的依赖顺序;update CLI/Metro 11 项、release-set 6 项和 42 文件包内容校验通过。
|
||||
|
||||
### 2026-07-18 / Jenkins #58—#60 与 App4 精确版本接入
|
||||
|
||||
- `sdk-rn-publish #58` 被 Windows Hermes 测试正确阻断:测试曾把文本写成 `hermesc.exe`,Windows `spawnSync` 无法执行;该构建未发布任何包。
|
||||
@ -190,7 +348,7 @@ pnpm --dir packages/update test
|
||||
- 更新检查改为按入口依赖闭包:启动时整包优先,其后 app+common;buz 进入前只检查当前 buz+common。
|
||||
- SDK 不决定宿主 UI:整包与插件均提供独立检查/安装 API;App4 进入 buz 使用 `checkAndInstallPlugin`,需要弹窗的宿主使用 `checkPluginRelease` 后再调用 `installPluginRelease`。
|
||||
- release-set 记录检查时的全部本地基线版本;弹窗停留期间状态变化会触发 `StaleReleaseSetError`,不会安装过期计划。
|
||||
- `xuqm.config.json` 统一升级到 schema v3:`commonVersionRange` + `minNativeApiLevel`,删除 `minCommonVersion`/`minNativeVersion` 双重语义。
|
||||
- `xuqm.modules.json` 统一使用 schema v3:`commonVersionRange` + `minNativeApiLevel`,删除 `minCommonVersion`/`minNativeVersion` 双重语义。
|
||||
- 验证:update typecheck、CLI 6 测试、release-set 6 测试通过;Android 原生代码尚待宿主工程编译验证。
|
||||
|
||||
### 2026-07-17 / Android 整包安装桥接
|
||||
@ -205,3 +363,87 @@ 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、权限和错误处理,
|
||||
并最终删除该公开入口。全屏页面由 `XWebViewHost` 组合统一导航栏、系统返回与
|
||||
`XWebViewView`;全屏和内嵌场景共享同一个浏览器内核。
|
||||
- 当时的 xwebview 类型检查与 4 项下载/权限测试、RNSDK 全量门禁和 App4 457 tasks
|
||||
构建均通过;该历史结果不能替代 1.0.0 当前实现的最终验证。当前 1.0.0 已扩展为
|
||||
7 项独立测试,Android 运行验证仍待执行。
|
||||
|
||||
### 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,18 +13,30 @@ await XuqmSDK.login({
|
||||
userSig: 'optional-im-credential',
|
||||
name: '姓名',
|
||||
phone: '手机号',
|
||||
});
|
||||
})
|
||||
|
||||
await XuqmSDK.logout();
|
||||
await XuqmSDK.logout()
|
||||
```
|
||||
|
||||
- `initialize(options)`:仅在不能使用配置文件自动初始化时调用。
|
||||
- `awaitInitialization()`:等待唯一初始化完成;未配置且未手动初始化会抛出明确错误。
|
||||
- `awaitInitialization()`:等待唯一自动初始化完成;缺少平台签发配置或 Metro 集成时
|
||||
抛出明确错误。
|
||||
- `login(options)`:唯一登录入口;同一会话重复调用幂等,不同会话串行切换。
|
||||
- `setPrivacyConsent(consented)`:唯一隐私同意同步入口;扩展 SDK 不自行弹窗。
|
||||
- `getInitializationState()`:返回 `idle / initializing / ready / degraded / failed` 及
|
||||
可选结构化错误。
|
||||
- `logout()`:唯一登出入口。
|
||||
- `getUserId()` / `getUserInfo()`:只读获取当前会话。
|
||||
|
||||
不提供 `setUserInfo`、`setUserId` 或公开的配置文件初始化入口。
|
||||
不提供 `initialize`、`setUserInfo`、`setUserId` 或公开的配置文件初始化入口。
|
||||
|
||||
### 地址职责
|
||||
|
||||
- `platformUrl`:Xuqm 租户平台地址。远程配置、整包更新、插件 release set 检查等平台接口只使用该地址;自动配置未提供时使用 SDK 内置的公有平台地址。
|
||||
- `apiUrl`:租户平台返回的业务扩展服务地址,只供明确声明使用该业务服务的扩展能力读取,不能替代 `platformUrl`。
|
||||
- common 的 `apiRequest()` 默认基础地址在 SDK 初始化后固定为 `platformUrl`;访问其它业务服务时必须传绝对 URL 或在 common-only 模式显式 `configureHttp()`,禁止用远程 `apiUrl` 静默改写平台请求。
|
||||
- 配置中的 `signingKey` 是可被客户端提取的请求完整性材料,不代表用户身份或接口
|
||||
授权;敏感接口必须继续校验登录 `accessToken`,构建制品上传使用 Jenkins 发布
|
||||
Bearer。
|
||||
|
||||
## common 通用能力
|
||||
|
||||
@ -33,16 +45,55 @@ await XuqmSDK.logout();
|
||||
- `apiRequest`、`configureHttp`、`useRequest`、`useApi`、`usePageApi`
|
||||
- `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`
|
||||
- `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`
|
||||
- `decryptXuqmFile`、`hmacSha256Base64Url`
|
||||
- `hmacSha256Base64Url`、`sha256Hex`
|
||||
- `getDeviceId`、`getDeviceInfo`、`detectPushVendor`
|
||||
- `showToast`、`showAlert`、`showConfirm`、`ScaledImage`
|
||||
|
||||
## 扩展包
|
||||
|
||||
- `@xuqm/rn-update`:`UpdateSDK`、`XuqmRuntime`、`definePlugin` 和 `xuqm-rn` CLI。
|
||||
- `@xuqm/rn-xwebview`:`XWebViewView`、`XWebViewScreen`、JSBridge 与统一下载。
|
||||
原生最终恢复页使用 `UpdateSDK.resetToEmbedded()`;它只清理 Update SDK 私有
|
||||
Bundle/发布状态,不影响宿主账号、业务数据、BugCollect 或签名 SDK。
|
||||
- `@xuqm/rn-xwebview`:根部一次挂载 `XWebViewHost`,业务使用
|
||||
`openWebView(config)` 打开全屏页面;`XWebViewView` 只用于内嵌浏览器。业务
|
||||
JSBridge 通过 `config.bridge` 注入,不属于 XWebView 自身协议。
|
||||
- `@xuqm/rn-bugcollect`:`BugCollect` 错误、事件、漏斗和队列上报。
|
||||
Debug 单次联调使用 `BugCollect.sendTestErrorAndFlush()`,App 不调用
|
||||
`startCapture()`。
|
||||
- `@xuqm/rn-im`:在 common 会话含 `userSig` 且服务启用时连接。
|
||||
- `@xuqm/rn-push`:在 common 会话变化时同步原生推送绑定。
|
||||
|
||||
所有扩展包都不提供第二套初始化或登录 API。
|
||||
|
||||
## update 插件检查边界
|
||||
|
||||
Update 服务由平台远程配置 `features.update` 控制,缺失时默认关闭。显式调用
|
||||
`checkAppUpdate`、`downloadApk`、`downloadAndInstallApp`、`checkPluginRelease` 或
|
||||
`installPluginRelease` 时,关闭状态统一抛出 `UpdateDisabledError`,错误码为
|
||||
`UPDATE_DISABLED`,且不会先访问网络或原生安装模块;自动启动检查会正常跳过。
|
||||
|
||||
```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` 和进度回调控制。
|
||||
- release-set 请求固定包含 `appVersion`、`nativeApiLevel`、`nativeBaselineId`;缺少
|
||||
native baseline 时不发请求。服务端返回的插件候选必须具有平台 Ed25519
|
||||
`keyId/signature`,客户端在计划前和安装前验证不可变 manifest。
|
||||
候选响应的 ZIP `sha256` 以 `archiveSha256` 纳入签名,下载后继续以同一值校验归档。
|
||||
- common HTTP 的统一默认请求上限为 30 秒;宿主需要更短的交互等待时显式传上述参数,不得在页面复制 `Promise.race` 或第二套超时器。
|
||||
|
||||
@ -1,211 +1,52 @@
|
||||
# XWebView JSBridge 标准接口
|
||||
# XWebView Bridge 边界
|
||||
|
||||
> 最后更新:2026-06-15
|
||||
> 最后更新:2026-07-26
|
||||
|
||||
---
|
||||
XWebView 不是业务 JSBridge 协议。它只定义一个通用适配接口:
|
||||
|
||||
## 概述
|
||||
|
||||
XWebView 内置 JSBridge,支持 H5 页面与 React Native 宿主之间的双向通信。
|
||||
|
||||
**通信机制**:
|
||||
- H5 → RN:`window.ReactNativeWebView.postMessage(JSON.stringify(data))`
|
||||
- RN → H5:`webViewRef.injectJavaScript(jsString)`
|
||||
|
||||
---
|
||||
|
||||
## 一、H5 → RN 消息协议
|
||||
|
||||
### 1.1 内置消息(__xwv 前缀)
|
||||
|
||||
XWebView 自动拦截以下浏览器行为,通过 `postMessage` 发送给 RN:
|
||||
|
||||
| 消息类型 | 触发场景 | 数据格式 |
|
||||
|---------|---------|---------|
|
||||
| `alert` | `window.alert(msg)` | `{__xwv: 'alert', msg: string}` |
|
||||
| `confirm` | `window.confirm(msg)` | `{__xwv: 'confirm', msg: string}` |
|
||||
| `prompt` | `window.prompt(msg, def)` | `{__xwv: 'prompt', msg: string, def: string}` |
|
||||
| `download` | 点击下载链接 | `{__xwv: 'download', url: string, filename: string}` |
|
||||
| `blobdownload` | blob URL 下载 | `{__xwv: 'blobdownload', url: string, filename: string, data: base64}` |
|
||||
| `bloberror` | blob 读取失败 | `{__xwv: 'bloberror', msg: string}` |
|
||||
| `log` | 调试日志 | `{__xwv: 'log', msg: string}` |
|
||||
|
||||
### 1.2 自定义消息
|
||||
|
||||
H5 发送非 `__xwv` 前缀的消息时,XWebView 将其转发给 `onMessage` 回调:
|
||||
|
||||
```javascript
|
||||
// H5 端
|
||||
window.ReactNativeWebView.postMessage(JSON.stringify({
|
||||
type: 'customEvent',
|
||||
payload: { key: 'value' }
|
||||
}));
|
||||
```
|
||||
|
||||
```typescript
|
||||
// RN 端
|
||||
<XWebViewView
|
||||
onMessage={(event) => {
|
||||
const data = JSON.parse(event.nativeEvent.data);
|
||||
if (data.type === 'customEvent') {
|
||||
// 处理自定义消息
|
||||
```ts
|
||||
type XWebViewBridge = {
|
||||
injectedJavaScriptBeforeContentLoaded?: string
|
||||
onMessage(raw: string, context: XWebViewBridgePageContext): boolean | Promise<boolean>
|
||||
onPageClosed?(pageId: string): void
|
||||
}
|
||||
}}
|
||||
/>
|
||||
```
|
||||
|
||||
---
|
||||
## 页面上下文
|
||||
|
||||
## 二、RN → H5 通信
|
||||
业务协议实现可以:
|
||||
|
||||
### 2.1 注入 JavaScript
|
||||
- 关闭当前页;
|
||||
- 向当前页执行 JavaScript;
|
||||
- 判断当前页是否为页面栈顶;
|
||||
- 取得真实状态栏高度;
|
||||
- 修改当前页导航栏标题/背景;
|
||||
- 修改当前页导航栏与状态栏显隐。
|
||||
|
||||
```typescript
|
||||
// 通过 controller API
|
||||
controller.postMessageToWeb('window.dispatchEvent(new CustomEvent("rnMessage", {detail: {type: "update", data: "hello"}}))');
|
||||
协议实现不能:
|
||||
|
||||
// 通过 ref
|
||||
webViewRef.injectJavaScript('window.handleRNMessage("hello")');
|
||||
```
|
||||
- 访问或修改其他 WebView 页面;
|
||||
- 从 XWebView 读取用户、token、平台、域名或厂商;
|
||||
- 直接控制宿主导航器;
|
||||
- 输出原始 Bridge payload;
|
||||
- 在 common 中注册全局业务 handler。
|
||||
|
||||
### 2.2 Controller API
|
||||
## 消息所有权
|
||||
|
||||
通过 `setXWebViewController` 获取的 controller 对象提供以下方法:
|
||||
XWebView 内部只消费 `__xwv` 命名空间:
|
||||
|
||||
| 方法 | 说明 |
|
||||
|------|------|
|
||||
| `refresh()` | 刷新当前页面 |
|
||||
| `close()` | 关闭 WebView |
|
||||
| `goBack()` | 返回上一页 |
|
||||
| `goForward()` | 前进 |
|
||||
| `copyUrl()` | 复制当前 URL 到剪贴板 |
|
||||
| `postMessageToWeb(jsString)` | 向 H5 注入并执行 JS |
|
||||
| `getTitle()` | 获取当前页面标题 |
|
||||
- `download` / `blobdownload` / `bloberror`
|
||||
- `permission`
|
||||
|
||||
---
|
||||
其他消息先交给 `config.bridge`。Bridge 返回 `false` 时才继续交给
|
||||
`behavior.onMessage`。Bridge 抛错由 `behavior.onBridgeError` 接收,不产生未处理
|
||||
Promise。
|
||||
|
||||
## 三、下载处理
|
||||
## 业务协议接入
|
||||
|
||||
### 3.1 自动下载拦截
|
||||
业务协议必须在自己的 SDK/插件中维护唯一方法表、请求解析、响应结构、错误码和兼容
|
||||
测试,然后以 `XWebViewBridge` 适配到容器。XWebView 不提供 `getSession`、`navigate`、
|
||||
`showToast`、签名、支付或任何假定宿主业务的内置方法。
|
||||
|
||||
XWebView 自动拦截以下文件扩展名的链接点击:
|
||||
|
||||
```
|
||||
.pdf .zip .rar .tar .gz .apk .ipa .doc .docx .xls .xlsx .ppt .pptx .mp4 .mp3 .mov .exe .dmg
|
||||
```
|
||||
|
||||
### 3.2 下载回调
|
||||
|
||||
```typescript
|
||||
<XWebViewView
|
||||
autoDownload={true}
|
||||
onDownloadStart={(request) => {
|
||||
// request: { url, suggestedFilename, mimeType?, fileSize? }
|
||||
console.log('Download started:', request.suggestedFilename);
|
||||
}}
|
||||
onDownloadProgress={(progress) => {
|
||||
// progress: { url, filename, received, total, percentage }
|
||||
console.log(`Download: ${progress.percentage}%`);
|
||||
}}
|
||||
onDownloadComplete={(result) => {
|
||||
// result: { url, filename, filePath, fileSize }
|
||||
console.log('Download complete:', result.filePath);
|
||||
}}
|
||||
onDownloadError={(url, error) => {
|
||||
console.error('Download failed:', url, error);
|
||||
}}
|
||||
onDownloadDecide={(request) => {
|
||||
// 返回下载决策
|
||||
return { allowed: true, filename: request.suggestedFilename };
|
||||
}}
|
||||
downloadConflict="rename" // 'rename' | 'overwrite'
|
||||
/>
|
||||
```
|
||||
|
||||
### 3.3 Blob URL 下载
|
||||
|
||||
XWebView 自动处理 blob URL 下载:
|
||||
1. 拦截 `<a href="blob:...">` 点击
|
||||
2. 通过 `FileReader` 读取 blob 数据为 base64
|
||||
3. 通过 `saveBase64File` 保存到本地
|
||||
|
||||
---
|
||||
|
||||
## 四、内置 Dialog 覆盖
|
||||
|
||||
XWebView 注入 `DIALOG_OVERRIDE_JS`,覆盖浏览器原生对话框:
|
||||
|
||||
| 原生方法 | 覆盖行为 |
|
||||
|---------|---------|
|
||||
| `window.alert(msg)` | 发送 `{__xwv: 'alert', msg}` 消息 |
|
||||
| `window.confirm(msg)` | 发送 `{__xwv: 'confirm', msg}` 消息,始终返回 `true` |
|
||||
| `window.prompt(msg, def)` | 发送 `{__xwv: 'prompt', msg, def}` 消息,返回默认值 |
|
||||
| `window.open(url)` | 发送日志消息,然后 `window.location.href = url` |
|
||||
|
||||
RN 端收到这些消息后,可以展示原生 Alert/Confirm/Prompt 对话框。
|
||||
|
||||
---
|
||||
|
||||
## 五、标准 Bridge 接口(宿主实现)
|
||||
|
||||
以下接口由宿主应用通过 `bridgeHandlers` 实现,H5 通过 `xuqm.call(method, params)` 调用:
|
||||
|
||||
### 5.1 会话管理
|
||||
|
||||
| 接口 | 参数 | 返回值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `getSession` | — | `{token, userId}` | 获取当前登录会话 |
|
||||
| `getUserInfo` | — | `{userId, name, phone, avatar}` | 获取用户信息 |
|
||||
|
||||
### 5.2 导航
|
||||
|
||||
| 接口 | 参数 | 返回值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `navigate` | `{screen, params?}` | — | 跳转宿主页面 |
|
||||
| `close` | — | — | 关闭当前 WebView |
|
||||
| `goBack` | — | — | 返回上一页 |
|
||||
|
||||
### 5.2 UI 交互
|
||||
|
||||
| 接口 | 参数 | 返回值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `showToast` | `{message}` | — | 显示 Toast |
|
||||
| `showAlert` | `{title?, message}` | — | 显示 Alert 对话框 |
|
||||
| `showConfirm` | `{title?, message, confirmText?, cancelText?}` | `{confirmed: boolean}` | 显示确认对话框 |
|
||||
|
||||
### 5.3 文件操作
|
||||
|
||||
| 接口 | 参数 | 返回值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `uploadFile` | `{accept?}` | `{url, name, size}` | 选择并上传文件 |
|
||||
| `openCamera` | — | `{url, base64}` | 打开相机拍照 |
|
||||
| `scanQR` | — | `{result}` | 扫描二维码 |
|
||||
|
||||
### 5.4 设备信息
|
||||
|
||||
| 接口 | 参数 | 返回值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `getDeviceInfo` | — | `{platform, version, model}` | 获取设备信息 |
|
||||
| `getLocation` | — | `{latitude, longitude}` | 获取当前位置 |
|
||||
|
||||
---
|
||||
|
||||
## 六、H5 端调用示例
|
||||
|
||||
```javascript
|
||||
// 获取会话
|
||||
xuqm.call('getSession').then(session => {
|
||||
console.log('Token:', session.token);
|
||||
});
|
||||
|
||||
// 跳转宿主页面
|
||||
xuqm.call('navigate', { screen: 'Settings' });
|
||||
|
||||
// 显示 Toast
|
||||
xuqm.call('showToast', { message: '操作成功' });
|
||||
|
||||
// 上传文件
|
||||
xuqm.call('uploadFile', { accept: 'image/*' }).then(file => {
|
||||
console.log('Uploaded:', file.url);
|
||||
});
|
||||
```
|
||||
医网信 H5 场景使用 `@szyx/sdk-h5-app/host`;其 V5 线协议不属于
|
||||
`@xuqm/rn-xwebview` 的公开契约。
|
||||
|
||||
@ -9,7 +9,17 @@ pnpm exec xuqm-rn init
|
||||
pnpm xuqm:doctor
|
||||
```
|
||||
|
||||
`init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件配置文件为根目录 `xuqm.config.json`。
|
||||
`init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件构建清单为根目录 `xuqm.modules.json`。它与租户平台签发、宿主不可编辑的 `config.xuqmconfig` 初始化文件职责完全分离。
|
||||
|
||||
构建清单分别使用 `packageName` 保存 Android applicationId、使用 `iosBundleId`
|
||||
保存 iOS Bundle Identifier。目标平台字段只在该平台 build/package/release 时必填。
|
||||
ZIP wire 字段统一名为 `packageName`,但其值必须由 CLI 按平台从上述两个配置字段解析;
|
||||
发布时再与同一平台身份精确比较。
|
||||
|
||||
插件 ZIP v1 只有一套协议上限:归档不超过 50 MiB、单个 entry 解压后不超过
|
||||
100 MiB、全部 entry 解压后合计不超过 100 MiB、entry 总数不超过 4096。CLI
|
||||
`zipSync` 只生成文件 entry,因此显式目录 entry 不属于 v1 协议,服务端和 Android
|
||||
stager 都必须拒绝;禁止在任一端另设 5000 等平行上限。
|
||||
|
||||
开发启动也使用同一 CLI,不由宿主复制 Node 包装脚本:
|
||||
|
||||
@ -30,7 +40,7 @@ pnpm exec xuqm-rn plugin create prescription
|
||||
|
||||
- 以小写字母开头
|
||||
- 只包含小写字母、数字和连字符
|
||||
- 在 `xuqm.config.json` 中唯一
|
||||
- 在 `xuqm.modules.json` 中唯一
|
||||
|
||||
CLI 会生成:
|
||||
|
||||
@ -40,7 +50,7 @@ src/plugins/prescription/
|
||||
PrescriptionScreen.tsx
|
||||
```
|
||||
|
||||
并向 `xuqm.config.json.modules` 增加唯一模块声明。不会修改 Babel alias、TypeScript paths、Metro offset 或增加模块专属 package script。
|
||||
并向 `xuqm.modules.json.modules` 增加唯一模块声明。不会修改 Babel alias、TypeScript paths、Metro offset 或增加模块专属 package script。
|
||||
|
||||
## 插件入口
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
`@xuqm/rn-common` 是唯一可以独立使用的基础包,负责:
|
||||
|
||||
- 为官方扩展提供从 `src/assets/config/config.xuqmconfig` 自动初始化的共享上下文;common 单独使用时不启动初始化;
|
||||
- 为官方扩展提供从 `src/assets/config/` 内唯一 `.xuqmconfig` 自动初始化的共享上下文;common 单独使用时不启动初始化;
|
||||
- 维护唯一的 SDK 配置和用户会话;
|
||||
- 网络、访问令牌、签名、文件、时间、加密、设备信息和通用 UI;
|
||||
- 将一次 `XuqmSDK.login()` / `logout()` 传播给官方扩展包。
|
||||
|
||||
@ -8,7 +8,7 @@ Xuqm RN 扩展 SDK 只支持一种自动初始化方案:宿主保存一份平
|
||||
|
||||
- 不放 `.xuqmconfig`
|
||||
- 不使用 `withXuqmConfig()`
|
||||
- 不调用 `XuqmSDK.initialize()`
|
||||
- 不存在也不需要调用 `XuqmSDK.initialize()`
|
||||
- 不调用 `XuqmSDK.login()`
|
||||
|
||||
common-only 模式保持零初始化、零登录。
|
||||
@ -18,35 +18,35 @@ common-only 模式保持零初始化、零登录。
|
||||
安装 update、bugcollect、xwebview 等扩展后,将平台签发的原始文件放到:
|
||||
|
||||
```text
|
||||
src/assets/config/config.xuqmconfig
|
||||
src/assets/config/<平台下载的文件名>.xuqmconfig
|
||||
```
|
||||
|
||||
文件名也可以是其他 `*.xuqmconfig`。扫描优先级为:
|
||||
|
||||
1. `config.xuqmconfig`
|
||||
2. `config.xuqm`
|
||||
3. 目录中的第一个 `*.xuqmconfig`
|
||||
文件名由租户平台决定,不要求宿主改名。该目录没有 `.xuqmconfig` 时保持
|
||||
common-only;恰好一个时自动使用;存在多个时构建明确失败,禁止按名称或排序猜测
|
||||
权威配置。不会扫描其它目录。
|
||||
|
||||
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)
|
||||
```
|
||||
|
||||
## 自动初始化时序
|
||||
|
||||
```text
|
||||
Metro 读取唯一 .xuqmconfig
|
||||
→ 验证平台 Ed25519 签名
|
||||
→ 校验 V2 Schema、有效期和 canonical JSON
|
||||
→ AES-GCM 解密
|
||||
→ 生成构建期传输模块
|
||||
→ 注册 @xuqm/rn-common/internal 为 pre-main module
|
||||
→ 解密配置
|
||||
→ 使用 appKey 请求远程 SDK 配置
|
||||
→ 初始化唯一 HTTP/扩展上下文
|
||||
→ 执行业务入口
|
||||
@ -56,50 +56,69 @@ pre-main 机制保证初始化不受 Metro `inlineRequires` 和业务模块加
|
||||
|
||||
## 原生配置同步
|
||||
|
||||
`withXuqmConfig()` 会把同一份源文件幂等同步到:
|
||||
Android Release 构建任务会在 V2 验签和 Bundle 构建成功后,把同一份源文件复制到:
|
||||
|
||||
```text
|
||||
android/app/src/main/assets/config/config.xuqmconfig
|
||||
ios/<App>/config/config.xuqmconfig
|
||||
android/app/build/generated/xuqm/assets/config/config.xuqmconfig
|
||||
```
|
||||
|
||||
这些位置是构建目标,不是第二配置源。宿主只维护 `src/assets/config` 下的原文件。
|
||||
该位置是被 Git 排除的固定构建目标,不是第二配置源。宿主只维护
|
||||
`src/assets/config/` 中平台下载的唯一 `.xuqmconfig`;不得把副本写进 Android/iOS
|
||||
源码目录。
|
||||
|
||||
## 加密文件
|
||||
|
||||
文件格式:
|
||||
|
||||
```text
|
||||
XUQM-CONFIG-V1.{salt}.{iv}.{ciphertextAndTag}
|
||||
XUQM-CONFIG-V2.{keyId}.{salt}.{iv}.{ciphertextAndTag}.{signature}
|
||||
```
|
||||
|
||||
- PBKDF2-HMAC-SHA256
|
||||
- AES-256-GCM
|
||||
- Ed25519 验签覆盖前五段的精确 ASCII,必须先验签再解密
|
||||
- `keyId` 只能命中 SDK 内置的 X.509 Ed25519 公钥集合
|
||||
- PBKDF2-HMAC-SHA256 + AES-256-GCM 仅用于混淆正文
|
||||
- 12 字节 IV
|
||||
- 16 字节认证标签
|
||||
|
||||
解密后的必要字段为 `appKey`,可选字段包括 `serverUrl`、`baseUrl` 和 `signingKey`。明文结构由平台负责签发,业务仓库不得自行构造。
|
||||
明文必须是键按字典序排列、无空白、忽略 null 的 canonical JSON。必要字段为
|
||||
`schemaVersion=2`、`configId`、正整数 `revision`、`issuedAt`、`appKey`、`appName`、
|
||||
`serverUrl` 和 `signingKey`;`expiresAt` 省略表示长期有效。平台重新生成配置后,
|
||||
旧 `configId/revision` 只会阻止后续 Release 构建,不会追溯影响已经安装的 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`。
|
||||
@ -107,7 +126,9 @@ await XuqmSDK.logout();
|
||||
## 失败规则
|
||||
|
||||
- 扩展项目缺少配置:`awaitInitialization()` 明确报错。
|
||||
- 解密失败或远程配置失败:初始化 Promise 拒绝并保留原始错误。
|
||||
- 签名、V2 Schema、canonical JSON 或有效期校验失败:构建立即失败。
|
||||
- 远程配置失败且存在七天内的最后成功配置:进入 `degraded` 并继续启动。
|
||||
- 首次启动没有缓存:扩展能力返回 `XUQM_NOT_READY`,宿主核心业务继续运行。
|
||||
- 不自动切换到硬编码配置,不静默创建默认租户,不提供多级兼容回退。
|
||||
- common-only 项目没有配置时不会执行初始化,也不会报错。
|
||||
|
||||
@ -117,3 +138,12 @@ await XuqmSDK.logout();
|
||||
- 不提交明文 appKey、signingKey 或租户密钥。
|
||||
- 不在多个目录手工维护配置副本。
|
||||
- `.xuqmconfig` 变更必须通过平台重新签发。
|
||||
- 最后成功配置不会明文写入 AsyncStorage。SDK 使用签发配置中的 `signingKey`、
|
||||
appKey、包名和平台地址派生独立 AES-GCM 缓存密钥,密钥材料不落缓存;篡改、
|
||||
跨包或跨环境读取都会失败。该边界属于应用沙箱内加密,不宣称具备 Android
|
||||
Keystore/iOS Keychain 的硬件密钥保密性。
|
||||
- `signingKey` 是随客户端配置交付、可被终端提取的请求签名材料,只用于请求完整性
|
||||
标记和本地缓存派生,不是 License、授权凭据或服务端秘密,服务端不得仅凭该签名
|
||||
授予敏感权限。
|
||||
- 用户数据、灰度发布、上传构建制品等敏感 API 仍必须使用登录得到的用户
|
||||
`accessToken` 或 Jenkins 发布流水线的独立 Bearer,并由服务端执行真实授权。
|
||||
|
||||
11
package.json
11
package.json
@ -18,12 +18,13 @@
|
||||
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
||||
},
|
||||
"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",
|
||||
"format": "prettier --write AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json scripts packages/common packages/bugcollect packages/update packages/xwebview",
|
||||
"format:check": "prettier --check AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json scripts packages/common packages/bugcollect packages/update packages/xwebview",
|
||||
"signature-vector:check": "node scripts/sync-server-signature-vector.mjs --check",
|
||||
"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 signature-vector:check && corepack pnpm format:check && corepack pnpm typecheck:all && corepack pnpm test"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@react-native-async-storage/async-storage": ">=1.21.0",
|
||||
|
||||
@ -24,8 +24,18 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
初始化只由 common 自动完成,登录只由 `XuqmSDK.login()` 完成。配置中启用 bugcollect 后,SDK 在共享初始化完成时自动执行一次 `startCapture()`;手工调用仍然幂等,但正常集成不需要调用。`BugCollect.flush()` 可在 App 转入后台前主动刷新队列。
|
||||
初始化只由 common 自动完成,登录只由 `XuqmSDK.login()` 完成。宿主通过
|
||||
`XuqmSDK.setPrivacyConsent(true)` 同步已取得的隐私同意;只有平台启用、用户同意且
|
||||
非 Debug 三个条件同时满足时,SDK 才自动注册采集。不存在宿主手工启动入口。
|
||||
`BugCollect.flush()` 可在 App 转入后台前主动刷新队列;Debug 开发页使用
|
||||
`BugCollect.sendTestErrorAndFlush()` 做一次性真实上报验证。
|
||||
|
||||
错误指纹使用 common 的统一 SHA-256 实现,事件里的 SDK 版本直接读取当前发布包版本,不再维护第二份硬编码版本号。
|
||||
|
||||
SourceMap 上传使用独立打包后处理流程;不要再叠加基于 Metro `customSerializer` 的旧插件方案,以免破坏新版 Metro 的 exports 与序列化流程。
|
||||
|
||||
fatal/error 持久队列复用 common 的 AES-GCM 沙箱加密能力;没有签发密钥时不降级写入
|
||||
包含堆栈和设备信息的明文。Issue 只有在错误堆栈能唯一匹配当前原生 Bundle
|
||||
state/manifest 时才携带 `buildId/moduleId/moduleVersion/bundleHash` 并标记
|
||||
`symbolicationStatus=exact`;无法归属时明确为 `unavailable`,服务端不得猜测 latest
|
||||
Source Map。
|
||||
|
||||
@ -1,220 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const https = require('https')
|
||||
const http = require('http')
|
||||
|
||||
/**
|
||||
* 读取配置文件获取 bugCollectApiUrl
|
||||
*/
|
||||
function readConfig(projectRoot) {
|
||||
// 1. 尝试读取 xuqm.config.js
|
||||
const configJsPath = path.join(projectRoot, 'xuqm.config.js')
|
||||
if (fs.existsSync(configJsPath)) {
|
||||
try {
|
||||
const config = require(configJsPath)
|
||||
if (config.bugCollectApiUrl) return config.bugCollectApiUrl
|
||||
if (config.platformUrl) return config.platformUrl.replace(/\/$/, '') + '/api'
|
||||
} catch (e) {
|
||||
console.warn('[BugCollect] Failed to read xuqm.config.js:', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试读取 .xuqmconfig (加密配置文件)
|
||||
const configDirs = ['config', 'xuqm']
|
||||
for (const dir of configDirs) {
|
||||
const configDir = path.join(projectRoot, 'src', 'assets', dir)
|
||||
if (!fs.existsSync(configDir)) continue
|
||||
|
||||
const files = fs.readdirSync(configDir)
|
||||
const configFile = files.find(f => f.endsWith('.xuqmconfig') || f.endsWith('.xuqm'))
|
||||
if (configFile) {
|
||||
// 加密配置文件需要解密,这里先跳过
|
||||
// 实际使用时应该通过 SDK 解密
|
||||
console.warn('[BugCollect] Encrypted config found but cannot be read directly')
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 从环境变量读取
|
||||
if (process.env.BUG_COLLECT_API_URL) return process.env.BUG_COLLECT_API_URL
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 SourceMap 文件
|
||||
*/
|
||||
function uploadSourceMap(apiUrl, appKey, platform, appVersion, sourceMapPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(`${apiUrl}/bugcollect/v1/sourcemaps/upload`)
|
||||
const isHttps = url.protocol === 'https:'
|
||||
const client = isHttps ? https : http
|
||||
|
||||
// 读取 sourcemap 文件
|
||||
const fileContent = fs.readFileSync(sourceMapPath)
|
||||
const fileName = path.basename(sourceMapPath)
|
||||
|
||||
// 构建 multipart/form-data
|
||||
const boundary = '----BugCollectBoundary' + Date.now()
|
||||
const parts = []
|
||||
|
||||
// appKey
|
||||
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="appKey"\r\n\r\n${appKey}`)
|
||||
|
||||
// platform
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="platform"\r\n\r\n${platform}`,
|
||||
)
|
||||
|
||||
// appVersion
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="appVersion"\r\n\r\n${appVersion}`,
|
||||
)
|
||||
|
||||
// bundleName
|
||||
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="bundleName"\r\n\r\nindex`)
|
||||
|
||||
// file
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/json\r\n\r\n`,
|
||||
)
|
||||
|
||||
const header = Buffer.from(parts.join('\r\n') + '\r\n')
|
||||
const footer = Buffer.from(`\r\n--${boundary}--\r\n`)
|
||||
const body = Buffer.concat([header, fileContent, footer])
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': body.length,
|
||||
},
|
||||
}
|
||||
|
||||
const req = client.request(options, res => {
|
||||
let data = ''
|
||||
res.on('data', chunk => (data += chunk))
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
console.log(`[BugCollect] SourceMap uploaded successfully: ${fileName}`)
|
||||
resolve(data)
|
||||
} else {
|
||||
console.warn(`[BugCollect] SourceMap upload failed: ${res.statusCode} ${data}`)
|
||||
reject(new Error(`Upload failed: ${res.statusCode}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', err => {
|
||||
console.warn('[BugCollect] SourceMap upload error:', err.message)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用版本号
|
||||
*/
|
||||
function getAppVersion(projectRoot) {
|
||||
try {
|
||||
const packageJson = require(path.join(projectRoot, 'package.json'))
|
||||
return packageJson.version || '1.0.0'
|
||||
} catch {
|
||||
return '1.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* withBugCollect(metroConfig)
|
||||
* 包裹 Metro 配置,打 Release 包时自动上传 SourceMap。
|
||||
*/
|
||||
function withBugCollect(metroConfig) {
|
||||
const projectRoot = metroConfig.projectRoot || process.cwd()
|
||||
|
||||
return {
|
||||
...metroConfig,
|
||||
serializer: {
|
||||
...metroConfig.serializer,
|
||||
customSerializer: async (entryPoint, preModules, graph, options) => {
|
||||
// 仅在宿主已有 customSerializer 时委托;否则保持 result 未定义并交回 Metro。
|
||||
// 注意:本插件应通过显式包裹在已有 serializer 之上使用(见 README),
|
||||
// 不建议自动链式启用(Metro 默认 serializer 不可经 exports 获取)。
|
||||
const baseSerializer = metroConfig.serializer?.customSerializer
|
||||
const result = baseSerializer
|
||||
? await baseSerializer(entryPoint, preModules, graph, options)
|
||||
: undefined
|
||||
|
||||
// 仅 Release 包上传 SourceMap(dev 模式跳过)
|
||||
if (!options.dev) {
|
||||
try {
|
||||
// 获取配置
|
||||
const apiUrl = readConfig(projectRoot)
|
||||
if (!apiUrl) {
|
||||
console.warn('[BugCollect] No API URL configured, skipping SourceMap upload')
|
||||
return result
|
||||
}
|
||||
|
||||
// 从 graph 中获取 appKey
|
||||
const appKey = graph.transformer?.config?.xuqmAppKey || process.env.XUQM_APP_KEY || ''
|
||||
|
||||
if (!appKey) {
|
||||
console.warn('[BugCollect] No appKey configured, skipping SourceMap upload')
|
||||
return result
|
||||
}
|
||||
|
||||
// 获取平台和版本
|
||||
const platform = options.platform || 'android'
|
||||
const appVersion = getAppVersion(projectRoot)
|
||||
|
||||
// 查找 sourcemap 文件路径
|
||||
// Metro 会在 options.sourceMapUrl 中指定 sourcemap 路径
|
||||
const sourceMapUrl = options.sourceMapUrl
|
||||
let sourceMapPath = null
|
||||
|
||||
if (sourceMapUrl) {
|
||||
// 从 URL 中提取本地路径
|
||||
if (sourceMapUrl.startsWith('file://')) {
|
||||
sourceMapPath = sourceMapUrl.replace('file://', '')
|
||||
} else if (sourceMapUrl.startsWith('/')) {
|
||||
sourceMapPath = sourceMapUrl
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有从 URL 获取,尝试默认路径
|
||||
if (!sourceMapPath || !fs.existsSync(sourceMapPath)) {
|
||||
const defaultPaths = [
|
||||
path.join(projectRoot, `${platform}.bundle.map`),
|
||||
path.join(projectRoot, 'index.bundle.map'),
|
||||
path.join(projectRoot, 'bundle.map'),
|
||||
]
|
||||
sourceMapPath = defaultPaths.find(p => fs.existsSync(p))
|
||||
}
|
||||
|
||||
if (!sourceMapPath || !fs.existsSync(sourceMapPath)) {
|
||||
console.warn('[BugCollect] SourceMap file not found, skipping upload')
|
||||
return result
|
||||
}
|
||||
|
||||
// 异步上传(不阻塞构建)
|
||||
uploadSourceMap(apiUrl, appKey, platform, appVersion, sourceMapPath).catch(err => {
|
||||
console.warn('[BugCollect] SourceMap upload failed:', err.message)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[BugCollect] SourceMap upload error:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { withBugCollect }
|
||||
@ -6,10 +6,8 @@
|
||||
"main": "src/index.ts",
|
||||
"react-native": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"metro": "metro/index.js",
|
||||
"files": [
|
||||
"src",
|
||||
"metro"
|
||||
"src"
|
||||
],
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { Platform, Dimensions } from 'react-native'
|
||||
import { getConfig, isInitialized, getUserId } from '@xuqm/rn-common'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { getConfig, hasPrivacyConsent, isInitialized, getUserId } from '@xuqm/rn-common'
|
||||
import { _resolveBundleIdentity } from '@xuqm/rn-common/internal'
|
||||
import { LogQueue } from './queue/LogQueue'
|
||||
import { ErrorCapture } from './capture/ErrorCapture'
|
||||
import { HttpInterceptor } from './interceptor/HttpInterceptor'
|
||||
@ -7,6 +9,7 @@ import { computeFingerprint } from './fingerprint'
|
||||
import { FunnelTracker } from './funnel/FunnelTracker'
|
||||
import { BUGCOLLECT_SDK_NAME, BUGCOLLECT_SDK_VERSION } from './sdkInfo'
|
||||
import { ReportPolicy } from './reportPolicy'
|
||||
import { assertBugCollectPrivacyConsent, sanitizeDiagnostic } from './privacy'
|
||||
import type {
|
||||
LogLevel,
|
||||
Environment,
|
||||
@ -28,6 +31,12 @@ let _queue: LogQueue | null = null
|
||||
let _errorCaptureStarted = false
|
||||
let _httpInterceptorStarted = false
|
||||
let _breadcrumbs: BreadcrumbItem[] = []
|
||||
let _platformDisabled = false
|
||||
const DISABLED_KEY_PREFIX = '@xuqm_bugcollect:disabled:'
|
||||
|
||||
function disabledKey(): string {
|
||||
return `${DISABLED_KEY_PREFIX}${getConfig().appKey}`
|
||||
}
|
||||
|
||||
const reportPolicy = new ReportPolicy()
|
||||
|
||||
@ -93,14 +102,69 @@ function _getDeviceInfo(): DeviceInfo {
|
||||
}
|
||||
}
|
||||
|
||||
function _enqueue(event: BugCollectEvent): void {
|
||||
if (!_queue) {
|
||||
function _createQueue(): LogQueue {
|
||||
const cfg = getConfig()
|
||||
if (!cfg.bugCollectApiUrl || !cfg.bugCollectEnabled) return
|
||||
_queue = new LogQueue({ bugCollectApiUrl: cfg.bugCollectApiUrl, appKey: cfg.appKey })
|
||||
return new LogQueue({
|
||||
bugCollectApiUrl: cfg.bugCollectApiUrl,
|
||||
appKey: cfg.appKey,
|
||||
encryptionSecret: cfg.signingKey,
|
||||
onDisabled: () => {
|
||||
_platformDisabled = true
|
||||
void AsyncStorage.setItem(disabledKey(), 'true').catch(() => undefined)
|
||||
stopCapture()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function _withBundleIdentity(event: BugCollectEvent): Promise<BugCollectEvent> {
|
||||
if (event.type !== 'issue') return event
|
||||
const stacktrace = event.exception?.stacktrace
|
||||
if (!stacktrace) return { ...event, symbolicationStatus: 'unavailable' }
|
||||
const identity = await _resolveBundleIdentity(stacktrace).catch(() => null)
|
||||
return identity
|
||||
? { ...event, ...identity, symbolicationStatus: 'exact' }
|
||||
: { ...event, symbolicationStatus: 'unavailable' }
|
||||
}
|
||||
|
||||
async function _enqueue(event: BugCollectEvent): Promise<void> {
|
||||
if (!_canCollect()) return
|
||||
if (!_queue) _queue = _createQueue()
|
||||
if (!_shouldReport(event)) return
|
||||
void _queue.push(event)
|
||||
const enriched = await _withBundleIdentity(event)
|
||||
await _queue?.push(sanitizeDiagnostic(enriched))
|
||||
}
|
||||
|
||||
function _enqueueInBackground(event: BugCollectEvent): void {
|
||||
void _enqueue(event).catch(() => undefined)
|
||||
}
|
||||
|
||||
function _canCollect(): boolean {
|
||||
if (!isInitialized() || !hasPrivacyConsent() || _platformDisabled) return false
|
||||
const cfg = getConfig()
|
||||
return !cfg.debug && cfg.bugCollectEnabled && Boolean(cfg.bugCollectApiUrl)
|
||||
}
|
||||
|
||||
function startCapture(): void {
|
||||
if (!_canCollect()) return
|
||||
if (!_errorCaptureStarted) {
|
||||
_errorCaptureStarted = true
|
||||
ErrorCapture.start(BugCollect.captureError.bind(BugCollect))
|
||||
}
|
||||
if (!_httpInterceptorStarted) {
|
||||
_httpInterceptorStarted = true
|
||||
HttpInterceptor.start(BugCollect.captureError.bind(BugCollect))
|
||||
}
|
||||
}
|
||||
|
||||
function stopCapture(): void {
|
||||
if (_errorCaptureStarted) {
|
||||
ErrorCapture.stop()
|
||||
_errorCaptureStarted = false
|
||||
}
|
||||
if (_httpInterceptorStarted) {
|
||||
HttpInterceptor.stop()
|
||||
_httpInterceptorStarted = false
|
||||
}
|
||||
}
|
||||
|
||||
function _buildIssue(level: Level, error: unknown, metadata?: Record<string, unknown>): IssueEvent {
|
||||
@ -125,6 +189,7 @@ function _buildIssue(level: Level, error: unknown, metadata?: Record<string, unk
|
||||
tags: metadata,
|
||||
device: _getDeviceInfo(),
|
||||
sdk: { name: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION },
|
||||
symbolicationStatus: 'unavailable',
|
||||
}
|
||||
}
|
||||
|
||||
@ -168,20 +233,20 @@ export const BugCollect = {
|
||||
device: _getDeviceInfo(),
|
||||
sdk: { name: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION },
|
||||
}
|
||||
_enqueue(event)
|
||||
_enqueueInBackground(event)
|
||||
FunnelTracker.track(name, properties)
|
||||
},
|
||||
|
||||
/** Upload a JS exception as an error-level issue. */
|
||||
captureError(error: unknown, metadata?: Record<string, unknown>): void {
|
||||
if (!isInitialized()) return
|
||||
_enqueue(_buildIssue('error', error, metadata))
|
||||
_enqueueInBackground(_buildIssue('error', error, metadata))
|
||||
},
|
||||
|
||||
/** Upload a JS exception as a fatal-level issue (persisted across restarts). */
|
||||
captureFatal(error: unknown, metadata?: Record<string, unknown>): void {
|
||||
if (!isInitialized()) return
|
||||
_enqueue(_buildIssue('fatal', error, metadata))
|
||||
_enqueueInBackground(_buildIssue('fatal', error, metadata))
|
||||
},
|
||||
|
||||
/** Log a warning (if log level allows). */
|
||||
@ -203,8 +268,9 @@ export const BugCollect = {
|
||||
sessionId: _sessionId,
|
||||
tags: metadata,
|
||||
sdk: { name: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION },
|
||||
symbolicationStatus: 'unavailable',
|
||||
}
|
||||
_enqueue(issue)
|
||||
_enqueueInBackground(issue)
|
||||
},
|
||||
|
||||
/** Log an informational event (if log level allows). */
|
||||
@ -225,31 +291,34 @@ export const BugCollect = {
|
||||
sessionId: _sessionId,
|
||||
tags: metadata,
|
||||
sdk: { name: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION },
|
||||
symbolicationStatus: 'unavailable',
|
||||
}
|
||||
_enqueue(issue)
|
||||
_enqueueInBackground(issue)
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable automatic capture of JS global errors and unhandled Promise rejections,
|
||||
* plus HTTP/接口报错 自动上报(包裹 global.fetch)。
|
||||
* Call once at app startup after XuqmSDK.initialize().
|
||||
*/
|
||||
startCapture(): void {
|
||||
if (!_errorCaptureStarted) {
|
||||
_errorCaptureStarted = true
|
||||
ErrorCapture.start(BugCollect.captureError.bind(BugCollect))
|
||||
/** Debug 开发页的一次性测试入口;不改变真实隐私同意状态或自动采集开关。 */
|
||||
async sendTestErrorAndFlush(message = 'Xuqm BugCollect test error'): Promise<void> {
|
||||
if (!isInitialized()) throw new Error('[BugCollect] SDK is not initialized')
|
||||
assertBugCollectPrivacyConsent(hasPrivacyConsent())
|
||||
const cfg = getConfig()
|
||||
if (!cfg.bugCollectEnabled || !cfg.bugCollectApiUrl || _platformDisabled) {
|
||||
throw new Error('[BugCollect] Service is disabled')
|
||||
}
|
||||
if (!_httpInterceptorStarted) {
|
||||
_httpInterceptorStarted = true
|
||||
HttpInterceptor.start(BugCollect.captureError.bind(BugCollect))
|
||||
if (!_queue) {
|
||||
_queue = _createQueue()
|
||||
}
|
||||
const issue = await _withBundleIdentity(
|
||||
_buildIssue('error', new Error(message), { testEvent: true }),
|
||||
)
|
||||
await _queue.push(sanitizeDiagnostic(issue))
|
||||
await _queue.flush(true)
|
||||
},
|
||||
|
||||
defineFunnel: FunnelTracker.define.bind(FunnelTracker),
|
||||
getFunnelProgress: FunnelTracker.getProgress.bind(FunnelTracker),
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (_queue) await _queue.flush()
|
||||
if (_queue) await _queue.flush(true)
|
||||
},
|
||||
|
||||
destroy(): void {
|
||||
@ -257,11 +326,43 @@ export const BugCollect = {
|
||||
_queue.destroy()
|
||||
_queue = null
|
||||
}
|
||||
if (_httpInterceptorStarted) {
|
||||
HttpInterceptor.stop()
|
||||
_httpInterceptorStarted = false
|
||||
}
|
||||
stopCapture()
|
||||
reportPolicy.clear()
|
||||
_breadcrumbs = []
|
||||
},
|
||||
}
|
||||
|
||||
/** 仅供包内初始化/隐私生命周期使用,不从 package index 导出。 */
|
||||
export const _BugCollectLifecycle = {
|
||||
startCapture,
|
||||
|
||||
async applyEnabledConfig(authoritative: boolean): Promise<boolean> {
|
||||
if (authoritative) {
|
||||
_platformDisabled = false
|
||||
await AsyncStorage.removeItem(disabledKey()).catch(() => undefined)
|
||||
return true
|
||||
}
|
||||
_platformDisabled = (await AsyncStorage.getItem(disabledKey()).catch(() => null)) === 'true'
|
||||
return !_platformDisabled
|
||||
},
|
||||
|
||||
async disableAndClear(): Promise<void> {
|
||||
_platformDisabled = true
|
||||
if (isInitialized()) {
|
||||
await AsyncStorage.setItem(disabledKey(), 'true').catch(() => undefined)
|
||||
}
|
||||
stopCapture()
|
||||
await _queue?.clear()
|
||||
if (isInitialized()) await LogQueue.clearStored(getConfig().appKey)
|
||||
_queue?.destroy()
|
||||
_queue = null
|
||||
},
|
||||
|
||||
async clearPendingAndStop(): Promise<void> {
|
||||
stopCapture()
|
||||
await _queue?.clear()
|
||||
if (isInitialized()) await LogQueue.clearStored(getConfig().appKey)
|
||||
_queue?.destroy()
|
||||
_queue = null
|
||||
},
|
||||
}
|
||||
|
||||
@ -11,24 +11,41 @@ declare const ErrorUtils: {
|
||||
|
||||
// React Native / Hermes exposes onunhandledrejection on globalThis
|
||||
type UnhandledRejectionHandler = ((event: { reason: unknown }) => void) | null | undefined
|
||||
let previousErrorHandler: ((error: Error, isFatal?: boolean) => void) | null = null
|
||||
let previousUnhandledHandler: UnhandledRejectionHandler = null
|
||||
let started = false
|
||||
|
||||
export const ErrorCapture = {
|
||||
start(onError: (error: unknown, meta?: Record<string, unknown>) => void): void {
|
||||
if (started) return
|
||||
started = true
|
||||
// JS global error handler
|
||||
const prevError = typeof ErrorUtils !== 'undefined' ? ErrorUtils.getGlobalHandler() : null
|
||||
previousErrorHandler = typeof ErrorUtils !== 'undefined' ? ErrorUtils.getGlobalHandler() : null
|
||||
if (typeof ErrorUtils !== 'undefined') {
|
||||
ErrorUtils.setGlobalHandler((error, isFatal) => {
|
||||
onError(error, { isFatal: isFatal ?? false })
|
||||
prevError?.(error, isFatal)
|
||||
previousErrorHandler?.(error, isFatal)
|
||||
})
|
||||
}
|
||||
|
||||
// Unhandled Promise rejection
|
||||
const g = globalThis as unknown as Record<string, unknown>
|
||||
const prevUnhandled = (g['onunhandledrejection'] as UnhandledRejectionHandler) ?? null
|
||||
previousUnhandledHandler = (g['onunhandledrejection'] as UnhandledRejectionHandler) ?? null
|
||||
g['onunhandledrejection'] = (event: { reason: unknown }) => {
|
||||
onError(event.reason, { type: 'unhandledRejection' })
|
||||
prevUnhandled?.(event)
|
||||
previousUnhandledHandler?.(event)
|
||||
}
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
if (!started) return
|
||||
if (typeof ErrorUtils !== 'undefined') {
|
||||
ErrorUtils.setGlobalHandler(previousErrorHandler ?? (() => undefined))
|
||||
}
|
||||
const g = globalThis as unknown as Record<string, unknown>
|
||||
g['onunhandledrejection'] = previousUnhandledHandler ?? null
|
||||
previousErrorHandler = null
|
||||
previousUnhandledHandler = null
|
||||
started = false
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,12 +1,27 @@
|
||||
import { _registerInitializationHandler } from '@xuqm/rn-common/internal'
|
||||
import {
|
||||
_registerInitializationHandler,
|
||||
_registerPrivacyConsentHandler,
|
||||
} from '@xuqm/rn-common/internal'
|
||||
import { hasPrivacyConsent } from '@xuqm/rn-common'
|
||||
|
||||
import { BugCollect } from './BugCollect'
|
||||
import { _BugCollectLifecycle, BugCollect } from './BugCollect'
|
||||
|
||||
_registerInitializationHandler('rn-bugcollect', config => {
|
||||
_registerInitializationHandler('rn-bugcollect', async config => {
|
||||
if (config.bugCollectEnabled && config.bugCollectApiUrl) {
|
||||
BugCollect.startCapture()
|
||||
const enabled = await _BugCollectLifecycle.applyEnabledConfig(
|
||||
config.configurationSource === 'remote',
|
||||
)
|
||||
if (enabled && hasPrivacyConsent() && !config.debug) _BugCollectLifecycle.startCapture()
|
||||
else void _BugCollectLifecycle.clearPendingAndStop()
|
||||
} else {
|
||||
void _BugCollectLifecycle.disableAndClear()
|
||||
}
|
||||
})
|
||||
|
||||
_registerPrivacyConsentHandler('rn-bugcollect', consented => {
|
||||
if (consented) _BugCollectLifecycle.startCapture()
|
||||
else return _BugCollectLifecycle.clearPendingAndStop()
|
||||
})
|
||||
|
||||
export { BugCollect }
|
||||
export type { LogLevel, Environment, LogEvent, IssueEvent, BugCollectEvent } from './types'
|
||||
|
||||
@ -10,6 +10,32 @@ type OnError = (error: unknown, meta?: Record<string, unknown>) => void
|
||||
|
||||
let _originalFetch: typeof fetch | null = null
|
||||
|
||||
function getRequestMetadata(input: RequestInfo | URL, init?: RequestInit) {
|
||||
const rawUrl =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
||||
let path: string
|
||||
try {
|
||||
path = new URL(rawUrl).pathname
|
||||
} catch {
|
||||
path = rawUrl.split(/[?#]/, 1)[0] ?? ''
|
||||
}
|
||||
return {
|
||||
method: init?.method?.toUpperCase() ?? 'GET',
|
||||
path,
|
||||
type: 'api_error',
|
||||
}
|
||||
}
|
||||
|
||||
function isBugCollectRequest(input: RequestInfo | URL): boolean {
|
||||
const rawUrl =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
||||
try {
|
||||
return new URL(rawUrl).pathname.includes('/bugcollect/')
|
||||
} catch {
|
||||
return rawUrl.includes('/bugcollect/')
|
||||
}
|
||||
}
|
||||
|
||||
export const HttpInterceptor = {
|
||||
start(onError: OnError): void {
|
||||
if (_originalFetch) return // already installed
|
||||
@ -22,23 +48,20 @@ export const HttpInterceptor = {
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const original = _originalFetch!
|
||||
if (isBugCollectRequest(input)) return original(input, init)
|
||||
try {
|
||||
const res = await original(input, init)
|
||||
if (!res.ok) {
|
||||
const url =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
||||
onError(new Error(`HTTP ${res.status} ${res.statusText}`), {
|
||||
type: 'api_error',
|
||||
url,
|
||||
onError(new Error(`HTTP ${res.status}`), {
|
||||
...getRequestMetadata(input, init),
|
||||
status: res.status,
|
||||
})
|
||||
}
|
||||
return res
|
||||
} catch (e) {
|
||||
const url =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
||||
onError(e, { type: 'api_error', url })
|
||||
throw e
|
||||
} catch (error) {
|
||||
// fetch 抛出的对象可能携带原始 URL 或原生请求细节;采集端只接收固定错误和摘要。
|
||||
onError(new Error('HTTP request failed'), getRequestMetadata(input, init))
|
||||
throw error
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
const SENSITIVE_KEY =
|
||||
/authorization|cookie|token|secret|password|passwd|pin|idcard|identity|phone|mobile/i
|
||||
const PHONE = /(?<!\d)1[3-9]\d{9}(?!\d)/g
|
||||
const ID_CARD = /(?<![0-9A-Z])\d{17}[0-9X](?![0-9A-Z])/gi
|
||||
const BEARER = /Bearer\s+[A-Za-z0-9._~+/=-]+/gi
|
||||
|
||||
export function assertBugCollectPrivacyConsent(consented: boolean): void {
|
||||
if (!consented) {
|
||||
throw new Error('[BugCollect] Privacy consent is required')
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeString(value: string): string {
|
||||
return value
|
||||
.replace(BEARER, 'Bearer [REDACTED]')
|
||||
.replace(PHONE, '[PHONE]')
|
||||
.replace(ID_CARD, '[ID_CARD]')
|
||||
}
|
||||
|
||||
/** BugCollect 入队前的唯一深度脱敏实现,避免网络与持久化分支出现不同策略。 */
|
||||
export function sanitizeDiagnostic<T>(value: T, seen = new WeakSet<object>()): T {
|
||||
if (typeof value === 'string') return sanitizeString(value) as T
|
||||
if (value === null || typeof value !== 'object') return value
|
||||
if (seen.has(value)) return '[CIRCULAR]' as T
|
||||
seen.add(value)
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => sanitizeDiagnostic(item, seen)) as T
|
||||
}
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
result[key] = SENSITIVE_KEY.test(key) ? '[REDACTED]' : sanitizeDiagnostic(item, seen)
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
@ -2,25 +2,50 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { BugCollectEvent } from '../types'
|
||||
import { BUGCOLLECT_SDK_NAME, BUGCOLLECT_SDK_VERSION } from '../sdkInfo'
|
||||
import { shouldPersist } from './queuePolicy'
|
||||
|
||||
const STORED_QUEUE_KEY = '@xuqm_bugcollect:queue'
|
||||
import { retryWithBackoff } from '@xuqm/rn-common'
|
||||
import {
|
||||
clearStoredQueue,
|
||||
isBugCollectDisabledPayload,
|
||||
openQueue,
|
||||
sealQueue,
|
||||
storedQueueKey,
|
||||
} from './queueStorage'
|
||||
const BATCH_SIZE = 30
|
||||
const FLUSH_INTERVAL_MS = 10_000 // 10 seconds
|
||||
const MAX_QUEUE_SIZE = 500
|
||||
const MAX_RETRY = 3
|
||||
const MAX_STORED_AGE_MS = 7 * 24 * 60 * 60 * 1_000
|
||||
const DISABLED_ERROR_CODE = 'BUGCOLLECT_DISABLED'
|
||||
|
||||
export class BugCollectDisabledError extends Error {
|
||||
readonly name = 'BugCollectDisabledError'
|
||||
readonly code = DISABLED_ERROR_CODE
|
||||
}
|
||||
|
||||
export class LogQueue {
|
||||
private _memory: BugCollectEvent[] = []
|
||||
private flushTimer: ReturnType<typeof setInterval> | null = null
|
||||
private retryCount = 0
|
||||
private disabled = false
|
||||
private flushPromise: Promise<void> | null = null
|
||||
|
||||
constructor(private cfg: { bugCollectApiUrl: string; appKey: string }) {
|
||||
constructor(
|
||||
private cfg: {
|
||||
bugCollectApiUrl: string
|
||||
appKey: string
|
||||
encryptionSecret?: string
|
||||
onDisabled?: () => void | Promise<void>
|
||||
},
|
||||
) {
|
||||
this.flushTimer = setInterval(() => {
|
||||
void this.flush()
|
||||
}, FLUSH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
static async clearStored(appKey: string): Promise<void> {
|
||||
await clearStoredQueue(AsyncStorage, appKey).catch(() => undefined)
|
||||
}
|
||||
|
||||
async push(event: BugCollectEvent): Promise<void> {
|
||||
if (this.disabled) return
|
||||
if (shouldPersist(event)) {
|
||||
const stored = await this._readStored()
|
||||
if (stored.length >= MAX_QUEUE_SIZE) stored.shift()
|
||||
@ -32,7 +57,16 @@ export class LogQueue {
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
async flush(throwOnFailure = false): Promise<void> {
|
||||
if (this.flushPromise) return this.flushPromise
|
||||
this.flushPromise = this._flushOnce(throwOnFailure).finally(() => {
|
||||
this.flushPromise = null
|
||||
})
|
||||
return this.flushPromise
|
||||
}
|
||||
|
||||
private async _flushOnce(throwOnFailure: boolean): Promise<void> {
|
||||
if (this.disabled) return
|
||||
const stored = await this._readStored()
|
||||
const combined = [...stored, ...this._memory]
|
||||
if (combined.length === 0) return
|
||||
@ -41,28 +75,36 @@ export class LogQueue {
|
||||
const storedUsed = Math.min(stored.length, BATCH_SIZE)
|
||||
const memUsed = batch.length - storedUsed
|
||||
|
||||
// Optimistically remove from sources before sending to avoid duplicates
|
||||
await this._writeStored(stored.slice(storedUsed))
|
||||
this._memory = this._memory.slice(memUsed)
|
||||
|
||||
const issues = batch.filter(e => e.type !== 'event')
|
||||
const events = batch.filter(e => e.type === 'event')
|
||||
|
||||
try {
|
||||
await retryWithBackoff(
|
||||
async () => {
|
||||
if (issues.length > 0) await this._post('/bugcollect/v1/issues/batch', issues)
|
||||
if (events.length > 0) await this._post('/bugcollect/v1/events/batch', events)
|
||||
this.retryCount = 0
|
||||
} catch {
|
||||
this.retryCount++
|
||||
if (this.retryCount < MAX_RETRY) {
|
||||
// Re-queue only the persistent subset (re-queuing memory events would inflate duplicates)
|
||||
const toRequeue = batch.filter(shouldPersist)
|
||||
if (toRequeue.length > 0) {
|
||||
const current = await this._readStored()
|
||||
await this._writeStored([...toRequeue, ...current])
|
||||
}
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
shouldRetry: error => !(error instanceof BugCollectDisabledError),
|
||||
},
|
||||
)
|
||||
await this._writeStored(stored.slice(storedUsed))
|
||||
this._memory = this._memory.slice(memUsed)
|
||||
} catch (error) {
|
||||
if (error instanceof BugCollectDisabledError) {
|
||||
this.disabled = true
|
||||
await this.clear()
|
||||
await this.cfg.onDisabled?.()
|
||||
}
|
||||
// 后台上报失败由队列吸收;保留原队列,等待网络恢复或下次进程启动。
|
||||
if (throwOnFailure) throw error
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this._memory = []
|
||||
await AsyncStorage.removeItem(this.storageKey).catch(() => undefined)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
@ -87,20 +129,57 @@ export class LogQueue {
|
||||
body,
|
||||
})
|
||||
if (!res.ok) {
|
||||
let code: string | undefined
|
||||
let disabledByPlatform = false
|
||||
try {
|
||||
const payload = (await res.json()) as Record<string, unknown>
|
||||
disabledByPlatform = isBugCollectDisabledPayload(payload)
|
||||
code = String(payload.code ?? payload.errorCode ?? payload.message ?? '')
|
||||
} catch {
|
||||
// 非 JSON 错误由通用重试策略处理。
|
||||
}
|
||||
if (disabledByPlatform || code === DISABLED_ERROR_CODE) {
|
||||
throw new BugCollectDisabledError()
|
||||
}
|
||||
throw new Error(`[BugCollect] Upload failed: ${res.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
private get storageKey(): string {
|
||||
return storedQueueKey(this.cfg.appKey)
|
||||
}
|
||||
|
||||
private get storageContext(): string {
|
||||
return `bugcollect-queue|${this.cfg.appKey}`
|
||||
}
|
||||
|
||||
private async _readStored(): Promise<BugCollectEvent[]> {
|
||||
const raw = await AsyncStorage.getItem(STORED_QUEUE_KEY)
|
||||
return raw ? (JSON.parse(raw) as BugCollectEvent[]) : []
|
||||
const raw = await AsyncStorage.getItem(this.storageKey)
|
||||
if (!raw) return []
|
||||
if (!this.cfg.encryptionSecret) {
|
||||
await AsyncStorage.removeItem(this.storageKey)
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const cutoff = Date.now() - MAX_STORED_AGE_MS
|
||||
return openQueue(raw, this.cfg.encryptionSecret, this.storageContext, cutoff)
|
||||
} catch {
|
||||
await AsyncStorage.removeItem(this.storageKey)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async _writeStored(queue: BugCollectEvent[]): Promise<void> {
|
||||
if (queue.length === 0) {
|
||||
await AsyncStorage.removeItem(STORED_QUEUE_KEY)
|
||||
await AsyncStorage.removeItem(this.storageKey)
|
||||
} else if (!this.cfg.encryptionSecret) {
|
||||
// 没有签发密钥时绝不降级写入含栈和设备信息的明文持久队列。
|
||||
await AsyncStorage.removeItem(this.storageKey)
|
||||
} else {
|
||||
await AsyncStorage.setItem(STORED_QUEUE_KEY, JSON.stringify(queue))
|
||||
await AsyncStorage.setItem(
|
||||
this.storageKey,
|
||||
sealQueue(queue, this.cfg.encryptionSecret, this.storageContext),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
import { openLocalCache, sealLocalCache } from '@xuqm/rn-common/secure-cache'
|
||||
import type { BugCollectEvent } from '../types'
|
||||
|
||||
const STORED_QUEUE_KEY_PREFIX = '@xuqm_bugcollect:queue:'
|
||||
|
||||
export function storedQueueKey(appKey: string): string {
|
||||
return `${STORED_QUEUE_KEY_PREFIX}${appKey}`
|
||||
}
|
||||
|
||||
export async function clearStoredQueue(
|
||||
storage: { removeItem(key: string): Promise<unknown> },
|
||||
appKey: string,
|
||||
): Promise<void> {
|
||||
await storage.removeItem(storedQueueKey(appKey))
|
||||
}
|
||||
|
||||
export function sealQueue(queue: BugCollectEvent[], secret: string, context: string): string {
|
||||
return sealLocalCache(JSON.stringify(queue), secret, context)
|
||||
}
|
||||
|
||||
export function openQueue(
|
||||
payload: string,
|
||||
secret: string,
|
||||
context: string,
|
||||
cutoff: number,
|
||||
): BugCollectEvent[] {
|
||||
const parsed = JSON.parse(openLocalCache(payload, secret, context)) as BugCollectEvent[]
|
||||
if (!Array.isArray(parsed)) throw new Error('BugCollect queue payload is not an array')
|
||||
return parsed.filter(event => Number.isFinite(event.timestamp) && event.timestamp >= cutoff)
|
||||
}
|
||||
|
||||
export function isBugCollectDisabledPayload(payload: Record<string, unknown>): boolean {
|
||||
return [payload.code, payload.errorCode, payload.message]
|
||||
.map(value => String(value ?? ''))
|
||||
.includes('BUGCOLLECT_DISABLED')
|
||||
}
|
||||
@ -79,6 +79,12 @@ export interface IssueEvent {
|
||||
device?: DeviceInfo
|
||||
tags?: Record<string, unknown>
|
||||
sdk?: SdkInfo
|
||||
/** 四项齐全时服务端才允许精确 Source Map 符号化。 */
|
||||
buildId?: string
|
||||
moduleId?: string
|
||||
moduleVersion?: string
|
||||
bundleHash?: string
|
||||
symbolicationStatus: 'exact' | 'unavailable'
|
||||
}
|
||||
|
||||
export type BugCollectEvent = LogEvent | IssueEvent
|
||||
|
||||
@ -0,0 +1,52 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { HttpInterceptor } from '../src/interceptor/HttpInterceptor'
|
||||
|
||||
test('HTTP interception removes host, query and request contents before reporting', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const reports: Array<{ error: unknown; metadata?: Record<string, unknown> }> = []
|
||||
globalThis.fetch = async () => new Response(null, { status: 500 })
|
||||
|
||||
try {
|
||||
HttpInterceptor.start((error, metadata) => reports.push({ error, metadata }))
|
||||
await globalThis.fetch('https://api.example.com/am/user?phone=13800000000&token=secret', {
|
||||
body: JSON.stringify({ userId: 'secret-user' }),
|
||||
headers: { authorization: 'secret-authorization' },
|
||||
method: 'POST',
|
||||
})
|
||||
} finally {
|
||||
HttpInterceptor.stop()
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
assert.equal(reports.length, 1)
|
||||
assert.deepEqual(reports[0]?.metadata, {
|
||||
method: 'POST',
|
||||
path: '/am/user',
|
||||
status: 500,
|
||||
type: 'api_error',
|
||||
})
|
||||
const serialized = JSON.stringify(reports)
|
||||
assert.doesNotMatch(serialized, /example\.com|13800000000|token|secret|authorization|userId/)
|
||||
})
|
||||
|
||||
test('BugCollect transport failures are excluded to prevent recursive reporting', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const reports: unknown[] = []
|
||||
globalThis.fetch = async () => new Response(null, { status: 503 })
|
||||
|
||||
try {
|
||||
HttpInterceptor.start(error => reports.push(error))
|
||||
const response = await globalThis.fetch(
|
||||
'https://bugcollect.example.com/bugcollect/v1/issues/batch',
|
||||
{ method: 'POST' },
|
||||
)
|
||||
assert.equal(response.status, 503)
|
||||
} finally {
|
||||
HttpInterceptor.stop()
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
assert.deepEqual(reports, [])
|
||||
})
|
||||
@ -44,6 +44,7 @@ test('fatal and error issues are the only events persisted for restart recovery'
|
||||
exception: { type: 'Error', value: 'value' },
|
||||
sessionId: 'session',
|
||||
sdk: { name: 'bugcollect.rn', version: '1.0.0' },
|
||||
symbolicationStatus: 'unavailable',
|
||||
} as const
|
||||
assert.equal(shouldPersist({ ...base, level: 'fatal' } as BugCollectEvent), true)
|
||||
assert.equal(shouldPersist({ ...base, level: 'error' } as BugCollectEvent), true)
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { assertBugCollectPrivacyConsent, sanitizeDiagnostic } from '../src/privacy'
|
||||
|
||||
test('diagnostic sanitization removes secrets and common identity values recursively', () => {
|
||||
const sanitized = sanitizeDiagnostic({
|
||||
authorization: 'Bearer secret-token',
|
||||
nested: {
|
||||
message: 'phone=13800138000 id=110101199001011234',
|
||||
token: 'secret',
|
||||
},
|
||||
})
|
||||
const serialized = JSON.stringify(sanitized)
|
||||
assert.doesNotMatch(serialized, /secret-token|13800138000|110101199001011234|"secret"/)
|
||||
assert.match(serialized, /REDACTED|PHONE|ID_CARD/)
|
||||
})
|
||||
|
||||
test('manual BugCollect test upload still requires privacy consent', () => {
|
||||
assert.throws(() => assertBugCollectPrivacyConsent(false), /Privacy consent is required/)
|
||||
assert.doesNotThrow(() => assertBugCollectPrivacyConsent(true))
|
||||
})
|
||||
@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
clearStoredQueue,
|
||||
isBugCollectDisabledPayload,
|
||||
openQueue,
|
||||
sealQueue,
|
||||
} from '../src/queue/queueStorage'
|
||||
import type { IssueEvent } from '../src/types'
|
||||
|
||||
const issue: IssueEvent = {
|
||||
type: 'issue',
|
||||
level: 'error',
|
||||
platform: 'android',
|
||||
fingerprint: 'fingerprint',
|
||||
timestamp: 1_000,
|
||||
appKey: 'app',
|
||||
release: '8.0.0',
|
||||
exception: { type: 'Error', value: 'secret', stacktrace: 'private stack' },
|
||||
symbolicationStatus: 'unavailable',
|
||||
}
|
||||
|
||||
test('persistent queue is encrypted, context-bound and tamper-evident', () => {
|
||||
const encrypted = sealQueue([issue], 'signing-key', 'bugcollect-queue|app')
|
||||
assert.equal(encrypted.includes('private stack'), false)
|
||||
assert.deepEqual(openQueue(encrypted, 'signing-key', 'bugcollect-queue|app', 0), [issue])
|
||||
assert.throws(() => openQueue(encrypted, 'signing-key', 'bugcollect-queue|other', 0))
|
||||
const parts = encrypted.split('.')
|
||||
parts[2] = `${parts[2][0] === 'A' ? 'B' : 'A'}${parts[2].slice(1)}`
|
||||
assert.throws(() => openQueue(parts.join('.'), 'signing-key', 'bugcollect-queue|app', 0))
|
||||
})
|
||||
|
||||
test('platform disabled response recognizes the deployed message contract', () => {
|
||||
assert.equal(
|
||||
isBugCollectDisabledPayload({
|
||||
code: 403,
|
||||
status: '1',
|
||||
message: 'BUGCOLLECT_DISABLED',
|
||||
}),
|
||||
true,
|
||||
)
|
||||
assert.equal(isBugCollectDisabledPayload({ code: 'BUGCOLLECT_DISABLED' }), true)
|
||||
assert.equal(isBugCollectDisabledPayload({ code: 403, message: 'forbidden' }), false)
|
||||
})
|
||||
|
||||
test('cold-start disable removes a persisted queue before LogQueue is created', async () => {
|
||||
const removed: string[] = []
|
||||
await clearStoredQueue(
|
||||
{
|
||||
async removeItem(key) {
|
||||
removed.push(key)
|
||||
},
|
||||
},
|
||||
'app',
|
||||
)
|
||||
assert.deepEqual(removed, ['@xuqm_bugcollect:queue:app'])
|
||||
})
|
||||
@ -25,7 +25,8 @@ const state = expirationStatus('2027-01-01T00:00:00Z')
|
||||
|
||||
## 扩展包自动初始化
|
||||
|
||||
把平台生成的加密配置放到 `src/assets/config/config.xuqmconfig`,并只包装一次 Metro 配置:
|
||||
把平台生成的加密配置原名放到 `src/assets/config/`(目录内只能有一个
|
||||
`.xuqmconfig`),并只包装一次 Metro 配置:
|
||||
|
||||
```js
|
||||
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config')
|
||||
@ -38,19 +39,15 @@ 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 或多套配置约定。
|
||||
common-only 项目不要放置 `.xuqmconfig`,也不要使用 `withXuqmConfig()`,即可保持零初始化。是否初始化只由这一条规则决定,不提供手动初始化、额外 alias 或多套配置约定。
|
||||
|
||||
无法使用配置文件时,才调用一次手动初始化:
|
||||
Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败,common 会终止该 Promise,避免形成未处理异常。随后第一个真正需要配置的扩展调用共享 `awaitInitialization()` 时,会使用同一份已解析配置重新尝试;配置、appKey 和平台地址不需要由页面或子 SDK 再次传入。持续失败由宿主入口统一记录一次,SDK 不重复打印同一错误。构建期配置损坏、密钥不匹配或 Schema 非法会直接阻断构建,禁止打出运行时才失败的安装包。
|
||||
|
||||
```ts
|
||||
await XuqmSDK.initialize({ appKey: 'app-key' })
|
||||
```
|
||||
|
||||
同一配置的并发初始化会复用同一个 Promise;重复使用不同配置会直接报错。
|
||||
|
||||
Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败,common 会终止该 Promise,避免形成未处理异常。随后第一个真正需要配置的扩展调用共享 `awaitInitialization()` 时,会使用同一份加密配置重新尝试;配置、appKey 和平台地址不需要由页面或子 SDK 再次传入。持续失败由宿主入口统一记录一次,SDK 不重复打印同一错误。
|
||||
平台远程配置中的 `features.update` 是 Update 服务开关;兼容字段
|
||||
`updateEnabled` 只在前者缺失时读取。两者都缺失时安全默认关闭,最后成功配置缓存会
|
||||
原样保存开关值。关闭 Update 不影响 common、宿主业务或其它扩展 SDK。
|
||||
|
||||
## 扩展包的唯一登录入口
|
||||
|
||||
@ -80,7 +77,11 @@ await XuqmSDK.logout()
|
||||
- `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`:统一时间解析与过期判断。
|
||||
- `formatNumericDateTime`:为 API 参数和固定数字布局提供不受 locale 标点/顺序影响的本地时间格式,调用方通过选项控制日期、时间、秒和分隔符。
|
||||
- `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`、`registerAndroidDownloadedFile`:统一文件与 Android 下载登记操作。
|
||||
- `decryptXuqmFile`、`hmacSha256Base64Url`:基于 `@noble/*` 的跨平台加密实现。
|
||||
- `hmacSha256Base64Url`、`sha256Hex`:签名与文件摘要使用的统一实现。平台签发的
|
||||
`.xuqmconfig` 只允许由 Metro 构建插件验签和解析,不向运行时暴露通用解密 API。
|
||||
- `getDeviceInfo`、`getDeviceId`、`useApi`、`usePageApi`、`showToast` 等项目通用能力。
|
||||
|
||||
业务代码不得导入 `@xuqm/rn-common/internal`。该子路径仅供官方 `@xuqm` 扩展包接入公共生命周期。
|
||||
`internal-security` 同样只供官方扩展使用:配置 V2 和插件不可变 manifest 复用一份
|
||||
canonical JSON(键排序、无空白、null/undefined 字段省略)与可信 Ed25519 keyId
|
||||
集合,不是宿主可替换的通用验签入口。
|
||||
|
||||
@ -6,42 +6,144 @@
|
||||
* 宿主只需将平台下载的 .xuqmconfig 文件放入 src/assets/config/,
|
||||
* SDK 自动读取并完成初始化,无需重命名或创建 .ts 包装文件。
|
||||
*
|
||||
* 对齐 Android SDK 的 ConfigFileReader 逻辑:
|
||||
* 优先 config.xuqmconfig,否则取第一个 *.xuqmconfig。
|
||||
* 目录内没有配置时保持 common-only;唯一一个 *.xuqmconfig 自动使用;多个文件
|
||||
* 无法判断权威配置,必须在构建期明确失败。
|
||||
*
|
||||
* @param {import('@react-native/metro-config').MetroConfig} metroConfig
|
||||
* @returns {import('@react-native/metro-config').MetroConfig}
|
||||
*/
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const VIRTUAL_MODULE_ID = '@xuqm/autoinit-config'
|
||||
const VIRTUAL_MODULE_ID = '@xuqm/rn-common/auto-init-config'
|
||||
const AUTO_INIT_MODULE = path.resolve(__dirname, '../src/internal.ts')
|
||||
const CONFIG_DIR_CANDIDATES = [
|
||||
'src/assets/config',
|
||||
'src/assets/xuqm',
|
||||
'assets/config',
|
||||
'assets/xuqm',
|
||||
]
|
||||
const CONFIG_DIRECTORY_RELATIVE_PATH = 'src/assets/config'
|
||||
const CONFIG_MAGIC = 'XUQM-CONFIG-V2'
|
||||
const CONFIG_PASSPHRASE = 'xuqm-config-file-v2.2026.internal'
|
||||
const PBKDF2_ITERATIONS = 120_000
|
||||
const TRUSTED_CONFIG_KEYS = require('../security/trusted-signing-keys.json')
|
||||
const { canonicalJson } = require('../security/canonical-json.js')
|
||||
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
return Buffer.from(value, 'base64url')
|
||||
}
|
||||
|
||||
/**
|
||||
* 配置文件只在 Node/Metro 构建期解密。移动端运行时直接消费解析后的配置,
|
||||
* 避免 Hermes 在应用启动阶段执行 12 万次 PBKDF2 并阻塞 JS 线程。
|
||||
*/
|
||||
function assertConfigPayload(config, plaintext) {
|
||||
const allowedFields = new Set([
|
||||
'schemaVersion',
|
||||
'configId',
|
||||
'revision',
|
||||
'issuedAt',
|
||||
'expiresAt',
|
||||
'appKey',
|
||||
'appName',
|
||||
'packageName',
|
||||
'iosBundleId',
|
||||
'harmonyBundleName',
|
||||
'serverUrl',
|
||||
'signingKey',
|
||||
])
|
||||
const unknownField = Object.keys(config).find(field => !allowedFields.has(field))
|
||||
if (unknownField) throw new Error(`配置包含不受支持字段:${unknownField}`)
|
||||
if (config.schemaVersion !== 2) throw new Error('配置 schemaVersion 必须为 2')
|
||||
if (
|
||||
typeof config.configId !== 'string' ||
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
config.configId,
|
||||
)
|
||||
) {
|
||||
throw new Error('配置 configId 不是有效 UUID')
|
||||
}
|
||||
if (!Number.isInteger(config.revision) || config.revision <= 0) {
|
||||
throw new Error('配置 revision 必须为正整数')
|
||||
}
|
||||
for (const field of ['issuedAt', 'appKey', 'appName', 'serverUrl', 'signingKey']) {
|
||||
if (typeof config[field] !== 'string' || !config[field].trim()) {
|
||||
throw new Error(`配置 ${field} 不能为空`)
|
||||
}
|
||||
}
|
||||
if (!config.issuedAt.endsWith('Z') || Number.isNaN(Date.parse(config.issuedAt))) {
|
||||
throw new Error('配置 issuedAt 必须为 RFC3339 UTC 时间')
|
||||
}
|
||||
if (config.expiresAt !== undefined) {
|
||||
const expiresAt = Date.parse(config.expiresAt)
|
||||
if (!config.expiresAt.endsWith('Z') || Number.isNaN(expiresAt)) {
|
||||
throw new Error('配置 expiresAt 必须为 RFC3339 UTC 时间')
|
||||
}
|
||||
if (expiresAt <= Date.now()) throw new Error('配置文件已过期,请从租户平台重新生成')
|
||||
}
|
||||
if (canonicalJson(config) !== plaintext) {
|
||||
throw new Error('配置正文不是规范 canonical JSON')
|
||||
}
|
||||
}
|
||||
|
||||
function decryptConfigAtBuildTime(content, trustedKeys = TRUSTED_CONFIG_KEYS) {
|
||||
const parts = content.trim().split('.')
|
||||
if (parts.length !== 6 || parts[0] !== CONFIG_MAGIC) {
|
||||
throw new Error('仅支持 XUQM-CONFIG-V2;请从租户平台重新下载配置文件')
|
||||
}
|
||||
|
||||
const keyId = parts[1]
|
||||
const publicKeyBase64 = trustedKeys[keyId]
|
||||
if (!publicKeyBase64) throw new Error(`配置签名 keyId 不受信任:${keyId}`)
|
||||
const unsignedToken = parts.slice(0, 5).join('.')
|
||||
const signatureValid = crypto.verify(
|
||||
null,
|
||||
Buffer.from(unsignedToken, 'ascii'),
|
||||
{
|
||||
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(publicKeyBase64, 'base64')]),
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
},
|
||||
decodeBase64Url(parts[5]),
|
||||
)
|
||||
if (!signatureValid) throw new Error('配置文件签名验证失败')
|
||||
|
||||
const salt = decodeBase64Url(parts[2])
|
||||
const iv = decodeBase64Url(parts[3])
|
||||
const encrypted = decodeBase64Url(parts[4])
|
||||
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()])
|
||||
const text = plaintext.toString('utf8')
|
||||
const config = JSON.parse(text)
|
||||
assertConfigPayload(config, text)
|
||||
return config
|
||||
} catch {
|
||||
throw new Error('配置文件解密或内容校验失败')
|
||||
}
|
||||
}
|
||||
|
||||
function findConfigFile(projectRoot) {
|
||||
for (const dir of CONFIG_DIR_CANDIDATES) {
|
||||
const absDir = path.resolve(projectRoot, dir)
|
||||
if (!fs.existsSync(absDir)) continue
|
||||
|
||||
const files = fs.readdirSync(absDir)
|
||||
// 优先 config.xuqmconfig 或 config.xuqm
|
||||
const preferred = files.find(f => f === 'config.xuqmconfig' || f === 'config.xuqm')
|
||||
if (preferred) {
|
||||
return path.join(absDir, preferred)
|
||||
const configDirectory = path.resolve(projectRoot, CONFIG_DIRECTORY_RELATIVE_PATH)
|
||||
if (!fs.existsSync(configDirectory)) return null
|
||||
const candidates = fs
|
||||
.readdirSync(configDirectory, { withFileTypes: true })
|
||||
.filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.xuqmconfig'))
|
||||
.map(entry => path.join(configDirectory, entry.name))
|
||||
.sort()
|
||||
if (candidates.length === 0) return null
|
||||
if (candidates.length > 1) {
|
||||
throw new Error(
|
||||
`[XuqmConfig] ${configDirectory}: 只能存在一个 .xuqmconfig 文件,当前发现 ${candidates.length} 个`,
|
||||
)
|
||||
}
|
||||
// 否则取第一个 .xuqmconfig
|
||||
const fallback = files.find(f => f.endsWith('.xuqmconfig'))
|
||||
if (fallback) {
|
||||
return path.join(absDir, fallback)
|
||||
}
|
||||
}
|
||||
return null
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
function applyBugCollect(metroConfig) {
|
||||
@ -51,56 +153,6 @@ function applyBugCollect(metroConfig) {
|
||||
return metroConfig
|
||||
}
|
||||
|
||||
/** 写入目标文件(仅在缺失或内容不一致时),返回是否发生写入。 */
|
||||
function syncFile(content, destPath, label) {
|
||||
try {
|
||||
if (fs.existsSync(destPath) && fs.readFileSync(destPath, 'utf-8').trim() === content) {
|
||||
return false
|
||||
}
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
fs.writeFileSync(destPath, content, 'utf-8')
|
||||
console.log(`[XuqmConfig] 已同步 .xuqmconfig → ${label}`)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.warn(`[XuqmConfig] 同步 .xuqmconfig 到 ${label} 失败:${e.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单点放置:宿主只在 RN 侧放置一份 .xuqmconfig,构建/运行时自动同步到
|
||||
* 原生位置(Android assets/config,供 XuqmMergedProvider 自动初始化;iOS 资源目录)。
|
||||
* 缺失或内容不一致才复制,幂等。
|
||||
*/
|
||||
function syncNativeConfig(projectRoot, content) {
|
||||
const fileName = 'config.xuqmconfig'
|
||||
|
||||
// Android:原生 ContentProvider 扫描 assets/config 下的 .xuqmconfig
|
||||
if (fs.existsSync(path.resolve(projectRoot, 'android'))) {
|
||||
syncFile(
|
||||
content,
|
||||
path.resolve(projectRoot, 'android/app/src/main/assets/config', fileName),
|
||||
'android assets/config',
|
||||
)
|
||||
}
|
||||
|
||||
// iOS:复制到 app 资源目录(需在 Xcode「Copy Bundle Resources」中引用一次)
|
||||
const iosDir = path.resolve(projectRoot, 'ios')
|
||||
if (fs.existsSync(iosDir)) {
|
||||
try {
|
||||
const appDir = fs
|
||||
.readdirSync(iosDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && fs.existsSync(path.join(iosDir, d.name, 'Info.plist')))
|
||||
.map(d => d.name)[0]
|
||||
if (appDir) {
|
||||
syncFile(content, path.join(iosDir, appDir, 'config', fileName), `ios ${appDir}/config`)
|
||||
}
|
||||
} catch {
|
||||
// 忽略 iOS 同步异常,不阻断打包
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withXuqmConfig(metroConfig) {
|
||||
const projectRoot = metroConfig.projectRoot ?? process.cwd()
|
||||
const configFile = findConfigFile(projectRoot)
|
||||
@ -113,10 +165,14 @@ function withXuqmConfig(metroConfig) {
|
||||
// 读取加密配置内容
|
||||
const encryptedContent = fs.readFileSync(configFile, 'utf-8').trim()
|
||||
|
||||
// 单点放置:自动同步到原生 Android/iOS(缺失或不一致才复制)
|
||||
syncNativeConfig(projectRoot, encryptedContent)
|
||||
let resolvedConfig
|
||||
try {
|
||||
resolvedConfig = decryptConfigAtBuildTime(encryptedContent)
|
||||
} catch (error) {
|
||||
throw new Error(`[XuqmConfig] ${configFile}: ${error.message}`)
|
||||
}
|
||||
|
||||
// 生成临时 TS 文件作为虚拟模块
|
||||
// 生成临时 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,11 +181,11 @@ 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;\nexport const BUILD_OPTIONS = ${JSON.stringify({ bugCollectMode: process.env.XUQM_BUGCOLLECT_MODE ?? 'platform' })} as const;\n`,
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
// 拦截模块解析,将 @xuqm/autoinit-config 指向虚拟文件
|
||||
// 拦截默认空实现,将 @xuqm/rn-common/auto-init-config 指向虚拟文件
|
||||
const existingResolveRequest = metroConfig.resolver?.resolveRequest
|
||||
const existingGetModulesRunBeforeMainModule =
|
||||
metroConfig.serializer?.getModulesRunBeforeMainModule
|
||||
@ -165,4 +221,4 @@ function withXuqmConfig(metroConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { withXuqmConfig }
|
||||
module.exports = { decryptConfigAtBuildTime, findConfigFile, withXuqmConfig }
|
||||
|
||||
@ -8,18 +8,22 @@
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./auto-init-config": "./src/autoInitConfig.ts",
|
||||
"./crypto": "./src/crypto.ts",
|
||||
"./download": "./src/download.ts",
|
||||
"./file-name": "./src/fileName.ts",
|
||||
"./internal": "./src/internal.ts",
|
||||
"./internal-security": "./src/internalSecurity.ts",
|
||||
"./metro": "./metro/index.js",
|
||||
"./package.json": "./package.json",
|
||||
"./secure-cache": "./src/secureCache.ts",
|
||||
"./version": "./src/version.ts"
|
||||
},
|
||||
"metro": "metro/index.js",
|
||||
"files": [
|
||||
"src",
|
||||
"metro"
|
||||
"metro",
|
||||
"security"
|
||||
],
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
@ -31,6 +35,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@noble/ciphers": "2.2.0",
|
||||
"@noble/curves": "2.2.0",
|
||||
"@noble/hashes": "2.2.0",
|
||||
"@types/semver": "7.7.1",
|
||||
"react-native-blob-util": "0.24.10",
|
||||
@ -38,18 +43,18 @@
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18.0.0",
|
||||
"react-native": ">=0.76.0",
|
||||
"@react-native-async-storage/async-storage": ">=1.21.0",
|
||||
"axios": ">=1.0.0"
|
||||
"axios": ">=1.0.0",
|
||||
"react": ">=18.0.0",
|
||||
"react-native": ">=0.76.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "catalog:",
|
||||
"@types/react": "^19.2.14",
|
||||
"@react-native-async-storage/async-storage": "^3.1.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"axios": "^1.18.0",
|
||||
"react": "19.2.7",
|
||||
"react-native": "0.86.0",
|
||||
"tsx": "^4.23.1"
|
||||
"tsx": "^4.23.1",
|
||||
"typescript": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
5
packages/common/security/base64.d.ts
vendored
普通文件
5
packages/common/security/base64.d.ts
vendored
普通文件
@ -0,0 +1,5 @@
|
||||
export function decodeBase64(value: string): Uint8Array
|
||||
export function encodeBase64(
|
||||
value: Uint8Array,
|
||||
options?: { urlSafe?: boolean; padding?: boolean },
|
||||
): string
|
||||
@ -0,0 +1,85 @@
|
||||
'use strict'
|
||||
|
||||
const STANDARD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
|
||||
const URL_SAFE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
|
||||
|
||||
/**
|
||||
* 纯 JS Base64 编码,默认输出带填充的标准 Base64。
|
||||
* 协议调用方必须显式声明 URL-safe 与是否保留填充,避免现场字符串替换。
|
||||
*/
|
||||
function encodeBase64(value, options = {}) {
|
||||
if (!(value instanceof Uint8Array)) throw new TypeError('Base64 value must be Uint8Array')
|
||||
const alphabet = options.urlSafe ? URL_SAFE_ALPHABET : STANDARD_ALPHABET
|
||||
const padding = options.padding !== false
|
||||
let output = ''
|
||||
for (let offset = 0; offset < value.length; offset += 3) {
|
||||
const first = value[offset]
|
||||
const hasSecond = offset + 1 < value.length
|
||||
const hasThird = offset + 2 < value.length
|
||||
const second = hasSecond ? value[offset + 1] : 0
|
||||
const third = hasThird ? value[offset + 2] : 0
|
||||
output += alphabet[first >>> 2]
|
||||
output += alphabet[((first & 0x03) << 4) | (second >>> 4)]
|
||||
if (hasSecond) {
|
||||
output += alphabet[((second & 0x0f) << 2) | (third >>> 6)]
|
||||
} else if (padding) {
|
||||
output += '='
|
||||
}
|
||||
if (hasThird) {
|
||||
output += alphabet[third & 0x3f]
|
||||
} else if (padding) {
|
||||
output += '='
|
||||
}
|
||||
}
|
||||
return output
|
||||
}
|
||||
|
||||
/**
|
||||
* 严格解码标准 Base64 或 Base64URL,不依赖浏览器/Hermes 的 atob。
|
||||
* 接受有填充或无填充形式,但拒绝混合字母表、非法长度、非法填充和非零尾随位。
|
||||
*/
|
||||
function decodeBase64(value) {
|
||||
if (typeof value !== 'string') throw new TypeError('Base64 value must be a string')
|
||||
if (value.length === 0) return new Uint8Array(0)
|
||||
if (/[^A-Za-z0-9+/_=-]/.test(value)) throw new Error('Base64 contains invalid characters')
|
||||
if (/[+\/]/.test(value) && /[-_]/.test(value)) {
|
||||
throw new Error('Base64 cannot mix standard and URL-safe alphabets')
|
||||
}
|
||||
|
||||
const firstPadding = value.indexOf('=')
|
||||
const data = firstPadding === -1 ? value : value.slice(0, firstPadding)
|
||||
const padding = firstPadding === -1 ? '' : value.slice(firstPadding)
|
||||
if (padding !== '' && padding !== '=' && padding !== '==') {
|
||||
throw new Error('Base64 padding is invalid')
|
||||
}
|
||||
if (data.includes('=')) throw new Error('Base64 padding is invalid')
|
||||
const remainder = data.length % 4
|
||||
if (remainder === 1) throw new Error('Base64 length is invalid')
|
||||
const expectedPadding = remainder === 0 ? 0 : 4 - remainder
|
||||
if (padding.length !== 0 && padding.length !== expectedPadding) {
|
||||
throw new Error('Base64 padding length is invalid')
|
||||
}
|
||||
|
||||
const normalized = data.replace(/-/g, '+').replace(/_/g, '/')
|
||||
const outputLength = Math.floor((normalized.length * 6) / 8)
|
||||
const output = new Uint8Array(outputLength)
|
||||
let accumulator = 0
|
||||
let bitCount = 0
|
||||
let outputIndex = 0
|
||||
for (const character of normalized) {
|
||||
const sextet = STANDARD_ALPHABET.indexOf(character)
|
||||
if (sextet < 0) throw new Error('Base64 contains invalid characters')
|
||||
accumulator = (accumulator << 6) | sextet
|
||||
bitCount += 6
|
||||
if (bitCount >= 8) {
|
||||
bitCount -= 8
|
||||
output[outputIndex] = (accumulator >>> bitCount) & 0xff
|
||||
outputIndex += 1
|
||||
accumulator &= (1 << bitCount) - 1
|
||||
}
|
||||
}
|
||||
if (accumulator !== 0) throw new Error('Base64 has non-zero trailing bits')
|
||||
return output
|
||||
}
|
||||
|
||||
module.exports = { decodeBase64, encodeBase64 }
|
||||
1
packages/common/security/canonical-json.d.ts
vendored
普通文件
1
packages/common/security/canonical-json.d.ts
vendored
普通文件
@ -0,0 +1 @@
|
||||
export function canonicalJson(value: unknown): string
|
||||
@ -0,0 +1,46 @@
|
||||
'use strict'
|
||||
|
||||
/**
|
||||
* 安全协议唯一使用的 canonical JSON。对象键按字典序排列,null/undefined 字段省略;
|
||||
* 非 JSON 协议值、数组空位和不确定的对象形态直接拒绝。
|
||||
*/
|
||||
function canonicalJson(value) {
|
||||
if (value === null) return 'null'
|
||||
if (typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value)
|
||||
if (typeof value === 'number') {
|
||||
if (!Number.isFinite(value)) throw new TypeError('Canonical JSON requires finite numbers')
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
if (!Object.prototype.hasOwnProperty.call(value, index) || value[index] === undefined) {
|
||||
throw new TypeError('Canonical JSON arrays cannot contain holes or undefined')
|
||||
}
|
||||
}
|
||||
return `[${value.map(canonicalJson).join(',')}]`
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
const prototype = Object.getPrototypeOf(value)
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
throw new TypeError('Canonical JSON only supports plain objects')
|
||||
}
|
||||
const keys = Reflect.ownKeys(value)
|
||||
if (keys.some(key => typeof key === 'symbol')) {
|
||||
throw new TypeError('Canonical JSON does not support symbol keys')
|
||||
}
|
||||
for (const key of keys) {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
||||
if (!descriptor?.enumerable || !('value' in descriptor)) {
|
||||
throw new TypeError('Canonical JSON only supports enumerable data properties')
|
||||
}
|
||||
}
|
||||
return `{${keys
|
||||
.filter(key => value[key] !== null && value[key] !== undefined)
|
||||
.sort()
|
||||
.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
||||
.join(',')}}`
|
||||
}
|
||||
throw new TypeError(`Canonical JSON does not support ${typeof value}`)
|
||||
}
|
||||
|
||||
module.exports = { canonicalJson }
|
||||
@ -0,0 +1,3 @@
|
||||
{
|
||||
"xuqm-config-dev-2026-07": "Ft9MBN8jNwCfalex4wg7kuy2DRAbXkGxZyA/Jaz+6YA="
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
import type { AxiosError, AxiosResponse } from 'axios'
|
||||
import { isAxiosError } from 'axios'
|
||||
import type { z } from 'zod'
|
||||
import type { RequestError } from './errors'
|
||||
|
||||
type SafeIssue = {
|
||||
code: string
|
||||
@ -16,15 +18,29 @@ export type SafeApiDiagnostic = {
|
||||
type: 'AxiosError' | 'Response' | 'ValidationError'
|
||||
}
|
||||
|
||||
export type SafeApiErrorReport = {
|
||||
diagnostic?: SafeApiDiagnostic
|
||||
requestType: RequestError['type']
|
||||
}
|
||||
|
||||
/**
|
||||
* 网络诊断只能描述请求形态,不得复制请求/响应 body、headers 或完整 URL。
|
||||
* 这些字段可能包含手机号、userId、sessionId、签名和其它业务数据。
|
||||
*/
|
||||
export function safeRequestPath(input?: string): string | undefined {
|
||||
if (!input) return undefined
|
||||
try {
|
||||
return new URL(input, 'https://xuqm.invalid').pathname
|
||||
} catch {
|
||||
return input.split(/[?#]/, 1)[0]
|
||||
}
|
||||
}
|
||||
|
||||
function requestShape(response: AxiosResponse<unknown>) {
|
||||
return {
|
||||
httpStatus: response.status,
|
||||
method: response.config.method?.toUpperCase(),
|
||||
path: response.config.url,
|
||||
path: safeRequestPath(response.config.url),
|
||||
}
|
||||
}
|
||||
|
||||
@ -53,6 +69,42 @@ export function createAxiosDiagnostic(error: AxiosError): SafeApiDiagnostic {
|
||||
code: error.code,
|
||||
httpStatus: error.response?.status,
|
||||
method: error.config?.method?.toUpperCase(),
|
||||
path: error.config?.url,
|
||||
path: safeRequestPath(error.config?.url),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 非 axios 异常只允许记录构造名与截断消息。
|
||||
* 不接触 cause、stack、config 或任何可能持有业务数据的对象。
|
||||
*/
|
||||
export function createUnexpectedDiagnostic(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return `${error.name}: ${error.message.slice(0, 200)}`
|
||||
}
|
||||
return typeof error
|
||||
}
|
||||
|
||||
type ValidationCause = {
|
||||
issues: z.ZodIssue[]
|
||||
response: AxiosResponse<unknown>
|
||||
}
|
||||
|
||||
function isValidationCause(value: unknown): value is ValidationCause {
|
||||
if (!value || typeof value !== 'object') return false
|
||||
const candidate = value as Partial<ValidationCause>
|
||||
return Array.isArray(candidate.issues) && Boolean(candidate.response?.config)
|
||||
}
|
||||
|
||||
/**
|
||||
* 全局错误采集只接收不可逆的安全摘要,绝不暴露 RequestError.cause。
|
||||
* cause 可能持有 Axios config、headers、请求体和完整响应,只能留在调用现场。
|
||||
*/
|
||||
export function createSafeApiErrorReport(error: RequestError): SafeApiErrorReport {
|
||||
const cause = error.cause
|
||||
const diagnostic = isValidationCause(cause)
|
||||
? createValidationDiagnostic(cause.response, cause.issues)
|
||||
: isAxiosError(cause)
|
||||
? createAxiosDiagnostic(cause)
|
||||
: undefined
|
||||
return { diagnostic, requestType: error.type }
|
||||
}
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
export class RequestError<TData = unknown> extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly type: 'Cancel' | 'ValidationError' | 'AxiosError' | 'OtherError',
|
||||
public readonly type:
|
||||
'Cancel' | 'ValidationError' | 'BusinessError' | 'AxiosError' | 'OtherError',
|
||||
public readonly cause?: unknown,
|
||||
public readonly response?: { data?: TData; status?: number },
|
||||
) {
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { RequestError } from './errors'
|
||||
import { createSafeApiErrorReport, type SafeApiErrorReport } from './diagnostics'
|
||||
|
||||
type ApiErrorHandler = (error: RequestError) => void
|
||||
type ApiErrorHandler = (report: SafeApiErrorReport) => void
|
||||
|
||||
let _handler: ApiErrorHandler | null = null
|
||||
|
||||
@ -11,5 +12,5 @@ export function setGlobalApiErrorHandler(handler: ApiErrorHandler): void {
|
||||
|
||||
/** 内部调用:在 useRequest 的 catch 分支中触发。*/
|
||||
export function _notifyApiError(error: RequestError): void {
|
||||
_handler?.(error)
|
||||
_handler?.(createSafeApiErrorReport(error))
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@ export { useApi } from './useApi'
|
||||
export { usePageApi } from './usePageApi'
|
||||
export { RequestError } from './errors'
|
||||
export { setGlobalApiErrorHandler } from './globalErrorHandler'
|
||||
export type { SafeApiDiagnostic, SafeApiErrorReport } from './diagnostics'
|
||||
export type { RequestOptions } from './useRequest'
|
||||
export type { ApiMethod } from './useApi'
|
||||
export type { PageOptions } from './usePageApi'
|
||||
|
||||
@ -10,12 +10,13 @@ import { z } from 'zod'
|
||||
import {
|
||||
createAxiosDiagnostic,
|
||||
createResponseDiagnostic,
|
||||
createUnexpectedDiagnostic,
|
||||
createValidationDiagnostic,
|
||||
} from './diagnostics'
|
||||
import { RequestError } from './errors'
|
||||
import { type RequestOptions, useRequest, ValidationError } from './useRequest'
|
||||
|
||||
const SUCCESS_STATUS = /^0$/
|
||||
const SUCCESS_STATUS = '0'
|
||||
|
||||
type ApiResponse<T> = {
|
||||
status: string
|
||||
@ -42,12 +43,14 @@ export const useApi = <S extends z.ZodTypeAny, T extends z.infer<S> = z.infer<S>
|
||||
requestInterceptors: AxiosInterceptorManager<AxiosRequestConfig<unknown>>
|
||||
responseInterceptors: AxiosInterceptorManager<AxiosResponse<ApiResponse<T>, unknown>>
|
||||
} => {
|
||||
// schema 只校验响应结构,不强制 status==='0';
|
||||
// 业务状态码非 '0' 属于正常业务响应,由 transformedFetchAsync 统一处理。
|
||||
const responseSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
status: z.string().regex(SUCCESS_STATUS),
|
||||
status: z.string(),
|
||||
message: z.string(),
|
||||
data: validationSchema ?? z.unknown(),
|
||||
data: (validationSchema ?? z.unknown()).optional(),
|
||||
}),
|
||||
[validationSchema],
|
||||
)
|
||||
@ -106,12 +109,6 @@ export const useApi = <S extends z.ZodTypeAny, T extends z.infer<S> = z.infer<S>
|
||||
}
|
||||
|
||||
const firstIssue = e.issues[0]
|
||||
if (firstIssue.path.length > 0 && firstIssue.path[0] === 'status') {
|
||||
return Promise.reject(
|
||||
new RequestError(e.response.data?.message ?? '接口返回失败', 'ValidationError', e),
|
||||
)
|
||||
}
|
||||
|
||||
return Promise.reject(new RequestError(firstIssue.message, 'ValidationError', e))
|
||||
}
|
||||
|
||||
@ -123,7 +120,13 @@ export const useApi = <S extends z.ZodTypeAny, T extends z.infer<S> = z.infer<S>
|
||||
return Promise.reject(new RequestError('网络请求失败', 'AxiosError', thrownError))
|
||||
}
|
||||
|
||||
console.error('[XuqmAPI] unexpected request failure')
|
||||
console.error(
|
||||
'[XuqmAPI] unexpected request failure',
|
||||
createUnexpectedDiagnostic(thrownError),
|
||||
)
|
||||
if (thrownError instanceof Error && thrownError.stack) {
|
||||
console.error('[XuqmAPI] unexpected request failure stack', thrownError.stack)
|
||||
}
|
||||
return Promise.reject(new RequestError('网络请求失败', 'OtherError', thrownError))
|
||||
},
|
||||
)
|
||||
@ -136,7 +139,13 @@ export const useApi = <S extends z.ZodTypeAny, T extends z.infer<S> = z.infer<S>
|
||||
async (nextConfig?: AxiosRequestConfig<D>) => {
|
||||
try {
|
||||
const requestResponse = await fetchAsync(nextConfig)
|
||||
return (requestResponse as unknown as AxiosResponse<ApiResponse<T>>).data.data
|
||||
const body = (requestResponse as unknown as AxiosResponse<ApiResponse<T>>).data
|
||||
// 业务状态码非 '0' 时,抛出 RequestError 而非 ValidationError,
|
||||
// 避免业务错误被误报为"校验失败"并影响调用方。
|
||||
if (body.status !== SUCCESS_STATUS) {
|
||||
throw new RequestError(body.message || '接口返回失败', 'BusinessError', body)
|
||||
}
|
||||
return body.data
|
||||
} catch (requestError) {
|
||||
return Promise.reject(requestError)
|
||||
}
|
||||
|
||||
@ -15,19 +15,27 @@ export interface RequestOptions {
|
||||
loadingDelay?: number
|
||||
/** 请求标签,用于日志或错误追踪。*/
|
||||
tag?: string
|
||||
/** true = 在响应拦截器中打印完整响应。*/
|
||||
/** true = 在响应拦截器中打印不含请求体、响应体和 headers 的安全摘要。*/
|
||||
log?: boolean
|
||||
}
|
||||
|
||||
// ─── 内部 ValidationError(对齐 @szyx-mobile/use-axios 的 ValidationError)────
|
||||
|
||||
export class ValidationError<T = unknown, D = unknown> extends z.ZodError {
|
||||
// 不能 extends z.ZodError:zod v4 的 ZodError 是 $constructor 工厂函数,
|
||||
// 其自定义 Symbol.hasInstance 要求实例已初始化 _zod.traits。Babel 转译的子类
|
||||
// 在 super() 之前先执行 _classCallCheck(this, ValidationError),此时 _zod 尚不
|
||||
// 存在,instanceof 返回 false,直接抛 “Cannot call a class as a function”,
|
||||
// 把真实的校验失败掩盖成非预期错误。
|
||||
export class ValidationError<T = unknown, D = unknown> extends Error {
|
||||
public readonly issues: z.ZodIssue[]
|
||||
|
||||
constructor(
|
||||
issues: z.ZodIssue[],
|
||||
public readonly response: AxiosResponse<T, D>,
|
||||
) {
|
||||
super(issues)
|
||||
super(issues[0]?.message ?? 'ValidationError')
|
||||
this.name = 'ValidationError'
|
||||
this.issues = issues
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,20 +8,31 @@
|
||||
* 2. metro.config.js 使用 withXuqmConfig(metroConfig) 包装
|
||||
* 3. Metro 在业务入口前加载本模块并完成一次共享初始化
|
||||
*
|
||||
* 如果配置未找到(require 失败),静默跳过,不崩溃。
|
||||
* 未使用 withXuqmConfig 时解析到包内空实现,静默跳过且仍可完成 Metro Bundle。
|
||||
*/
|
||||
|
||||
import { isInitialized } from './config'
|
||||
import { _initializeFromConfigFile } from './sdk'
|
||||
import type { DecryptedConfig } from './crypto'
|
||||
import { _initializeFromResolvedConfig } from './sdk'
|
||||
|
||||
let _autoInitAttempted = false
|
||||
|
||||
function tryRequireConfig(): string | null {
|
||||
interface AutoInitPayload {
|
||||
config: DecryptedConfig
|
||||
buildOptions?: { bugCollectMode?: 'platform' | 'disabled' }
|
||||
}
|
||||
|
||||
function tryRequireConfig(): AutoInitPayload | 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/rn-common/auto-init-config') as {
|
||||
RESOLVED_CONFIG?: DecryptedConfig
|
||||
default?: DecryptedConfig
|
||||
BUILD_OPTIONS?: { bugCollectMode?: 'platform' | 'disabled'; buildId?: string }
|
||||
}
|
||||
const config = mod?.RESOLVED_CONFIG ?? mod?.default ?? null
|
||||
return config ? { config, buildOptions: mod.BUILD_OPTIONS } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@ -38,10 +49,13 @@ export function tryAutoInit(): void {
|
||||
_autoInitAttempted = true
|
||||
if (isInitialized()) return
|
||||
|
||||
const encrypted = tryRequireConfig()
|
||||
if (!encrypted) return
|
||||
const payload = tryRequireConfig()
|
||||
if (!payload) return
|
||||
|
||||
void _initializeFromConfigFile(encrypted, { debug: __DEV__ }).catch(() => undefined)
|
||||
void _initializeFromResolvedConfig(payload.config, {
|
||||
debug: __DEV__,
|
||||
bugCollectMode: payload.buildOptions?.bugCollectMode,
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
tryAutoInit()
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* common-only 宿主的真实空实现。withXuqmConfig 会在 Metro 解析阶段把该公开子路径
|
||||
* 精确替换为构建期配置模块;未使用 Metro 包装器时仍可正常完成静态依赖解析。
|
||||
*/
|
||||
export const RESOLVED_CONFIG = undefined
|
||||
export const BUILD_OPTIONS = undefined
|
||||
@ -0,0 +1,28 @@
|
||||
export interface XuqmBundleIdentity {
|
||||
buildId: string
|
||||
moduleId: string
|
||||
moduleVersion: string
|
||||
bundleHash: string
|
||||
}
|
||||
|
||||
export type XuqmBundleIdentityProvider = (stacktrace: string) => Promise<XuqmBundleIdentity | null>
|
||||
|
||||
let provider: { owner: string; resolve: XuqmBundleIdentityProvider } | null = null
|
||||
|
||||
/** @internal update 是 Bundle 状态唯一事实源,只允许注册一个解析器。 */
|
||||
export function _registerBundleIdentityProvider(
|
||||
owner: string,
|
||||
next: XuqmBundleIdentityProvider,
|
||||
): void {
|
||||
if (provider && provider.owner !== owner) {
|
||||
throw new Error('[XuqmSDK] A bundle identity provider is already registered.')
|
||||
}
|
||||
provider = { owner, resolve: next }
|
||||
}
|
||||
|
||||
/** @internal 无法精确归属时返回 null,调用方不得猜测 latest 或 app Bundle。 */
|
||||
export async function _resolveBundleIdentity(
|
||||
stacktrace: string,
|
||||
): Promise<XuqmBundleIdentity | null> {
|
||||
return provider ? provider.resolve(stacktrace) : null
|
||||
}
|
||||
@ -1,11 +1,23 @@
|
||||
export interface XuqmInitOptions {
|
||||
/** @internal 仅由 Metro 已验签配置驱动,不属于宿主公开契约。 */
|
||||
export interface InternalInitOptions {
|
||||
appKey: string
|
||||
platformUrl?: string // 不传则使用内置默认公有平台地址
|
||||
debug?: boolean
|
||||
/** 构建工具只允许将 BugCollect 降级为 disabled,不能反向覆盖平台关闭状态。 */
|
||||
bugCollectMode?: 'platform' | 'disabled'
|
||||
/** 自动配置内部使用。 */
|
||||
packageName?: string
|
||||
/** @internal 远程配置来源,用于扩展判断缓存是否有权覆盖服务端停用锁。 */
|
||||
configSource?: 'remote' | 'cache'
|
||||
}
|
||||
|
||||
export interface XuqmConfig {
|
||||
appKey: string
|
||||
/** 已验签配置中的宿主包名,用于绑定插件签名,禁止由业务运行时改写。 */
|
||||
packageName: string
|
||||
/** 租户平台地址:配置、更新、插件发布等 Xuqm 平台接口统一使用该地址。 */
|
||||
platformUrl: string
|
||||
/** 业务扩展服务地址,由租户平台远程配置返回,不能代替 platformUrl。 */
|
||||
apiUrl: string
|
||||
imWsUrl: string
|
||||
fileServiceUrl: string
|
||||
@ -16,10 +28,26 @@ export interface XuqmConfig {
|
||||
// 崩溃采集服务(rn-bugcollect 使用)
|
||||
bugCollectApiUrl: string
|
||||
bugCollectEnabled: boolean
|
||||
/** Update 服务必须由平台明确开启;缺失时安全关闭。 */
|
||||
updateEnabled: boolean
|
||||
/** Update 灰度检查是否要求共享登录态;由租户平台动态配置。 */
|
||||
updateRequiresLogin: boolean
|
||||
configurationSource: 'remote' | 'cache'
|
||||
// 请求签名密钥(从配置文件读取)
|
||||
signingKey?: string
|
||||
}
|
||||
|
||||
export type XuqmInitializationState = 'idle' | 'initializing' | 'ready' | 'degraded' | 'failed'
|
||||
|
||||
export interface XuqmInitializationSnapshot {
|
||||
state: XuqmInitializationState
|
||||
source?: 'remote' | 'cache'
|
||||
error?: {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface XuqmUserInfo {
|
||||
userId: string
|
||||
userSig?: string // IM 登录凭证;未开通 IM 时可不传
|
||||
@ -36,13 +64,17 @@ export interface XuqmLoginOptions extends XuqmUserInfo {
|
||||
|
||||
export type XuqmUserInfoHandler = (info: XuqmUserInfo | null) => void | Promise<void>
|
||||
export type XuqmInitializationHandler = (config: Readonly<XuqmConfig>) => void | Promise<void>
|
||||
export type XuqmPrivacyConsentHandler = (consented: boolean) => void | Promise<void>
|
||||
|
||||
// ─── Internal state ────────────────────────────────────────────────────────────
|
||||
|
||||
let _config: XuqmConfig | null = null
|
||||
let _userInfo: XuqmUserInfo | null = null
|
||||
let _privacyConsented = false
|
||||
let _initializationSnapshot: XuqmInitializationSnapshot = { state: 'idle' }
|
||||
const _userInfoHandlers = new Map<string, XuqmUserInfoHandler>()
|
||||
const _initializationHandlers = new Map<string, XuqmInitializationHandler>()
|
||||
const _privacyConsentHandlers = new Map<string, XuqmPrivacyConsentHandler>()
|
||||
|
||||
// ─── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -55,16 +87,21 @@ export interface XuqmRemoteConfig {
|
||||
pushEnabled?: boolean
|
||||
bugCollectApiUrl?: string
|
||||
bugCollectEnabled?: boolean
|
||||
updateEnabled?: boolean
|
||||
updateRequiresLogin?: boolean
|
||||
}
|
||||
|
||||
export function initConfigFromRemote(
|
||||
options: XuqmInitOptions,
|
||||
options: InternalInitOptions,
|
||||
remote: XuqmRemoteConfig,
|
||||
signingKey?: string,
|
||||
): void {
|
||||
const platformUrl = (options.platformUrl ?? '').replace(/\/$/, '')
|
||||
const apiUrl = remote.apiUrl ?? remote.imApiUrl ?? ''
|
||||
_config = {
|
||||
appKey: options.appKey,
|
||||
packageName: options.packageName?.trim() ?? '',
|
||||
platformUrl,
|
||||
apiUrl,
|
||||
imWsUrl: remote.imWsUrl ?? '',
|
||||
fileServiceUrl: remote.fileServiceUrl ?? apiUrl,
|
||||
@ -72,13 +109,19 @@ export function initConfigFromRemote(
|
||||
imEnabled: remote.imEnabled ?? !!remote.imWsUrl,
|
||||
pushEnabled: remote.pushEnabled ?? true,
|
||||
bugCollectApiUrl: remote.bugCollectApiUrl ?? '',
|
||||
bugCollectEnabled: remote.bugCollectEnabled ?? false,
|
||||
bugCollectEnabled: options.bugCollectMode !== 'disabled' && (remote.bugCollectEnabled ?? false),
|
||||
updateEnabled: remote.updateEnabled ?? false,
|
||||
// 服务端未下发匿名策略时按安全默认禁止匿名更新检查。
|
||||
updateRequiresLogin: remote.updateRequiresLogin ?? true,
|
||||
configurationSource: options.configSource ?? 'remote',
|
||||
signingKey,
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): XuqmConfig {
|
||||
if (!_config) throw new Error('[XuqmSDK] Not initialized — call XuqmSDK.initialize() first.')
|
||||
if (!_config) {
|
||||
throw new Error('[XuqmSDK] Not initialized — verify the signed config and Metro integration.')
|
||||
}
|
||||
return _config
|
||||
}
|
||||
|
||||
@ -94,6 +137,42 @@ export function isInitialized(): boolean {
|
||||
return _config !== null
|
||||
}
|
||||
|
||||
export function getInitializationSnapshot(): Readonly<XuqmInitializationSnapshot> {
|
||||
return _initializationSnapshot
|
||||
}
|
||||
|
||||
export function _setInitializationSnapshot(snapshot: XuqmInitializationSnapshot): void {
|
||||
_initializationSnapshot = snapshot
|
||||
}
|
||||
|
||||
export function hasPrivacyConsent(): boolean {
|
||||
return _privacyConsented
|
||||
}
|
||||
|
||||
export async function _setPrivacyConsent(consented: boolean): Promise<void> {
|
||||
if (_privacyConsented === consented) return
|
||||
_privacyConsented = consented
|
||||
const results = await Promise.allSettled(
|
||||
Array.from(_privacyConsentHandlers.values(), handler => Promise.resolve(handler(consented))),
|
||||
)
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
console.warn('[XuqmSDK] Privacy consent handler failed:', result.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function _registerPrivacyConsentHandler(
|
||||
name: string,
|
||||
handler: XuqmPrivacyConsentHandler,
|
||||
): () => void {
|
||||
if (_privacyConsentHandlers.has(name)) {
|
||||
throw new Error(`[XuqmSDK] Privacy consent handler "${name}" is already registered.`)
|
||||
}
|
||||
_privacyConsentHandlers.set(name, handler)
|
||||
return () => _privacyConsentHandlers.delete(name)
|
||||
}
|
||||
|
||||
export function _registerInitializationHandler(
|
||||
name: string,
|
||||
handler: XuqmInitializationHandler,
|
||||
@ -157,11 +236,11 @@ export async function _setUserInfo(info: XuqmUserInfo | null): Promise<void> {
|
||||
}
|
||||
}),
|
||||
)
|
||||
const failures = results
|
||||
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
.map(result => String(result.reason))
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`[XuqmSDK] Session propagation failed: ${failures.join('; ')}`)
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
// 扩展登录失败不能反向破坏宿主已经完成的业务登录。
|
||||
console.warn('[XuqmSDK] Session propagation failed:', result.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,77 +1,30 @@
|
||||
import { gcm } from '@noble/ciphers/aes.js'
|
||||
import { hmac } from '@noble/hashes/hmac.js'
|
||||
import { pbkdf2Async } from '@noble/hashes/pbkdf2.js'
|
||||
import { sha256 } from '@noble/hashes/sha2.js'
|
||||
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'
|
||||
|
||||
const MAGIC_CONFIG = 'XUQM-CONFIG-V1'
|
||||
const PASSPHRASES = {
|
||||
[MAGIC_CONFIG]: 'xuqm-config-file-v1.2026.internal',
|
||||
} as const
|
||||
const PBKDF2_ITERATIONS = 120_000
|
||||
const AES_KEY_BYTES = 32
|
||||
|
||||
export type XuqmEncryptedFileMagic = keyof typeof PASSPHRASES
|
||||
import { encodeBase64 } from '../security/base64.js'
|
||||
|
||||
export interface DecryptedConfig {
|
||||
schemaVersion: 2
|
||||
configId: string
|
||||
revision: number
|
||||
appKey: string
|
||||
appName?: string
|
||||
companyName?: string
|
||||
baseUrl?: string
|
||||
serverUrl?: string
|
||||
signingKey?: string
|
||||
issuedAt?: string
|
||||
appName: string
|
||||
packageName?: string
|
||||
iosBundleId?: string
|
||||
harmonyBundleName?: string
|
||||
serverUrl: string
|
||||
signingKey: string
|
||||
issuedAt: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string): Uint8Array {
|
||||
const padded =
|
||||
value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4)
|
||||
const binary = atob(padded)
|
||||
return Uint8Array.from({ length: binary.length }, (_, index) => binary.charCodeAt(index))
|
||||
/** Encode arbitrary binary data without exceeding Hermes argument-count limits. */
|
||||
export function bytesToBase64(value: Uint8Array): string {
|
||||
return encodeBase64(value)
|
||||
}
|
||||
|
||||
function base64UrlEncode(value: Uint8Array): string {
|
||||
let binary = ''
|
||||
value.forEach(byte => {
|
||||
binary += String.fromCharCode(byte)
|
||||
})
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode UTF-8 without relying on the optional TextDecoder implementation in
|
||||
* the React Native runtime. Hermes versions and third-party polyfills have
|
||||
* returned invalid text for Uint8Array views in real applications.
|
||||
*/
|
||||
function utf8BytesToString(value: Uint8Array): string {
|
||||
let encoded = ''
|
||||
for (const byte of value) {
|
||||
encoded += `%${byte.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
return decodeURIComponent(encoded)
|
||||
}
|
||||
|
||||
function parseEncryptedFile(content: string): {
|
||||
magic: XuqmEncryptedFileMagic
|
||||
salt: Uint8Array
|
||||
iv: Uint8Array
|
||||
ciphertext: Uint8Array
|
||||
} {
|
||||
const parts = content.trim().split('.')
|
||||
const magic = parts[0] as XuqmEncryptedFileMagic
|
||||
if (parts.length !== 4 || !(magic in PASSPHRASES)) {
|
||||
throw new Error('[XuqmSDK] Invalid config file format')
|
||||
}
|
||||
|
||||
const salt = base64UrlDecode(parts[1])
|
||||
const iv = base64UrlDecode(parts[2])
|
||||
const ciphertext = base64UrlDecode(parts[3])
|
||||
if (salt.length === 0 || iv.length !== 12 || ciphertext.length <= 16) {
|
||||
throw new Error('[XuqmSDK] Invalid config cryptographic parameters')
|
||||
}
|
||||
|
||||
return { magic, salt, iv, ciphertext }
|
||||
return encodeBase64(value, { urlSafe: true, padding: false })
|
||||
}
|
||||
|
||||
export function hmacSha256Base64Url(secret: string, data: string): string {
|
||||
@ -82,30 +35,3 @@ export function hmacSha256Base64Url(secret: string, data: string): string {
|
||||
export function sha256Hex(value: string | Uint8Array): string {
|
||||
return bytesToHex(sha256(typeof value === 'string' ? utf8ToBytes(value) : value))
|
||||
}
|
||||
|
||||
export async function decryptXuqmFile<T extends object = DecryptedConfig>(
|
||||
content: string,
|
||||
): Promise<T> {
|
||||
const { magic, salt, iv, ciphertext } = parseEncryptedFile(content)
|
||||
const key = await pbkdf2Async(sha256, PASSPHRASES[magic], salt, {
|
||||
c: PBKDF2_ITERATIONS,
|
||||
dkLen: AES_KEY_BYTES,
|
||||
})
|
||||
|
||||
let plaintext: Uint8Array
|
||||
try {
|
||||
plaintext = gcm(key, iv).decrypt(ciphertext)
|
||||
} catch {
|
||||
throw new Error('[XuqmSDK] Config decryption failed')
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(utf8BytesToString(plaintext)) as T
|
||||
} catch {
|
||||
throw new Error('[XuqmSDK] Config content is not valid JSON')
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptConfigFile(content: string): Promise<DecryptedConfig> {
|
||||
return decryptXuqmFile<DecryptedConfig>(content)
|
||||
}
|
||||
|
||||
20
packages/common/src/errors.ts
普通文件
20
packages/common/src/errors.ts
普通文件
@ -0,0 +1,20 @@
|
||||
export type XuqmErrorCode =
|
||||
| 'XUQM_NOT_READY'
|
||||
| 'XUQM_NOT_LOGGED_IN'
|
||||
| 'XUQM_CONFIG_INVALID'
|
||||
| 'XUQM_CONFIG_EXPIRED'
|
||||
| 'XUQM_CONFIG_REVOKED'
|
||||
| 'XUQM_SERVICE_DISABLED'
|
||||
|
||||
/** SDK 公共结构化错误。宿主可以按 code 决定提示方式,无需解析错误文案。 */
|
||||
export class XuqmError extends Error {
|
||||
readonly name = 'XuqmError'
|
||||
|
||||
constructor(
|
||||
public readonly code: XuqmErrorCode,
|
||||
message: string,
|
||||
public readonly cause?: unknown,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
|
||||
import { getOptionalConfig, getUserId } from './config'
|
||||
import { hmacSha256Base64Url } from './crypto'
|
||||
import { safeRequestPath } from './api/diagnostics'
|
||||
|
||||
const TOKEN_KEY = '@xuqm:token'
|
||||
let _baseUrl = DEFAULT_TENANT_PLATFORM_URL
|
||||
@ -44,6 +45,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}`
|
||||
@ -79,25 +84,44 @@ export async function apiRequest<T>(
|
||||
if (_debugHttp) {
|
||||
console.debug('[XuqmSDK][HTTP] request', {
|
||||
method: options.method ?? 'GET',
|
||||
url,
|
||||
path: safeRequestPath(url),
|
||||
hasBody: Boolean(options.body),
|
||||
})
|
||||
}
|
||||
|
||||
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 }))
|
||||
if (_debugHttp) {
|
||||
console.debug('[XuqmSDK][HTTP] response', {
|
||||
url,
|
||||
path: safeRequestPath(url),
|
||||
status: res.status,
|
||||
ok: false,
|
||||
message: (err as { message?: string }).message ?? res.statusText,
|
||||
})
|
||||
}
|
||||
throw new Error((err as { message?: string }).message ?? `HTTP ${res.status}`)
|
||||
@ -106,7 +130,7 @@ export async function apiRequest<T>(
|
||||
const json = await res.json()
|
||||
if (_debugHttp) {
|
||||
console.debug('[XuqmSDK][HTTP] response', {
|
||||
url,
|
||||
path: safeRequestPath(url),
|
||||
status: res.status,
|
||||
ok: true,
|
||||
})
|
||||
|
||||
@ -1,10 +1,27 @@
|
||||
export { XuqmSDK } from './sdk'
|
||||
export type { XuqmInitOptions, XuqmConfig, XuqmLoginOptions, XuqmUserInfo } from './config'
|
||||
export { getConfig, isInitialized, getUserId, getUserInfo } from './config'
|
||||
export type {
|
||||
XuqmConfig,
|
||||
XuqmInitializationSnapshot,
|
||||
XuqmInitializationState,
|
||||
XuqmLoginOptions,
|
||||
XuqmUserInfo,
|
||||
} from './config'
|
||||
export {
|
||||
getConfig,
|
||||
getInitializationSnapshot,
|
||||
hasPrivacyConsent,
|
||||
isInitialized,
|
||||
getUserId,
|
||||
getUserInfo,
|
||||
} from './config'
|
||||
export { XuqmError } from './errors'
|
||||
export type { XuqmErrorCode } from './errors'
|
||||
export { retryWithBackoff } from './retry'
|
||||
export type { RetryOptions } from './retry'
|
||||
export { awaitInitialization } from './sdk'
|
||||
export { apiRequest, configureHttp } from './http'
|
||||
export { decryptConfigFile, decryptXuqmFile, hmacSha256Base64Url, sha256Hex } from './crypto'
|
||||
export type { DecryptedConfig, XuqmEncryptedFileMagic } from './crypto'
|
||||
export { bytesToBase64, hmacSha256Base64Url, sha256Hex } from './crypto'
|
||||
export type { DecryptedConfig } from './crypto'
|
||||
export { DEFAULT_TENANT_PLATFORM_URL, DEFAULT_IM_WS_URL } from './constants'
|
||||
export { getDeviceId, getDeviceInfo, detectPushVendor } from './device'
|
||||
export type { DeviceInfo, PushVendor } from './device'
|
||||
@ -46,30 +63,6 @@ export {
|
||||
satisfiesVersion,
|
||||
} from './version'
|
||||
export { ScaledImage } from './components/ScaledImage'
|
||||
export {
|
||||
XWebViewControl,
|
||||
getXWebViewConfig,
|
||||
openXWebView,
|
||||
setXWebViewController,
|
||||
setXWebViewNavigationRef,
|
||||
setXWebViewScanQRCodeHandler,
|
||||
processJSBridgeMessage,
|
||||
} from './xwebview/XWebViewBridge'
|
||||
export type { XWebViewNavigationRef, ScanQRCodeHandler } from './xwebview/XWebViewBridge'
|
||||
export type {
|
||||
XWebViewClickMenu,
|
||||
XWebViewConfig,
|
||||
XWebViewControllerAPI,
|
||||
XWebViewNavigationState,
|
||||
XWebViewDownloadDecision,
|
||||
XWebViewDownloadDestination,
|
||||
XWebViewDownloadProgress,
|
||||
XWebViewDownloadRequest,
|
||||
XWebViewDownloadResult,
|
||||
XWebViewMessageEvent,
|
||||
XWebViewPermissionRequest,
|
||||
} from './xwebview/types'
|
||||
|
||||
// api/ 子模块
|
||||
export {
|
||||
RequestError,
|
||||
@ -80,6 +73,7 @@ export {
|
||||
ValidationError,
|
||||
} from './api'
|
||||
export type { RequestOptions as UseRequestOptions, ApiMethod } from './api'
|
||||
export type { SafeApiDiagnostic, SafeApiErrorReport } from './api'
|
||||
export type { PageOptions } from './api'
|
||||
export type { AxiosRequestConfig } from 'axios'
|
||||
|
||||
|
||||
@ -6,5 +6,11 @@
|
||||
*/
|
||||
import './autoInit'
|
||||
|
||||
export { _registerInitializationHandler, _registerUserInfoHandler } from './config'
|
||||
export { _registerBundleIdentityProvider, _resolveBundleIdentity } from './bundleIdentity'
|
||||
export {
|
||||
_registerInitializationHandler,
|
||||
_registerPrivacyConsentHandler,
|
||||
_registerUserInfoHandler,
|
||||
} from './config'
|
||||
export type { XuqmBundleIdentity, XuqmBundleIdentityProvider } from './bundleIdentity'
|
||||
export type { XuqmInitializationHandler } from './config'
|
||||
|
||||
@ -0,0 +1,68 @@
|
||||
import { ed25519 } from '@noble/curves/ed25519.js'
|
||||
import { utf8ToBytes } from '@noble/hashes/utils.js'
|
||||
import { decodeBase64 } from '../security/base64.js'
|
||||
import { canonicalJson } from '../security/canonical-json.js'
|
||||
|
||||
const TRUSTED_SIGNING_KEYS = require('../security/trusted-signing-keys.json') as Readonly<
|
||||
Record<string, string>
|
||||
>
|
||||
|
||||
export interface SignedPluginManifest {
|
||||
appKey: string
|
||||
packageName: string
|
||||
platform: 'ANDROID' | 'IOS'
|
||||
moduleId: string
|
||||
type: 'common' | 'app' | 'buz'
|
||||
version: string
|
||||
appVersionRange: string
|
||||
builtAgainstNativeBaselineId: string
|
||||
buildId: string
|
||||
bundleFormat: 'hermes-bytecode' | 'javascript'
|
||||
commonVersionRange?: string
|
||||
minNativeApiLevel: number
|
||||
bundleSha256: string
|
||||
archiveSha256: string
|
||||
}
|
||||
|
||||
export class PluginManifestSignatureError extends Error {
|
||||
readonly name = 'PluginManifestSignatureError'
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly code: 'UNKNOWN_SIGNING_KEY' | 'INVALID_SIGNATURE',
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
/** @internal 插件候选必须在生成计划和下载前通过同一可信密钥集合验签。 */
|
||||
export function verifySignedPluginManifest(
|
||||
manifest: SignedPluginManifest,
|
||||
keyId: string,
|
||||
signature: string,
|
||||
trustedKeys: Readonly<Record<string, string>> = TRUSTED_SIGNING_KEYS,
|
||||
): void {
|
||||
const publicKey = trustedKeys[keyId]
|
||||
if (!publicKey) {
|
||||
throw new PluginManifestSignatureError(
|
||||
`[XuqmSecurity] Untrusted plugin signing key: ${keyId}`,
|
||||
'UNKNOWN_SIGNING_KEY',
|
||||
)
|
||||
}
|
||||
let valid = false
|
||||
try {
|
||||
valid = ed25519.verify(
|
||||
decodeBase64(signature),
|
||||
utf8ToBytes(canonicalJson(manifest)),
|
||||
decodeBase64(publicKey),
|
||||
)
|
||||
} catch {
|
||||
valid = false
|
||||
}
|
||||
if (!valid) {
|
||||
throw new PluginManifestSignatureError(
|
||||
`[XuqmSecurity] Invalid plugin manifest signature: ${manifest.moduleId}@${manifest.version}`,
|
||||
'INVALID_SIGNATURE',
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,55 @@
|
||||
import { z } from 'zod'
|
||||
import type { XuqmRemoteConfig } from './config'
|
||||
|
||||
const flatRemoteConfigSchema = z.object({
|
||||
apiUrl: z.string().optional(),
|
||||
imApiUrl: z.string().optional(),
|
||||
imWsUrl: z.string().optional(),
|
||||
fileServiceUrl: z.string().optional(),
|
||||
imEnabled: z.boolean().optional(),
|
||||
pushEnabled: z.boolean().optional(),
|
||||
bugCollectApiUrl: z.string().optional(),
|
||||
bugCollectEnabled: z.boolean().optional(),
|
||||
updateEnabled: z.boolean().optional(),
|
||||
updateRequiresLogin: z.boolean().optional(),
|
||||
})
|
||||
|
||||
const remoteConfigResponseSchema = flatRemoteConfigSchema
|
||||
.extend({
|
||||
allowAnonymousUpdateCheck: z.boolean().optional(),
|
||||
features: z
|
||||
.object({
|
||||
im: z.boolean().optional(),
|
||||
push: z.boolean().optional(),
|
||||
bugCollect: z.boolean().optional(),
|
||||
update: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.loose()
|
||||
|
||||
/** 平台远程配置的唯一归一化入口;features 字段优先于历史 flat 字段。 */
|
||||
export function normalizeRemoteConfig(value: unknown): XuqmRemoteConfig {
|
||||
const raw = remoteConfigResponseSchema.parse(value)
|
||||
const features = raw.features ?? {}
|
||||
return {
|
||||
apiUrl: raw.apiUrl,
|
||||
imApiUrl: raw.imApiUrl,
|
||||
imWsUrl: raw.imWsUrl,
|
||||
fileServiceUrl: raw.fileServiceUrl,
|
||||
imEnabled: features.im ?? raw.imEnabled,
|
||||
pushEnabled: features.push ?? raw.pushEnabled,
|
||||
bugCollectApiUrl: raw.bugCollectApiUrl,
|
||||
bugCollectEnabled: features.bugCollect ?? raw.bugCollectEnabled,
|
||||
updateEnabled: features.update ?? raw.updateEnabled,
|
||||
updateRequiresLogin:
|
||||
raw.updateRequiresLogin ??
|
||||
(raw.allowAnonymousUpdateCheck === undefined ? undefined : !raw.allowAnonymousUpdateCheck),
|
||||
}
|
||||
}
|
||||
|
||||
/** 旧缓存允许缺少 updateEnabled,最终由 XuqmConfig 按安全默认 false 收敛。 */
|
||||
export function parseCachedRemoteConfig(value: unknown): XuqmRemoteConfig | null {
|
||||
const parsed = flatRemoteConfigSchema.safeParse(value)
|
||||
return parsed.success ? parsed.data : null
|
||||
}
|
||||
57
packages/common/src/retry.ts
普通文件
57
packages/common/src/retry.ts
普通文件
@ -0,0 +1,57 @@
|
||||
export interface RetryOptions {
|
||||
maxAttempts?: number
|
||||
baseDelayMs?: number
|
||||
maxDelayMs?: number
|
||||
jitterRatio?: number
|
||||
signal?: AbortSignal
|
||||
shouldRetry?: (error: unknown) => boolean
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const error = new Error('Operation aborted')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
function wait(delayMs: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) return Promise.reject(abortError())
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, delayMs)
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
reject(abortError())
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** 后台网络任务统一使用三次指数退避并加入抖动,禁止各扩展自行无限重试。 */
|
||||
export async function retryWithBackoff<T>(
|
||||
action: (attempt: number) => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts = 3,
|
||||
baseDelayMs = 500,
|
||||
maxDelayMs = 8_000,
|
||||
jitterRatio = 0.2,
|
||||
signal,
|
||||
shouldRetry = () => true,
|
||||
} = options
|
||||
let lastError: unknown
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
return await action(attempt)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt >= maxAttempts || !shouldRetry(error)) throw error
|
||||
const base = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1))
|
||||
const jitter = base * jitterRatio * (Math.random() * 2 - 1)
|
||||
await wait(Math.max(0, Math.round(base + jitter)), signal)
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
@ -1,33 +1,110 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import {
|
||||
_notifyInitialized,
|
||||
_setInitializationSnapshot,
|
||||
_setPrivacyConsent,
|
||||
_setUserInfo,
|
||||
getInitializationSnapshot,
|
||||
getUserId as getCommonUserId,
|
||||
getUserInfo as getCommonUserInfo,
|
||||
initConfigFromRemote,
|
||||
isInitialized,
|
||||
type XuqmInitOptions,
|
||||
type InternalInitOptions,
|
||||
type XuqmLoginOptions,
|
||||
type XuqmRemoteConfig,
|
||||
type XuqmUserInfo,
|
||||
} from './config'
|
||||
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
|
||||
import { sha256Hex } from './crypto'
|
||||
import { XuqmError } from './errors'
|
||||
import { _clearAccessToken, _saveAccessToken, configureHttp } from './http'
|
||||
import { decryptConfigFile } from './crypto'
|
||||
import { retryWithBackoff } from './retry'
|
||||
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from './secureCache'
|
||||
import type { DecryptedConfig } from './crypto'
|
||||
import { normalizeRemoteConfig, parseCachedRemoteConfig } from './remoteConfig'
|
||||
|
||||
let _initPromise: Promise<void> | null = null
|
||||
let _configFileInitPromise: Promise<void> | null = null
|
||||
let _configFileRequest: {
|
||||
encryptedContent: string
|
||||
options?: { debug?: boolean }
|
||||
let _resolvedConfigInitPromise: Promise<void> | null = null
|
||||
let _resolvedConfigRequest: {
|
||||
config: DecryptedConfig
|
||||
options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' }
|
||||
} | null = null
|
||||
let _initializationKey: string | null = null
|
||||
let _sessionTransition: Promise<void> = Promise.resolve()
|
||||
let _activeSessionKey: string | null = null
|
||||
|
||||
function initializationKey(options: XuqmInitOptions): string {
|
||||
return `${options.appKey.trim()}\n${(options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')}`
|
||||
const REMOTE_CONFIG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000
|
||||
const REMOTE_CONFIG_CACHE_PREFIX = '@xuqm_common:remote_config:v1:'
|
||||
interface CachedRemoteConfig {
|
||||
cachedAt: number
|
||||
remote: XuqmRemoteConfig
|
||||
}
|
||||
|
||||
function startInitialization(options: XuqmInitOptions, signingKey?: string): Promise<void> {
|
||||
function runtimePlatform(): 'ANDROID' | 'IOS' {
|
||||
try {
|
||||
// 延迟加载避免 common 的纯 Node 工具与测试被 React Native 的 Flow 源码绑定。
|
||||
// Metro 仍能静态发现该依赖,移动端运行时以 React Native Platform 为唯一事实。
|
||||
const reactNative = require('react-native') as {
|
||||
Platform?: { OS?: string }
|
||||
}
|
||||
return reactNative.Platform?.OS === 'ios' ? 'IOS' : 'ANDROID'
|
||||
} catch {
|
||||
return 'ANDROID'
|
||||
}
|
||||
}
|
||||
|
||||
function initializationKey(options: InternalInitOptions): string {
|
||||
return `${options.appKey.trim()}\n${(options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')}\n${options.packageName ?? ''}`
|
||||
}
|
||||
|
||||
function storageKey(options: InternalInitOptions): string {
|
||||
return `${REMOTE_CONFIG_CACHE_PREFIX}${sha256Hex(initializationKey(options))}`
|
||||
}
|
||||
|
||||
async function readCachedRemoteConfig(
|
||||
options: InternalInitOptions,
|
||||
signingKey?: string,
|
||||
): Promise<CachedRemoteConfig | null> {
|
||||
if (!signingKey) return null
|
||||
try {
|
||||
const encrypted = await AsyncStorage.getItem(storageKey(options))
|
||||
if (!encrypted) return null
|
||||
const raw = openLocalCache(encrypted, signingKey, initializationKey(options))
|
||||
const cached = JSON.parse(raw) as CachedRemoteConfig
|
||||
const parsedRemote = parseCachedRemoteConfig(cached?.remote)
|
||||
if (
|
||||
!cached ||
|
||||
!isCacheRecordFresh(cached.cachedAt, Date.now(), REMOTE_CONFIG_CACHE_TTL_MS) ||
|
||||
!parsedRemote
|
||||
) {
|
||||
await AsyncStorage.removeItem(storageKey(options))
|
||||
return null
|
||||
}
|
||||
return { cachedAt: cached.cachedAt, remote: parsedRemote }
|
||||
} catch {
|
||||
await AsyncStorage.removeItem(storageKey(options)).catch(() => undefined)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCachedRemoteConfig(
|
||||
options: InternalInitOptions,
|
||||
remote: CachedRemoteConfig['remote'],
|
||||
signingKey?: string,
|
||||
): Promise<void> {
|
||||
if (!signingKey) return
|
||||
try {
|
||||
const encrypted = sealLocalCache(
|
||||
JSON.stringify({ cachedAt: Date.now(), remote } satisfies CachedRemoteConfig),
|
||||
signingKey,
|
||||
initializationKey(options),
|
||||
)
|
||||
await AsyncStorage.setItem(storageKey(options), encrypted)
|
||||
} catch {
|
||||
// 安全随机数或沙箱存储不可用时跳过缓存,不能降级为明文,也不能阻断初始化。
|
||||
}
|
||||
}
|
||||
|
||||
function startInitialization(options: InternalInitOptions, signingKey?: string): Promise<void> {
|
||||
const nextKey = initializationKey(options)
|
||||
if (isInitialized()) {
|
||||
if (_initializationKey !== nextKey) {
|
||||
@ -46,36 +123,73 @@ 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 res = await fetch(configUrl)
|
||||
if (!res.ok) {
|
||||
throw new Error(`[XuqmSDK] Platform config request failed: ${res.status}`)
|
||||
_setInitializationSnapshot({ state: 'initializing' })
|
||||
const platformUrl = (options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')
|
||||
const configParams = new URLSearchParams({
|
||||
appKey: options.appKey,
|
||||
platform: runtimePlatform(),
|
||||
})
|
||||
if (options.packageName) configParams.set('packageName', options.packageName)
|
||||
const configUrl = `${platformUrl}/api/sdk/config?${configParams.toString()}`
|
||||
let remote: CachedRemoteConfig['remote']
|
||||
let source: 'remote' | 'cache' = 'remote'
|
||||
try {
|
||||
const res = await retryWithBackoff(
|
||||
async () => {
|
||||
const response = await fetch(configUrl)
|
||||
if (response.ok) return response
|
||||
if (response.status >= 500) {
|
||||
throw new Error(`[XuqmSDK] Platform config request failed: ${response.status}`)
|
||||
}
|
||||
const json = (await res.json()) as Record<string, unknown>
|
||||
const remote = (json.data ?? json) as Record<string, unknown>
|
||||
initConfigFromRemote(
|
||||
options,
|
||||
{
|
||||
apiUrl: remote.apiUrl as string | undefined,
|
||||
imWsUrl: remote.imWsUrl as string | undefined,
|
||||
fileServiceUrl: remote.fileServiceUrl as string | undefined,
|
||||
imEnabled: remote.imEnabled as boolean | undefined,
|
||||
pushEnabled: remote.pushEnabled as boolean | undefined,
|
||||
bugCollectApiUrl: remote.bugCollectApiUrl as string | undefined,
|
||||
bugCollectEnabled: remote.bugCollectEnabled as boolean | undefined,
|
||||
},
|
||||
signingKey,
|
||||
throw new XuqmError(
|
||||
'XUQM_NOT_READY',
|
||||
`[XuqmSDK] Platform config request failed: ${response.status}`,
|
||||
)
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
shouldRetry: error => !(error instanceof XuqmError),
|
||||
},
|
||||
)
|
||||
const json = (await res.json()) as Record<string, unknown>
|
||||
remote = normalizeRemoteConfig(json.data ?? json)
|
||||
await writeCachedRemoteConfig(options, remote, signingKey)
|
||||
} catch (error) {
|
||||
const cached = await readCachedRemoteConfig(options, signingKey)
|
||||
if (!cached) {
|
||||
throw new XuqmError(
|
||||
'XUQM_NOT_READY',
|
||||
'[XuqmSDK] Tenant configuration is unavailable and no valid cache exists.',
|
||||
error,
|
||||
)
|
||||
}
|
||||
remote = cached.remote
|
||||
source = 'cache'
|
||||
}
|
||||
initConfigFromRemote({ ...options, platformUrl, configSource: source }, remote, signingKey)
|
||||
// Xuqm 平台接口始终回到租户平台。remote.apiUrl 是业务扩展服务地址,
|
||||
// 二者职责不同;混用会把版本检查错误地发送到 App 自身的业务服务。
|
||||
configureHttp({
|
||||
baseUrl: (remote.apiUrl as string | undefined) ?? baseUrl,
|
||||
baseUrl: platformUrl,
|
||||
debug: options.debug,
|
||||
})
|
||||
await _notifyInitialized()
|
||||
_setInitializationSnapshot({
|
||||
state: source === 'remote' ? 'ready' : 'degraded',
|
||||
source,
|
||||
})
|
||||
})().catch(error => {
|
||||
_initPromise = null
|
||||
_initializationKey = null
|
||||
throw error
|
||||
const structured =
|
||||
error instanceof XuqmError
|
||||
? error
|
||||
: new XuqmError('XUQM_NOT_READY', '[XuqmSDK] Initialization failed.', error)
|
||||
_setInitializationSnapshot({
|
||||
state: 'failed',
|
||||
error: { code: structured.code, message: structured.message },
|
||||
})
|
||||
throw structured
|
||||
})
|
||||
return _initPromise
|
||||
}
|
||||
@ -87,23 +201,6 @@ function enqueueSessionTransition(action: () => Promise<void>): Promise<void> {
|
||||
}
|
||||
|
||||
export const XuqmSDK = {
|
||||
/**
|
||||
* 方式 B:手动初始化。
|
||||
*
|
||||
* 请求平台获取该 appKey 的完整服务配置(IM、Push、文件服务等 URL 及开通状态)。
|
||||
* 失败时直接抛出,不降级。
|
||||
*
|
||||
* @param options.appKey 应用标识
|
||||
* @param options.platformUrl 平台地址;不传则使用内置默认公有平台地址
|
||||
* @param options.debug 是否开启调试日志
|
||||
*/
|
||||
async initialize(options: XuqmInitOptions): Promise<void> {
|
||||
if (!options.appKey.trim()) {
|
||||
throw new Error('[XuqmSDK] appKey is required.')
|
||||
}
|
||||
await startInitialization(options)
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待初始化完成。在任意子 SDK 内部调用以确保 XuqmSDK 已就绪。
|
||||
*/
|
||||
@ -111,6 +208,17 @@ export const XuqmSDK = {
|
||||
await awaitInitialization()
|
||||
},
|
||||
|
||||
getInitializationState() {
|
||||
return getInitializationSnapshot()
|
||||
},
|
||||
|
||||
/**
|
||||
* 宿主隐私声明的唯一同步入口。SDK 不弹隐私提示,也不自行推断用户是否同意。
|
||||
*/
|
||||
async setPrivacyConsent(consented: boolean): Promise<void> {
|
||||
await _setPrivacyConsent(consented)
|
||||
},
|
||||
|
||||
getUserId(): string | null {
|
||||
return getCommonUserId()
|
||||
},
|
||||
@ -152,38 +260,47 @@ 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(
|
||||
'[XuqmSDK] Automatic initialization did not start. Place config.xuqmconfig in src/assets/config and wrap Metro with withXuqmConfig(), or call XuqmSDK.initialize().',
|
||||
throw new XuqmError(
|
||||
'XUQM_NOT_READY',
|
||||
'[XuqmSDK] Automatic initialization did not start. Place exactly one .xuqmconfig file in src/assets/config and wrap Metro with withXuqmConfig().',
|
||||
)
|
||||
}
|
||||
await pendingInitialization
|
||||
}
|
||||
|
||||
/** Internal entry used only by the Metro auto-init module. */
|
||||
export function _initializeFromConfigFile(
|
||||
encryptedContent: string,
|
||||
options?: { debug?: boolean },
|
||||
/** Metro 自动初始化专用入口;配置已在可信构建进程中完成解密和格式校验。 */
|
||||
export function _initializeFromResolvedConfig(
|
||||
config: DecryptedConfig,
|
||||
options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' },
|
||||
): 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
|
||||
await startInitialization(
|
||||
{ appKey: file.appKey, platformUrl, debug: options?.debug },
|
||||
file.signingKey,
|
||||
{
|
||||
appKey: config.appKey,
|
||||
platformUrl,
|
||||
debug: options?.debug,
|
||||
bugCollectMode: options?.bugCollectMode,
|
||||
packageName:
|
||||
runtimePlatform() === 'IOS'
|
||||
? (config.iosBundleId ?? config.packageName)
|
||||
: config.packageName,
|
||||
},
|
||||
config.signingKey,
|
||||
)
|
||||
})().catch(error => {
|
||||
_configFileInitPromise = null
|
||||
_resolvedConfigInitPromise = null
|
||||
throw error
|
||||
})
|
||||
|
||||
return _configFileInitPromise
|
||||
return _resolvedConfigInitPromise
|
||||
}
|
||||
|
||||
@ -0,0 +1,60 @@
|
||||
import { gcm } from '@noble/ciphers/aes.js'
|
||||
import { sha256 } from '@noble/hashes/sha2.js'
|
||||
import { utf8ToBytes } from '@noble/hashes/utils.js'
|
||||
import { decodeBase64, encodeBase64 } from '../security/base64.js'
|
||||
|
||||
const CACHE_FORMAT = 'XUQM-CACHE-V1'
|
||||
const NONCE_BYTES = 12
|
||||
|
||||
function bytesToBase64Url(value: Uint8Array): string {
|
||||
return encodeBase64(value, { urlSafe: true, padding: false })
|
||||
}
|
||||
|
||||
function cacheKey(secret: string, context: string): Uint8Array {
|
||||
return sha256(utf8ToBytes(`xuqm-lkg-cache\0${context}\0${secret}`))
|
||||
}
|
||||
|
||||
function utf8BytesToString(value: Uint8Array): string {
|
||||
let encoded = ''
|
||||
for (const byte of value) encoded += `%${byte.toString(16).padStart(2, '0')}`
|
||||
return decodeURIComponent(encoded)
|
||||
}
|
||||
|
||||
function randomNonce(): Uint8Array {
|
||||
const nonce = new Uint8Array(NONCE_BYTES)
|
||||
const cryptoApi = globalThis.crypto
|
||||
if (!cryptoApi?.getRandomValues) {
|
||||
throw new Error('[XuqmSDK] Secure random source is unavailable; LKG cache was not written.')
|
||||
}
|
||||
cryptoApi.getRandomValues(nonce)
|
||||
return nonce
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用沙箱内的带完整性加密。密钥由平台签发配置中的 signingKey 派生但不落缓存;
|
||||
* 这是防止缓存明文与离线篡改的边界,不宣称具备硬件 Keystore 保密性。
|
||||
*/
|
||||
export function sealLocalCache(plaintext: string, secret: string, context: string): string {
|
||||
const nonce = randomNonce()
|
||||
const ciphertext = gcm(cacheKey(secret, context), nonce, utf8ToBytes(context)).encrypt(
|
||||
utf8ToBytes(plaintext),
|
||||
)
|
||||
return `${CACHE_FORMAT}.${bytesToBase64Url(nonce)}.${bytesToBase64Url(ciphertext)}`
|
||||
}
|
||||
|
||||
export function openLocalCache(payload: string, secret: string, context: string): string {
|
||||
const parts = payload.split('.')
|
||||
if (parts.length !== 3 || parts[0] !== CACHE_FORMAT) {
|
||||
throw new Error('[XuqmSDK] LKG cache format is invalid.')
|
||||
}
|
||||
const nonce = decodeBase64(parts[1])
|
||||
if (nonce.length !== NONCE_BYTES) throw new Error('[XuqmSDK] LKG cache nonce is invalid.')
|
||||
const plaintext = gcm(cacheKey(secret, context), nonce, utf8ToBytes(context)).decrypt(
|
||||
decodeBase64(parts[2]),
|
||||
)
|
||||
return utf8BytesToString(plaintext)
|
||||
}
|
||||
|
||||
export function isCacheRecordFresh(cachedAt: number, now: number, ttlMs: number): boolean {
|
||||
return Number.isFinite(cachedAt) && cachedAt <= now && ttlMs > 0 && now - cachedAt <= ttlMs
|
||||
}
|
||||
@ -1,222 +0,0 @@
|
||||
import { Alert, Platform, ToastAndroid } from 'react-native'
|
||||
import { getDeviceInfo } from '../device'
|
||||
import { getUserInfo } from '../config'
|
||||
import { _getAccessToken } from '../http'
|
||||
import type { XWebViewConfig, XWebViewControllerAPI } from './types'
|
||||
|
||||
export type { XWebViewControllerAPI } from './types'
|
||||
|
||||
// ─── Navigation ref (宿主注入) ─────────────────────────────────────────────────
|
||||
|
||||
export type XWebViewNavigationRef = {
|
||||
navigate: (route: string, params?: Record<string, unknown>) => void
|
||||
goBack: () => void
|
||||
}
|
||||
|
||||
let _navigationRef: XWebViewNavigationRef | null = null
|
||||
|
||||
/**
|
||||
* 注入导航能力(宿主在 App 初始化时调用)。
|
||||
* 供 JSBridge 的 `xuqm.openNativePage` / `xuqm.closeWebView` 使用。
|
||||
*/
|
||||
export function setXWebViewNavigationRef(ref: XWebViewNavigationRef | null): void {
|
||||
_navigationRef = ref
|
||||
}
|
||||
|
||||
// ─── ScanQRCode handler (宿主注入) ────────────────────────────────────────────
|
||||
|
||||
export type ScanQRCodeHandler = () => Promise<string>
|
||||
|
||||
let _scanQRCodeHandler: ScanQRCodeHandler | null = null
|
||||
|
||||
/**
|
||||
* 注入扫码能力(宿主在 App 初始化时调用)。
|
||||
* 供 JSBridge 的 `xuqm.scanQRCode` 使用。
|
||||
* handler 应打开原生扫码页并返回扫码结果字符串。
|
||||
*/
|
||||
export function setXWebViewScanQRCodeHandler(handler: ScanQRCodeHandler | null): void {
|
||||
_scanQRCodeHandler = handler
|
||||
}
|
||||
|
||||
// ─── JSBridge response helpers ─────────────────────────────────────────────────
|
||||
|
||||
type BridgeResponse = { code: 0; data: unknown } | { code: -1; message: string }
|
||||
|
||||
function ok(data: unknown): BridgeResponse {
|
||||
return { code: 0, data }
|
||||
}
|
||||
|
||||
function fail(message: string): BridgeResponse {
|
||||
return { code: -1, message }
|
||||
}
|
||||
|
||||
function showToast(message: string): void {
|
||||
if (Platform.OS === 'android') {
|
||||
ToastAndroid.show(message, ToastAndroid.SHORT)
|
||||
} else {
|
||||
Alert.alert('', message)
|
||||
}
|
||||
}
|
||||
|
||||
// ─── JSBridge message processor ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 处理来自 WebView 的 JSBridge 消息(action 以 `xuqm.` 开头)。
|
||||
*
|
||||
* 在 WebView 的 `onMessage` 回调中调用此函数。若消息被本 bridge 处理,
|
||||
* 返回 `true`(上层不必再转发给 userOnMessage);否则返回 `false`。
|
||||
*
|
||||
* @param rawData WebViewMessageEvent.nativeEvent.data
|
||||
* @param postMessageToWeb 向 WebView 回传字符串的函数(injectJavaScript 封装)
|
||||
*/
|
||||
export async function processJSBridgeMessage(
|
||||
rawData: string,
|
||||
postMessageToWeb: (js: string) => void,
|
||||
): Promise<boolean> {
|
||||
let parsed: {
|
||||
__xuqm?: string
|
||||
id?: string | number
|
||||
params?: Record<string, unknown>
|
||||
}
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(rawData)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
|
||||
const action = parsed.__xuqm
|
||||
if (typeof action !== 'string' || !action.startsWith('xuqm.')) {
|
||||
return false
|
||||
}
|
||||
|
||||
const id = parsed.id
|
||||
const params = parsed.params ?? {}
|
||||
|
||||
function respond(result: BridgeResponse): void {
|
||||
const js = `
|
||||
(function(){
|
||||
var e = new CustomEvent('xuqm_bridge_response_${String(id ?? '')}', { detail: ${JSON.stringify(result)} });
|
||||
window.dispatchEvent(e);
|
||||
if (window.__xuqmBridgeCallback) {
|
||||
window.__xuqmBridgeCallback(${JSON.stringify(String(id ?? ''))}, ${JSON.stringify(result)});
|
||||
}
|
||||
})();
|
||||
true;`.trim()
|
||||
postMessageToWeb(js)
|
||||
}
|
||||
|
||||
try {
|
||||
switch (action) {
|
||||
case 'xuqm.getDeviceInfo': {
|
||||
const info = await getDeviceInfo()
|
||||
respond(ok(info))
|
||||
break
|
||||
}
|
||||
|
||||
case 'xuqm.getToken': {
|
||||
const token = await _getAccessToken()
|
||||
respond(ok(token))
|
||||
break
|
||||
}
|
||||
|
||||
case 'xuqm.getUserInfo': {
|
||||
const info = getUserInfo()
|
||||
respond(ok(info))
|
||||
break
|
||||
}
|
||||
|
||||
case 'xuqm.openNativePage': {
|
||||
const route = params.route
|
||||
if (typeof route !== 'string') {
|
||||
respond(fail('params.route is required'))
|
||||
break
|
||||
}
|
||||
const navParams = params.params as Record<string, unknown> | undefined
|
||||
if (!_navigationRef) {
|
||||
respond(fail('navigation not available — call setXWebViewNavigationRef() in host app'))
|
||||
break
|
||||
}
|
||||
_navigationRef.navigate(route, navParams)
|
||||
respond(ok(null))
|
||||
break
|
||||
}
|
||||
|
||||
case 'xuqm.closeWebView': {
|
||||
if (!_navigationRef) {
|
||||
respond(fail('navigation not available — call setXWebViewNavigationRef() in host app'))
|
||||
break
|
||||
}
|
||||
_navigationRef.goBack()
|
||||
respond(ok(null))
|
||||
break
|
||||
}
|
||||
|
||||
case 'xuqm.showToast': {
|
||||
const message = params.message
|
||||
if (typeof message !== 'string') {
|
||||
respond(fail('params.message is required'))
|
||||
break
|
||||
}
|
||||
showToast(message)
|
||||
respond(ok(null))
|
||||
break
|
||||
}
|
||||
|
||||
case 'xuqm.scanQRCode': {
|
||||
if (!_scanQRCodeHandler) {
|
||||
respond(
|
||||
fail('scanQRCode not available — call setXWebViewScanQRCodeHandler() in host app'),
|
||||
)
|
||||
break
|
||||
}
|
||||
const result = await _scanQRCodeHandler()
|
||||
respond(ok(result))
|
||||
break
|
||||
}
|
||||
|
||||
default:
|
||||
// Unknown xuqm.* action — not handled by built-in bridge
|
||||
return false
|
||||
}
|
||||
} catch (err) {
|
||||
respond(fail(err instanceof Error ? err.message : String(err)))
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// ─── Config + Controller ───────────────────────────────────────────────────────
|
||||
|
||||
let _config: XWebViewConfig = {}
|
||||
let _controller: XWebViewControllerAPI | null = null
|
||||
|
||||
export function openXWebView(navigate: (pluginId: string) => void, config: XWebViewConfig) {
|
||||
_config = { ...config }
|
||||
navigate('xwebview')
|
||||
}
|
||||
|
||||
export function getXWebViewConfig(): XWebViewConfig {
|
||||
return _config
|
||||
}
|
||||
|
||||
export function setXWebViewController(controller: XWebViewControllerAPI | null) {
|
||||
_controller = controller
|
||||
}
|
||||
|
||||
export const XWebViewControl: XWebViewControllerAPI = {
|
||||
refresh: () => _controller?.refresh(),
|
||||
close: () => _controller?.close(),
|
||||
goBack: () => _controller?.goBack(),
|
||||
goForward: () => _controller?.goForward(),
|
||||
copyUrl: () => _controller?.copyUrl(),
|
||||
postMessageToWeb: js => _controller?.postMessageToWeb(js),
|
||||
getTitle: () => _controller?.getTitle() ?? '',
|
||||
getNavigationState: () =>
|
||||
_controller?.getNavigationState() ?? {
|
||||
canGoBack: false,
|
||||
canGoForward: false,
|
||||
title: '',
|
||||
url: '',
|
||||
},
|
||||
}
|
||||
@ -1,99 +0,0 @@
|
||||
import type React from 'react'
|
||||
|
||||
export type XWebViewClickMenu = {
|
||||
view?: React.ReactNode
|
||||
onClick: () => void
|
||||
}
|
||||
|
||||
export type XWebViewMessageEvent = {
|
||||
nativeEvent: { data: string }
|
||||
}
|
||||
|
||||
export type XWebViewPermissionRequest = {
|
||||
origin: string
|
||||
resources: string[]
|
||||
grant: (resources?: string[]) => void
|
||||
deny: () => void
|
||||
}
|
||||
|
||||
export type XWebViewDownloadRequest = {
|
||||
url: string
|
||||
suggestedFilename: string
|
||||
mimeType?: string
|
||||
fileSize?: number
|
||||
}
|
||||
|
||||
export type XWebViewDownloadDecision = {
|
||||
allowed: boolean
|
||||
filename?: string
|
||||
savePath?: string
|
||||
}
|
||||
|
||||
export type XWebViewDownloadProgress = {
|
||||
url: string
|
||||
filename: string
|
||||
received: number
|
||||
total: number
|
||||
percentage: number
|
||||
}
|
||||
|
||||
export type XWebViewDownloadResult = {
|
||||
url: string
|
||||
filename: string
|
||||
filePath: string
|
||||
fileSize: number
|
||||
}
|
||||
|
||||
export type XWebViewNavigationState = {
|
||||
canGoBack: boolean
|
||||
canGoForward: boolean
|
||||
title: string
|
||||
url: string
|
||||
}
|
||||
|
||||
export type XWebViewControllerAPI = {
|
||||
refresh: () => void
|
||||
close: () => void
|
||||
goBack: () => void
|
||||
goForward: () => void
|
||||
copyUrl: () => void
|
||||
postMessageToWeb: (jsString: string) => void
|
||||
getTitle: () => string
|
||||
getNavigationState: () => XWebViewNavigationState
|
||||
}
|
||||
|
||||
export type XWebViewDownloadDestination = 'sandbox' | 'publicDownloads'
|
||||
|
||||
export type XWebViewConfig = {
|
||||
showTopBar?: boolean
|
||||
showStatusBar?: boolean
|
||||
doubleBackExit?: boolean
|
||||
title?: string
|
||||
showTitle?: boolean
|
||||
autoTitle?: boolean
|
||||
showMenu?: boolean
|
||||
openForBrowser?: boolean
|
||||
clickMenu?: XWebViewClickMenu
|
||||
url?: string
|
||||
/** 首次加载 URL 时携带的请求头,适用于需要会话鉴权的业务 H5。 */
|
||||
headers?: Record<string, string>
|
||||
content?: string
|
||||
onMessage?: (event: XWebViewMessageEvent) => void
|
||||
onNavigationStateChange?: (state: XWebViewNavigationState) => void
|
||||
injectedJavaScript?: string
|
||||
onPermissionRequest?: (request: XWebViewPermissionRequest) => void
|
||||
autoDownload?: boolean
|
||||
onDownloadStart?: (request: XWebViewDownloadRequest) => void
|
||||
onDownloadProgress?: (progress: XWebViewDownloadProgress) => void
|
||||
onDownloadComplete?: (result: XWebViewDownloadResult) => void
|
||||
onDownloadError?: (url: string, error: string) => void
|
||||
onDownloadDecide?: (
|
||||
request: XWebViewDownloadRequest,
|
||||
) => XWebViewDownloadDecision | Promise<XWebViewDownloadDecision>
|
||||
downloadConflict?: 'rename' | 'overwrite'
|
||||
/** 下载保存位置;与原生 Android SDK FileDownloadDestination 对齐。 */
|
||||
downloadDestination?: XWebViewDownloadDestination
|
||||
/** 非空时显示状态栏下载进度通知。 */
|
||||
downloadNotificationTitle?: string
|
||||
onClose?: () => void
|
||||
}
|
||||
@ -2,7 +2,13 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import type { AxiosResponse } from 'axios'
|
||||
|
||||
import { createResponseDiagnostic, createValidationDiagnostic } from '../src/api/diagnostics'
|
||||
import {
|
||||
createResponseDiagnostic,
|
||||
createSafeApiErrorReport,
|
||||
createValidationDiagnostic,
|
||||
safeRequestPath,
|
||||
} from '../src/api/diagnostics'
|
||||
import { RequestError } from '../src/api/errors'
|
||||
|
||||
function sensitiveResponse(): AxiosResponse<unknown> {
|
||||
return {
|
||||
@ -36,6 +42,14 @@ test('API diagnostics only retain non-sensitive request shape', () => {
|
||||
assert.doesNotMatch(serialized, /secret|phone|token|authorization|example\.com/)
|
||||
})
|
||||
|
||||
test('request paths remove hosts, query parameters and fragments', () => {
|
||||
assert.equal(
|
||||
safeRequestPath('https://api.example.com/am/example?phone=13800000000#secret'),
|
||||
'/am/example',
|
||||
)
|
||||
assert.equal(safeRequestPath('/am/example?token=secret'), '/am/example')
|
||||
})
|
||||
|
||||
test('validation diagnostics retain issue paths without response data', () => {
|
||||
const diagnostic = createValidationDiagnostic(sensitiveResponse(), [
|
||||
{
|
||||
@ -50,3 +64,29 @@ test('validation diagnostics retain issue paths without response data', () => {
|
||||
const serialized = JSON.stringify(diagnostic)
|
||||
assert.doesNotMatch(serialized, /secret|13800000000|authorization|example\.com/)
|
||||
})
|
||||
|
||||
test('global API error reports never expose RequestError causes', () => {
|
||||
const response = sensitiveResponse()
|
||||
const cause = {
|
||||
issues: [
|
||||
{
|
||||
code: 'invalid_type',
|
||||
expected: 'string',
|
||||
message: 'Invalid input: expected string, received number',
|
||||
path: ['data', 'userId'],
|
||||
},
|
||||
],
|
||||
response,
|
||||
}
|
||||
const report = createSafeApiErrorReport(
|
||||
new RequestError('server message may contain private data', 'ValidationError', cause),
|
||||
)
|
||||
|
||||
assert.equal(report.requestType, 'ValidationError')
|
||||
assert.equal(report.diagnostic?.type, 'ValidationError')
|
||||
const serialized = JSON.stringify(report)
|
||||
assert.doesNotMatch(
|
||||
serialized,
|
||||
/private|secret|13800000000|authorization|example\.com|server message/,
|
||||
)
|
||||
})
|
||||
|
||||
@ -1,50 +1,15 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { decryptXuqmFile, hmacSha256Base64Url } from '../src/crypto'
|
||||
|
||||
const CONFIG_VECTOR =
|
||||
'XUQM-CONFIG-V1.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.vhdKh3AhEdeftXCdXp9KusVySkrBNAzmdaA747uf1tVWGFnAsJeCLe2mHKk0dxxez8SRTknIbk1UuRibnXY0Gbot1fmkR-jSN2ssMJO0_zQ8dUu8'
|
||||
import { hmacSha256Base64Url, sha256Hex } from '../src/crypto'
|
||||
|
||||
test('hmacSha256Base64Url matches the standard HMAC-SHA256 vector', () => {
|
||||
test('sha256Hex uses the canonical lowercase digest', () => {
|
||||
assert.equal(
|
||||
hmacSha256Base64Url('key', 'The quick brown fox jumps over the lazy dog'),
|
||||
'97yD9DBThCSxMpjmqm-xQ-9NWaFJRhdZl0edvC0aPNg',
|
||||
sha256Hex('xuqm'),
|
||||
'8d7c5b94129df39d6bec13e9fed29bb15fd1715f2e75a506436d9464bd38250c',
|
||||
)
|
||||
})
|
||||
|
||||
test('decryptXuqmFile remains compatible with existing PBKDF2 + AES-256-GCM files', async () => {
|
||||
await assert.doesNotReject(async () => {
|
||||
const result = await decryptXuqmFile(CONFIG_VECTOR)
|
||||
assert.deepEqual(result, {
|
||||
appKey: 'test-app',
|
||||
appName: '兼容向量',
|
||||
signingKey: 'secret',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('decryptXuqmFile does not depend on a React Native TextDecoder polyfill', async () => {
|
||||
const original = globalThis.TextDecoder
|
||||
Object.defineProperty(globalThis, 'TextDecoder', {
|
||||
configurable: true,
|
||||
value: class BrokenTextDecoder {
|
||||
decode(): string {
|
||||
return '[object Uint8Array]'
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await decryptXuqmFile(CONFIG_VECTOR)
|
||||
assert.equal(result.appName, '兼容向量')
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'TextDecoder', {
|
||||
configurable: true,
|
||||
value: original,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('decryptXuqmFile rejects invalid authentication tags', async () => {
|
||||
await assert.rejects(() => decryptXuqmFile(`${CONFIG_VECTOR.slice(0, -1)}A`), /decryption failed/)
|
||||
test('hmacSha256Base64Url returns an unpadded URL-safe value', () => {
|
||||
assert.match(hmacSha256Base64Url('secret', 'payload'), /^[A-Za-z0-9_-]+$/)
|
||||
})
|
||||
|
||||
13
packages/common/tests/fixtures/README.md
vendored
普通文件
13
packages/common/tests/fixtures/README.md
vendored
普通文件
@ -0,0 +1,13 @@
|
||||
# 测试夹具
|
||||
|
||||
`rn-release-signature-vector.generated.json` 由 Server 仓库的
|
||||
`update-service/src/test/resources/rn-release-signature-vector.json` 生成。
|
||||
|
||||
**GENERATED / DO NOT EDIT**:禁止手工修改。Server 是唯一规范源,更新时执行:
|
||||
|
||||
```bash
|
||||
node scripts/sync-server-signature-vector.mjs --write
|
||||
node scripts/sync-server-signature-vector.mjs --check
|
||||
```
|
||||
|
||||
该文件只位于测试目录,不进入 `@xuqm/rn-common` 发布包。
|
||||
4
packages/common/tests/fixtures/common-only.js
vendored
普通文件
4
packages/common/tests/fixtures/common-only.js
vendored
普通文件
@ -0,0 +1,4 @@
|
||||
// 真实 Metro fixture:不使用 withXuqmConfig,也不提供 .xuqmconfig。
|
||||
const config = require('@xuqm/rn-common/auto-init-config')
|
||||
|
||||
module.exports = config.RESOLVED_CONFIG === undefined
|
||||
6
packages/common/tests/fixtures/public-api.ts
vendored
普通文件
6
packages/common/tests/fixtures/public-api.ts
vendored
普通文件
@ -0,0 +1,6 @@
|
||||
import { XuqmSDK } from '@xuqm/rn-common'
|
||||
|
||||
void XuqmSDK.awaitInitialization()
|
||||
|
||||
// @ts-expect-error 平台签发配置 + Metro 是唯一初始化入口。
|
||||
void XuqmSDK.initialize({ appKey: 'manual-init-is-not-public' })
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"keyId": "xuqm-rn-vector-2026-07",
|
||||
"publicKeyRawBase64": "jkw/1R5aVg2NAwB7+xw2fR9i8phXi+2jt9l8QzuG8pU=",
|
||||
"canonicalJson": "{\"appKey\":\"ak_vector\",\"appVersionRange\":\">=8.0.0 <9.0.0\",\"archiveSha256\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"buildId\":\"build-42\",\"builtAgainstNativeBaselineId\":\"android-sha256:abc\",\"bundleFormat\":\"hermes-bytecode\",\"bundleSha256\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"minNativeApiLevel\":2,\"moduleId\":\"common\",\"packageName\":\"com.xuqm.vector\",\"platform\":\"ANDROID\",\"type\":\"common\",\"version\":\"1.2.3\"}",
|
||||
"signatureBase64Url": "p5GHekcL-wqM9yC7xNGRLMEwNVy7ECwVGwIdWkMJ9G85Pgr4XXca3kBXm3G-hLB0XZa76_tVmBNJhHkUuse6AQ"
|
||||
}
|
||||
7
packages/common/tests/fixtures/tsconfig.json
vendored
普通文件
7
packages/common/tests/fixtures/tsconfig.json
vendored
普通文件
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./public-api.ts"]
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { apiRequest, configureHttp } from '../src/http'
|
||||
|
||||
test('apiRequest aborts a stalled request at the shared timeout boundary', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
configureHttp({ baseUrl: 'https://platform.example.test' })
|
||||
globalThis.fetch = async (_input, init) =>
|
||||
new Promise((_resolve, reject) => {
|
||||
init?.signal?.addEventListener(
|
||||
'abort',
|
||||
() => reject(init.signal?.reason ?? new Error('aborted')),
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
|
||||
try {
|
||||
await assert.rejects(
|
||||
() => apiRequest('/api/v1/rn/release-set/check', { skipAuth: true, timeoutMs: 5 }),
|
||||
/Request timed out after 5ms/,
|
||||
)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
@ -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,8 @@ 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.equal(getConfig().updateEnabled, false)
|
||||
assert.deepEqual(observed, ['shared-app:https://api.example.test'])
|
||||
unregister()
|
||||
})
|
||||
|
||||
@ -0,0 +1,44 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { createRequire } = require('node:module')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
test('common-only host bundles the default auto-init module with real Metro', async () => {
|
||||
const workspaceRoot = path.resolve(__dirname, '../../..')
|
||||
const outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-common-only-'))
|
||||
const output = path.join(outputDirectory, 'common-only.bundle.js')
|
||||
try {
|
||||
const reactNativeRequire = createRequire(
|
||||
require.resolve('react-native/package.json', { paths: [workspaceRoot] }),
|
||||
)
|
||||
const metro = reactNativeRequire('metro')
|
||||
const metroConfig = reactNativeRequire('metro-config')
|
||||
const config = await metroConfig.getDefaultConfig(workspaceRoot)
|
||||
config.watchFolders = [workspaceRoot]
|
||||
config.resolver.extraNodeModules = {
|
||||
'metro-runtime': path.dirname(reactNativeRequire.resolve('metro-runtime/package.json')),
|
||||
}
|
||||
await metro.runBuild(config, {
|
||||
dev: false,
|
||||
entry: 'packages/common/tests/fixtures/common-only.js',
|
||||
minify: false,
|
||||
out: output,
|
||||
platform: 'android',
|
||||
})
|
||||
assert.equal(fs.statSync(output).size > 0, true)
|
||||
} finally {
|
||||
fs.rmSync(outputDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('public types expose no manual initialization entry', () => {
|
||||
const workspaceRoot = path.resolve(__dirname, '../../..')
|
||||
execFileSync(
|
||||
path.join(workspaceRoot, 'node_modules/.bin/tsc'),
|
||||
['-p', path.join(__dirname, 'fixtures/tsconfig.json')],
|
||||
{ cwd: workspaceRoot, stdio: 'pipe' },
|
||||
)
|
||||
})
|
||||
@ -6,6 +6,9 @@ const test = require('node:test')
|
||||
|
||||
const { withXuqmConfig } = require('../metro')
|
||||
|
||||
const CONFIG_VECTOR =
|
||||
'XUQM-CONFIG-V2.xuqm-config-dev-2026-07.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.MYRqE2BtQG0PvSnG3TLrr4PXd10C1cHhNA2iJLfy60XmQ5ixQBTCpn4e2g2YN0QQbZY0LVnou1rGONIzMrT7VidOMi_lHSfUf2BXnqlCIRm4yKcYCvALvrIF-Llv2xQWla0BTpKNq2Yuejpsw0oGTZGjNkvmlbBu8JJHAckHi-wP1sAgZuKVjjtJnjc7venWzaa75KwsqOYiNOkhIjWqTLNMG1siCMbX96d2A15KJYRUpwd-rBSpgjmOFLdH6DDaj2ZqCqQg6NRJprjsEorrCAnDjRjwoMKqHiIwHErfihw3v0AO19MJUzCeYzCzU2WfFw.Z-NMbyec47P4K_lfx3l7s3jZJVpd7ru8ySj2eBvXfteU4tVBoJ_3N7IRPMeiv1lUxMchsvNg8okxYpcBs4_2CQ'
|
||||
|
||||
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,
|
||||
@ -29,11 +32,52 @@ test('withXuqmConfig schedules automatic initialization before the app entry', (
|
||||
|
||||
const virtualModule = config.resolver.resolveRequest(
|
||||
{ resolveRequest: () => assert.fail('fallback resolver should not run') },
|
||||
'@xuqm/autoinit-config',
|
||||
'@xuqm/rn-common/auto-init-config',
|
||||
'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-V2'), false)
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('withXuqmConfig rejects legacy V1 files with a migration instruction', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
||||
const configDir = path.join(projectRoot, 'src/assets/config')
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'config.xuqmconfig'),
|
||||
'XUQM-CONFIG-V1.invalid.invalid.invalid',
|
||||
'utf8',
|
||||
)
|
||||
assert.throws(
|
||||
() => withXuqmConfig({ projectRoot }),
|
||||
/仅支持 XUQM-CONFIG-V2;请从租户平台重新下载配置文件/,
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('withXuqmConfig rejects a modified V2 signature before decrypting content', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
||||
const configDir = path.join(projectRoot, 'src/assets/config')
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
const parts = CONFIG_VECTOR.split('.')
|
||||
parts[5] = `${parts[5][0] === 'A' ? 'B' : 'A'}${parts[5].slice(1)}`
|
||||
const tampered = parts.join('.')
|
||||
fs.writeFileSync(path.join(configDir, 'config.xuqmconfig'), tampered, 'utf8')
|
||||
assert.throws(() => withXuqmConfig({ projectRoot }), /配置文件签名验证失败/)
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(projectRoot, 'android/app/src/main/assets/config/config.xuqmconfig')),
|
||||
false,
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
@ -54,3 +98,19 @@ test('withXuqmConfig leaves common-only hosts unchanged without a config file',
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('withXuqmConfig rejects ambiguous config directories instead of choosing a file', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
||||
const configDir = path.join(projectRoot, 'src/assets/config')
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(configDir, 'app.xuqmconfig'), CONFIG_VECTOR, 'utf8')
|
||||
fs.writeFileSync(path.join(configDir, 'tenant.xuqmconfig'), CONFIG_VECTOR, 'utf8')
|
||||
assert.throws(
|
||||
() => withXuqmConfig({ projectRoot }),
|
||||
/只能存在一个 \.xuqmconfig 文件,当前发现 2 个/,
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@ -0,0 +1,74 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import test from 'node:test'
|
||||
import { decodeBase64 } from '../security/base64.js'
|
||||
import {
|
||||
PluginManifestSignatureError,
|
||||
verifySignedPluginManifest,
|
||||
type SignedPluginManifest,
|
||||
} from '../src/internalSecurity'
|
||||
import { canonicalJson } from '../security/canonical-json.js'
|
||||
|
||||
interface ServerSignatureVector {
|
||||
keyId: string
|
||||
publicKeyRawBase64: string
|
||||
canonicalJson: string
|
||||
signatureBase64Url: string
|
||||
}
|
||||
|
||||
const vectorPath = new URL('./fixtures/rn-release-signature-vector.generated.json', import.meta.url)
|
||||
const vector = JSON.parse(readFileSync(vectorPath, 'utf8')) as ServerSignatureVector
|
||||
const manifest = JSON.parse(vector.canonicalJson) as SignedPluginManifest
|
||||
const trustedKeys = { [vector.keyId]: vector.publicKeyRawBase64 }
|
||||
|
||||
test('accepts the Server-owned Ed25519 verification vector byte-for-byte', () => {
|
||||
assert.deepEqual(
|
||||
Buffer.from(canonicalJson(manifest), 'utf8'),
|
||||
Buffer.from(vector.canonicalJson, 'utf8'),
|
||||
)
|
||||
assert.equal(decodeBase64(vector.publicKeyRawBase64).length, 32)
|
||||
assert.equal(decodeBase64(vector.signatureBase64Url).length, 64)
|
||||
assert.doesNotThrow(() =>
|
||||
verifySignedPluginManifest(manifest, vector.keyId, vector.signatureBase64Url, trustedKeys),
|
||||
)
|
||||
})
|
||||
|
||||
test('canonical manifest omits null fields used only by non-common modules', () => {
|
||||
assert.equal(
|
||||
canonicalJson({ moduleId: 'common', commonVersionRange: null }),
|
||||
'{"moduleId":"common"}',
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects an unknown plugin signing key', () => {
|
||||
assert.throws(
|
||||
() => verifySignedPluginManifest(manifest, 'unknown', vector.signatureBase64Url, trustedKeys),
|
||||
(error: unknown) =>
|
||||
error instanceof PluginManifestSignatureError && error.code === 'UNKNOWN_SIGNING_KEY',
|
||||
)
|
||||
})
|
||||
|
||||
test('rejects any immutable manifest field tampering', () => {
|
||||
assert.throws(
|
||||
() =>
|
||||
verifySignedPluginManifest(
|
||||
{ ...manifest, bundleSha256: 'b'.repeat(64) },
|
||||
vector.keyId,
|
||||
vector.signatureBase64Url,
|
||||
trustedKeys,
|
||||
),
|
||||
(error: unknown) =>
|
||||
error instanceof PluginManifestSignatureError && error.code === 'INVALID_SIGNATURE',
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
verifySignedPluginManifest(
|
||||
{ ...manifest, archiveSha256: 'd'.repeat(64) },
|
||||
vector.keyId,
|
||||
vector.signatureBase64Url,
|
||||
trustedKeys,
|
||||
),
|
||||
(error: unknown) =>
|
||||
error instanceof PluginManifestSignatureError && error.code === 'INVALID_SIGNATURE',
|
||||
)
|
||||
})
|
||||
@ -0,0 +1,24 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { normalizeRemoteConfig, parseCachedRemoteConfig } from '../src/remoteConfig'
|
||||
|
||||
test('platform update feature is authoritative over the flat compatibility field', () => {
|
||||
assert.equal(
|
||||
normalizeRemoteConfig({ features: { update: false }, updateEnabled: true }).updateEnabled,
|
||||
false,
|
||||
)
|
||||
assert.equal(normalizeRemoteConfig({ updateEnabled: true }).updateEnabled, true)
|
||||
})
|
||||
|
||||
test('missing update feature stays absent so XuqmConfig can safely default it off', () => {
|
||||
assert.equal(normalizeRemoteConfig({}).updateEnabled, undefined)
|
||||
assert.deepEqual(parseCachedRemoteConfig({ apiUrl: 'https://api.example.test' }), {
|
||||
apiUrl: 'https://api.example.test',
|
||||
})
|
||||
})
|
||||
|
||||
test('cached update availability is preserved exactly', () => {
|
||||
assert.equal(parseCachedRemoteConfig({ updateEnabled: true })?.updateEnabled, true)
|
||||
assert.equal(parseCachedRemoteConfig({ updateEnabled: false })?.updateEnabled, false)
|
||||
})
|
||||
@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { retryWithBackoff } from '../src/retry'
|
||||
|
||||
test('retryWithBackoff succeeds within the bounded three-attempt policy', async () => {
|
||||
let attempts = 0
|
||||
const value = await retryWithBackoff(
|
||||
async () => {
|
||||
attempts += 1
|
||||
if (attempts < 3) throw new Error('temporary')
|
||||
return 'ok'
|
||||
},
|
||||
{ baseDelayMs: 0, jitterRatio: 0 },
|
||||
)
|
||||
assert.equal(value, 'ok')
|
||||
assert.equal(attempts, 3)
|
||||
})
|
||||
|
||||
test('retryWithBackoff stops immediately for non-retryable errors', async () => {
|
||||
let attempts = 0
|
||||
await assert.rejects(
|
||||
() =>
|
||||
retryWithBackoff(
|
||||
async () => {
|
||||
attempts += 1
|
||||
throw new Error('disabled')
|
||||
},
|
||||
{ baseDelayMs: 0, shouldRetry: () => false },
|
||||
),
|
||||
/disabled/,
|
||||
)
|
||||
assert.equal(attempts, 1)
|
||||
})
|
||||
@ -1,19 +1,30 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { _initializeFromConfigFile, awaitInitialization } from '../src/sdk'
|
||||
import { apiRequest } from '../src/http'
|
||||
import { getConfig, getInitializationSnapshot } from '../src/config'
|
||||
import { _initializeFromResolvedConfig, awaitInitialization, XuqmSDK } from '../src/sdk'
|
||||
|
||||
const CONFIG_VECTOR =
|
||||
'XUQM-CONFIG-V1.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.vhdKh3AhEdeftXCdXp9KusVySkrBNAzmdaA747uf1tVWGFnAsJeCLe2mHKk0dxxez8SRTknIbk1UuRibnXY0Gbot1fmkR-jSN2ssMJO0_zQ8dUu8'
|
||||
test('public SDK has no second manual initialization entry', () => {
|
||||
assert.equal('initialize' in XuqmSDK, false)
|
||||
})
|
||||
|
||||
test('awaitInitialization observes in-flight work and retries a transient config request failure', async () => {
|
||||
test('awaitInitialization retries a transient config request without leaking failure to the host', 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 +39,32 @@ test('awaitInitialization observes in-flight work and retries a transient config
|
||||
}
|
||||
|
||||
try {
|
||||
const autoInitialization = _initializeFromConfigFile(CONFIG_VECTOR, {
|
||||
debug: true,
|
||||
})
|
||||
await assert.rejects(() => awaitInitialization(), /Network request failed/)
|
||||
await assert.rejects(() => autoInitialization, /Network request failed/)
|
||||
|
||||
// 配置内容由 common 保存,扩展包再次等待时可以自行重试,无需宿主传递配置。
|
||||
const autoInitialization = _initializeFromResolvedConfig(
|
||||
{
|
||||
appKey: 'test-app',
|
||||
appName: '测试应用',
|
||||
configId: '123e4567-e89b-42d3-a456-426614174000',
|
||||
issuedAt: '2026-07-26T00:00:00Z',
|
||||
revision: 1,
|
||||
schemaVersion: 2,
|
||||
serverUrl: 'https://dev.xuqinmin.com',
|
||||
signingKey: 'secret',
|
||||
},
|
||||
{ debug: true },
|
||||
)
|
||||
await assert.doesNotReject(() => autoInitialization)
|
||||
await assert.doesNotReject(() => awaitInitialization())
|
||||
assert.equal(requestCount, 2)
|
||||
assert.deepEqual(getInitializationSnapshot(), { state: 'ready', source: 'remote' })
|
||||
assert.equal(getConfig().updateEnabled, false)
|
||||
assert.equal(getConfig().updateRequiresLogin, true)
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from '../src/secureCache'
|
||||
|
||||
test('LKG cache round-trips with context-bound authenticated encryption', () => {
|
||||
const encrypted = sealLocalCache('{"enabled":true}', 'signed-secret', 'app|package|environment')
|
||||
assert.equal(
|
||||
openLocalCache(encrypted, 'signed-secret', 'app|package|environment'),
|
||||
'{"enabled":true}',
|
||||
)
|
||||
assert.equal(encrypted.includes('enabled'), false)
|
||||
})
|
||||
|
||||
test('LKG cache rejects tampering and a different package/environment context', () => {
|
||||
const encrypted = sealLocalCache('payload', 'signed-secret', 'app|package|environment')
|
||||
const parts = encrypted.split('.')
|
||||
parts[2] = `${parts[2][0] === 'A' ? 'B' : 'A'}${parts[2].slice(1)}`
|
||||
assert.throws(() => openLocalCache(parts.join('.'), 'signed-secret', 'app|package|environment'))
|
||||
assert.throws(() => openLocalCache(encrypted, 'signed-secret', 'app|other|environment'))
|
||||
})
|
||||
|
||||
test('LKG freshness rejects future timestamps and expired records', () => {
|
||||
const now = 1_000_000
|
||||
assert.equal(isCacheRecordFresh(now - 1_000, now, 2_000), true)
|
||||
assert.equal(isCacheRecordFresh(now - 2_001, now, 2_000), false)
|
||||
assert.equal(isCacheRecordFresh(now + 1, now, 2_000), false)
|
||||
})
|
||||
@ -0,0 +1,64 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { decodeBase64, encodeBase64 } from '../security/base64.js'
|
||||
import { canonicalJson } from '../security/canonical-json.js'
|
||||
|
||||
test('strict Base64 decoder supports padded, unpadded and URL-safe inputs without atob', () => {
|
||||
assert.deepEqual([...decodeBase64('TQ==')], [77])
|
||||
assert.deepEqual([...decodeBase64('TWE=')], [77, 97])
|
||||
assert.deepEqual([...decodeBase64('TWFu')], [77, 97, 110])
|
||||
assert.deepEqual([...decodeBase64('AQIDBA==')], [1, 2, 3, 4])
|
||||
assert.deepEqual([...decodeBase64('AQIDBA')], [1, 2, 3, 4])
|
||||
assert.deepEqual([...decodeBase64('-_8=')], [251, 255])
|
||||
})
|
||||
|
||||
test('strict Base64 decoder rejects invalid alphabets, lengths, padding and trailing bits', () => {
|
||||
for (const value of [
|
||||
'A',
|
||||
'A+_-',
|
||||
'AA=A',
|
||||
'AA===',
|
||||
'AAA==',
|
||||
'TQ=',
|
||||
'TWE==',
|
||||
'TQ===',
|
||||
'TQ==AA',
|
||||
'TR==',
|
||||
'TWF=',
|
||||
'AA!A',
|
||||
]) {
|
||||
assert.throws(() => decodeBase64(value), /Base64/)
|
||||
}
|
||||
})
|
||||
|
||||
test('pure JS Base64 encoder emits canonical standard and URL-safe forms', () => {
|
||||
assert.equal(encodeBase64(new Uint8Array()), '')
|
||||
assert.equal(encodeBase64(Uint8Array.of(77)), 'TQ==')
|
||||
assert.equal(encodeBase64(Uint8Array.of(77, 97)), 'TWE=')
|
||||
assert.equal(encodeBase64(Uint8Array.of(77, 97, 110)), 'TWFu')
|
||||
assert.equal(encodeBase64(Uint8Array.of(251, 255), { urlSafe: true }), '-_8=')
|
||||
assert.equal(encodeBase64(Uint8Array.of(251, 255), { urlSafe: true, padding: false }), '-_8')
|
||||
assert.throws(() => encodeBase64([1, 2, 3] as unknown as Uint8Array), /Uint8Array/)
|
||||
})
|
||||
|
||||
test('Base64 codec round-trips chunk boundaries and matches the Node reference', () => {
|
||||
for (const length of [0, 1, 2, 3, 4, 31, 32, 33, 255, 256, 257]) {
|
||||
const input = Uint8Array.from({ length }, (_, index) => (index * 197 + 31) & 0xff)
|
||||
const standard = encodeBase64(input)
|
||||
const urlSafe = encodeBase64(input, { urlSafe: true, padding: false })
|
||||
assert.equal(standard, Buffer.from(input).toString('base64'))
|
||||
assert.equal(urlSafe, Buffer.from(input).toString('base64url'))
|
||||
assert.deepEqual(decodeBase64(standard), input)
|
||||
assert.deepEqual(decodeBase64(urlSafe), input)
|
||||
}
|
||||
})
|
||||
|
||||
test('canonical JSON rejects values that have no cross-runtime protocol representation', () => {
|
||||
assert.throws(() => canonicalJson(Number.NaN), /finite numbers/)
|
||||
assert.throws(() => canonicalJson(Number.POSITIVE_INFINITY), /finite numbers/)
|
||||
assert.throws(() => canonicalJson(() => undefined), /does not support function/)
|
||||
assert.throws(() => canonicalJson(Symbol('value')), /does not support symbol/)
|
||||
assert.throws(() => canonicalJson([undefined]), /cannot contain holes or undefined/)
|
||||
assert.throws(() => canonicalJson(new Array(1)), /cannot contain holes or undefined/)
|
||||
assert.throws(() => canonicalJson(new Date(0)), /only supports plain objects/)
|
||||
})
|
||||
@ -13,13 +13,39 @@ pnpm exec xuqm-rn init
|
||||
pnpm run xuqm:doctor
|
||||
```
|
||||
|
||||
`xuqm-rn init` 生成 schema v3 `xuqm.config.json` 并补充最少脚本。版本只有两条明确来源:
|
||||
`xuqm-rn init` 生成 schema v3 `xuqm.modules.json` 并补充最少脚本。版本只有两条明确来源:
|
||||
|
||||
- 完整 App 版本来自宿主 `package.json.version`,医网信新包从 `8.0.0` 开始。
|
||||
- 插件默认版本来自 `xuqm.config.json.pluginVersion`,从 `1.0.0` 开始;仅独立发布的模块覆盖 `moduleVersion`。
|
||||
- 插件默认版本来自 `xuqm.modules.json.pluginVersion`,从 `1.0.0` 开始;仅独立发布的模块覆盖 `moduleVersion`。
|
||||
|
||||
`xuqm.modules.json` 分别使用 Android `packageName` 和 iOS `iosBundleId` 保存平台宿主
|
||||
身份,只在目标平台 build/package/release 时要求相应字段。ZIP wire 字段仍统一为
|
||||
`packageName`;CLI 通过唯一平台解析器写入对应身份,发布前从 ZIP 内重新读取并与同一
|
||||
解析结果精确比较,缺失或不一致时在联网前拒绝发布。
|
||||
|
||||
Debug 启动只注册并校验插件,不访问远程更新服务。开发页如需验证平台能力,显式调用
|
||||
`UpdateSDK.developer.checkStartupOnce()` 或
|
||||
`UpdateSDK.developer.checkAndInstallPluginOnce(moduleId)`;该状态不会跨重启保留。
|
||||
|
||||
`xuqm-rn package android` 会为每次 Release 自动生成递增 `versionCode` 与不可变
|
||||
`buildId`,并同时注入 APK、插件 manifest 和 Source Map 身份。宿主不得手工维护
|
||||
versionCode;Jenkins 可通过 `XUQM_VERSION_CODE`、`XUQM_BUILD_ID` 固定本次流水线身份。
|
||||
同一 `versionName` 的重复测试包仍会获得新的 versionCode/buildId,从而正常覆盖安装并
|
||||
加载本次 Bundle。
|
||||
|
||||
app/buz 使用 `commonVersionRange` 声明 SemVer 兼容范围,并使用 `minNativeApiLevel` 声明最低原生能力。Bundle 只接受 SHA-256,不使用 MD5。
|
||||
|
||||
宿主的唯一 Metro 配置使用 update 组合器;它已经包含 common 的自动初始化配置:
|
||||
|
||||
```js
|
||||
const { getDefaultConfig } = require('@react-native/metro-config')
|
||||
const { withXuqmModuleConfig } = require('@xuqm/rn-update/metro')
|
||||
|
||||
module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname))
|
||||
```
|
||||
|
||||
日常 `start/android` 没有模块构建上下文,保持标准 Metro 行为。构建 app/buz 时 CLI 自动先生成 startup/common 共享模块表,并为每个插件分配互不重叠的稳定区间;宿主不得复制模块 ID 缓存或维护多份 Metro 配置。
|
||||
|
||||
## 开发与打包
|
||||
|
||||
- `pnpm android`:安装 Debug 包、连接 Metro、支持热刷新。
|
||||
@ -27,6 +53,11 @@ app/buz 使用 `commonVersionRange` 声明 SemVer 兼容范围,并使用 `minN
|
||||
- `pnpm release:android -- --apk`:生成包含同一批插件的 Release APK。
|
||||
- `pnpm publish:android`:上传插件产物;SDK npm 制品发布只能通过 Jenkins。
|
||||
|
||||
发布使用的 `XUQM_API_TOKEN` 只能由 Jenkins Credentials 注入环境变量,不属于
|
||||
`xuqm.modules.json` 或 `config.xuqmconfig`。CLI 不读取项目文件中的 Token,避免可用凭据
|
||||
进入源码、插件包或构建日志。
|
||||
配置校验会直接拒绝 `release.apiToken`;发布凭据只能来自 `XUQM_API_TOKEN`。
|
||||
|
||||
Android 宿主只应用一次 SDK 脚本:
|
||||
|
||||
```groovy
|
||||
@ -35,13 +66,47 @@ 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;无需也不允许在
|
||||
`xuqm.config.json` 再声明一份引擎。manifest 和发布请求会记录 `bundleFormat`。
|
||||
`xuqm.modules.json` 再声明一份引擎。manifest 和发布请求会记录 `bundleFormat`。
|
||||
|
||||
## 运行时规则
|
||||
|
||||
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。
|
||||
@ -88,7 +153,25 @@ const plan = await UpdateSDK.checkAndInstallPlugin('prescription', {
|
||||
})
|
||||
```
|
||||
|
||||
SDK 会验证所有已安装模块在目标 common 下仍兼容,然后整组下载、校验、staging、激活、确认或回滚。Bundle 版本与事务状态只保存在原生 state;宿主不得另建 AsyncStorage 版本表,也不得逐层传递版本或路径状态。
|
||||
SDK 会验证所有已安装模块在目标 common 下仍兼容,并拒绝 `builtAgainstNativeBaselineId` 与当前完整包不一致的候选,然后整组下载、校验、staging、激活、确认或回滚。Bundle 版本与事务状态只保存在原生 state;宿主不得另建 AsyncStorage 版本表,也不得逐层传递版本或路径状态。
|
||||
|
||||
所有公开检查、下载和安装 API 都要求平台明确开启 `features.update`。关闭时在任何网络
|
||||
或原生操作前抛出 `UpdateDisabledError`(`code: UPDATE_DISABLED`);登录、启动等自动
|
||||
后台路径会吞掉该状态并正常返回,不影响宿主使用。
|
||||
|
||||
release-set 检查请求必须携带当前 `appVersion`、`nativeApiLevel` 和
|
||||
`nativeBaselineId`;原生基线缺失时客户端在请求前阻断。服务端候选必须提供
|
||||
`keyId/signature`,签名覆盖以下不可变 canonical manifest:
|
||||
|
||||
`appKey, packageName, platform, moduleId, type, version, appVersionRange, builtAgainstNativeBaselineId, buildId, bundleFormat, minNativeApiLevel, bundleSha256, archiveSha256`
|
||||
|
||||
app/buz 另包含 `commonVersionRange`;common 模块不构造该字段。canonical JSON 与
|
||||
Config V2 一致:UTF-8、键按字典序、无空白、null/undefined 字段省略。
|
||||
|
||||
客户端在生成计划前及安装前各验签一次,未知 keyId、签名错误、宿主身份不匹配或任一
|
||||
字段被篡改都会拒绝。响应 `sha256` 以 `archiveSha256` 进入签名清单,下载后使用同值
|
||||
校验 ZIP;Android 解包层还会把已签名的
|
||||
`buildId/bundleFormat/bundleSha256` 与包内 manifest 逐项比较。
|
||||
|
||||
需要先展示插件更新提示的项目,分别调用纯检查和安装 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,8 @@
|
||||
package com.xuqm.update;
|
||||
|
||||
import android.content.Context;
|
||||
import android.system.ErrnoException;
|
||||
import android.system.Os;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Base64;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
|
||||
@ -21,22 +21,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 +51,16 @@ 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";
|
||||
private static final String RESET_PREFERENCES = "xuqm-update-reset";
|
||||
private static final String RESET_GENERATION = "generation";
|
||||
private static final String CONFIRMED_RESET_GENERATION = "confirmed-generation";
|
||||
|
||||
/** APK 内嵌 manifest 很小且在进程生命周期内不可变,只解析一次。 */
|
||||
private JSONObject embeddedManifestCache;
|
||||
|
||||
public XuqmBundleModule(ReactApplicationContext reactContext) {
|
||||
super(reactContext);
|
||||
@ -60,6 +77,52 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
promise.resolve(NATIVE_API_LEVEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 原生恢复页的唯一清理入口。只删除 Update SDK 私有 rn-bundles 目录;
|
||||
* 宿主账号、业务缓存和其它 SDK 数据均不在该目录中。
|
||||
*/
|
||||
@ReactMethod
|
||||
public void resetToEmbedded(Promise promise) {
|
||||
try {
|
||||
deleteRecursively(getBundleDir());
|
||||
embeddedManifestCache = null;
|
||||
SharedPreferences preferences = getReactApplicationContext()
|
||||
.getSharedPreferences(RESET_PREFERENCES, Context.MODE_PRIVATE);
|
||||
int generation = preferences.getInt(RESET_GENERATION, 0) + 1;
|
||||
preferences.edit().putInt(RESET_GENERATION, generation).apply();
|
||||
promise.resolve(true);
|
||||
} catch (Exception error) {
|
||||
promise.reject("BUNDLE_RESET_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void getResetGeneration(Promise promise) {
|
||||
SharedPreferences preferences = getReactApplicationContext()
|
||||
.getSharedPreferences(RESET_PREFERENCES, Context.MODE_PRIVATE);
|
||||
promise.resolve(preferences.getInt(RESET_GENERATION, 0));
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void getConfirmedResetGeneration(Promise promise) {
|
||||
SharedPreferences preferences = getReactApplicationContext()
|
||||
.getSharedPreferences(RESET_PREFERENCES, Context.MODE_PRIVATE);
|
||||
promise.resolve(preferences.getInt(CONFIRMED_RESET_GENERATION, 0));
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void confirmResetGeneration(int generation, Promise promise) {
|
||||
SharedPreferences preferences = getReactApplicationContext()
|
||||
.getSharedPreferences(RESET_PREFERENCES, Context.MODE_PRIVATE);
|
||||
int current = preferences.getInt(RESET_GENERATION, 0);
|
||||
if (generation != current) {
|
||||
promise.reject("BUNDLE_RESET_STALE", "Reset generation changed before confirmation");
|
||||
return;
|
||||
}
|
||||
preferences.edit().putInt(CONFIRMED_RESET_GENERATION, generation).apply();
|
||||
promise.resolve(true);
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void bundleExists(String moduleId, Promise promise) {
|
||||
try {
|
||||
@ -105,8 +168,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 +191,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 +262,28 @@ 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("pendingBuildId", packageManifest.getString("buildId"));
|
||||
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 +292,6 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
state.put("releaseId", releaseId);
|
||||
state.put("status", "staged");
|
||||
writeState(moduleId, state);
|
||||
stagedModules.add(moduleId);
|
||||
moduleIds.put(moduleId);
|
||||
}
|
||||
|
||||
@ -269,8 +383,8 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
JSONObject release = readReleaseStateOrNull();
|
||||
if (release != null) {
|
||||
if ("pending_confirmation".equals(release.optString("status"))
|
||||
&& release.optInt("launchAttempts", 0) == 0) {
|
||||
release.put("launchAttempts", 1);
|
||||
&& release.optInt("launchAttempts", 0) < 2) {
|
||||
release.put("launchAttempts", release.optInt("launchAttempts", 0) + 1);
|
||||
writeReleaseState(release);
|
||||
} else {
|
||||
JSONArray modules = release.getJSONArray("modules");
|
||||
@ -308,7 +422,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 +432,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 +472,215 @@ 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, manifest, 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, manifest, entry, embeddedHash, active.length());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String installedVersion = state.optString(metadataPrefix + "Version", "");
|
||||
String embeddedVersion = entry.optString("moduleVersion", "0.0.0");
|
||||
final int versionComparison;
|
||||
try {
|
||||
versionComparison = XuqmSemanticVersion.compare(installedVersion, embeddedVersion);
|
||||
} catch (IllegalArgumentException invalidVersion) {
|
||||
return false;
|
||||
}
|
||||
if (versionComparison < 0) return false;
|
||||
if (versionComparison == 0
|
||||
&& !embeddedHash.equalsIgnoreCase(state.optString(metadataPrefix + "Hash", ""))) {
|
||||
// 相同展示版本的新版 APK 以摘要识别真实构建,覆盖旧本地副本;
|
||||
// 真正更高且兼容当前原生基线的远程插件仍保留。
|
||||
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", ""))
|
||||
&& manifest.optString("buildId", "").equals(state.optString("activeBuildId", ""))
|
||||
&& 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 manifest,
|
||||
JSONObject entry,
|
||||
String embeddedHash,
|
||||
long byteLength) throws Exception {
|
||||
writeState(
|
||||
moduleId,
|
||||
createEmbeddedState(
|
||||
moduleId,
|
||||
manifest.optString("buildId", ""),
|
||||
entry,
|
||||
embeddedHash,
|
||||
byteLength));
|
||||
}
|
||||
|
||||
private JSONObject createEmbeddedState(
|
||||
String moduleId,
|
||||
String buildId,
|
||||
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 (!buildId.isEmpty()) state.put("activeBuildId", buildId);
|
||||
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 +710,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 +739,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 +776,29 @@ 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,
|
||||
manifest.optString("buildId", ""),
|
||||
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));
|
||||
}
|
||||
@ -512,32 +847,44 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
|
||||
private void copyActiveToRollbackMetadata(JSONObject state) throws Exception {
|
||||
copyJsonValue(state, "activeVersion", state, "rollbackVersion");
|
||||
copyJsonValue(state, "activeBuildId", state, "rollbackBuildId");
|
||||
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, "pendingBuildId", "activeBuildId");
|
||||
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, "rollbackBuildId", "activeBuildId");
|
||||
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 +893,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", "activeBuildId", "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", "pendingBuildId", "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", "rollbackBuildId", "rollbackHash", "rollbackByteLength", "rollbackType", "rollbackAppVersionRange", "rollbackBuiltAgainstNativeBaselineId", "rollbackCommonVersionRange", "rollbackMinNativeApiLevel", "rollbackPackageHash", "rollbackAssetFiles"}) state.remove(key);
|
||||
}
|
||||
|
||||
private void validateModuleId(String moduleId) {
|
||||
@ -569,23 +916,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 +926,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,267 @@
|
||||
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_ENTRY_UNCOMPRESSED_BYTES = 100L * 1024L * 1024L;
|
||||
private static final long MAX_UNCOMPRESSED_BYTES = 100L * 1024L * 1024L;
|
||||
private static final int MAX_ARCHIVE_ENTRIES = 4096;
|
||||
|
||||
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) {
|
||||
entryCount += 1;
|
||||
validateArchiveEntryCount(entryCount);
|
||||
// CLI 的 zipSync 只生成文件 entry;显式目录 entry 不属于 v1 协议,
|
||||
// 与服务端保持一致拒绝,避免目录绕过 entry 数量和路径检查。
|
||||
if (entry.isDirectory()) {
|
||||
throw new IOException("Plugin package directory entries are not allowed");
|
||||
}
|
||||
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)) {
|
||||
long entryUncompressedBytes = 0L;
|
||||
int count;
|
||||
while ((count = input.read(buffer)) != -1) {
|
||||
entryUncompressedBytes += count;
|
||||
validateEntryUncompressedSize(entryUncompressedBytes);
|
||||
uncompressedBytes += count;
|
||||
if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) {
|
||||
throw new IOException("Plugin package exceeds the uncompressed size limit");
|
||||
}
|
||||
output.write(buffer, 0, count);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return archiveEntries;
|
||||
}
|
||||
|
||||
static void validateArchiveEntryCount(int entryCount) throws IOException {
|
||||
if (entryCount > MAX_ARCHIVE_ENTRIES) {
|
||||
throw new IOException("Plugin package has too many entries");
|
||||
}
|
||||
}
|
||||
|
||||
static void validateEntryUncompressedSize(long entryBytes) throws IOException {
|
||||
if (entryBytes > MAX_ENTRY_UNCOMPRESSED_BYTES) {
|
||||
throw new IOException("Plugin package entry exceeds the uncompressed size limit");
|
||||
}
|
||||
}
|
||||
|
||||
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");
|
||||
}
|
||||
if (manifest.optString("buildId").trim().isEmpty()) {
|
||||
throw new IOException("Plugin package buildId is missing");
|
||||
}
|
||||
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, "buildId");
|
||||
requireEqual(candidate, manifest, "bundleFormat");
|
||||
requireEqual(candidate, manifest, "bundleSha256");
|
||||
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,49 @@
|
||||
package com.xuqm.update;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
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"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recursiveDeleteRemovesOnlyTheSelectedSdkDirectory() throws Exception {
|
||||
File root = new File(System.getProperty("java.io.tmpdir"), "xuqm-reset-test-" + System.nanoTime());
|
||||
File updateDirectory = new File(root, "rn-bundles/common");
|
||||
File hostDirectory = new File(root, "host-business");
|
||||
assertTrue(updateDirectory.mkdirs());
|
||||
assertTrue(hostDirectory.mkdirs());
|
||||
XuqmBundleStorage.atomicWrite(
|
||||
new File(updateDirectory, "active.bundle"),
|
||||
"bundle".getBytes(StandardCharsets.UTF_8));
|
||||
XuqmBundleStorage.atomicWrite(
|
||||
new File(hostDirectory, "account.json"),
|
||||
"account".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
XuqmBundleStorage.deleteRecursively(new File(root, "rn-bundles"));
|
||||
|
||||
assertFalse(new File(root, "rn-bundles").exists());
|
||||
assertTrue(new File(hostDirectory, "account.json").exists());
|
||||
XuqmBundleStorage.deleteRecursively(root);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,35 @@
|
||||
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"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void enforcesTheV1ArchiveEntryBoundaries() throws Exception {
|
||||
XuqmPluginPackageStager.validateArchiveEntryCount(4096);
|
||||
assertThrows(
|
||||
java.io.IOException.class,
|
||||
() -> XuqmPluginPackageStager.validateArchiveEntryCount(4097));
|
||||
|
||||
XuqmPluginPackageStager.validateEntryUncompressedSize(100L * 1024L * 1024L);
|
||||
assertThrows(
|
||||
java.io.IOException.class,
|
||||
() -> XuqmPluginPackageStager.validateEntryUncompressedSize(
|
||||
100L * 1024L * 1024L + 1L));
|
||||
}
|
||||
}
|
||||
@ -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,23 +40,47 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask {
|
||||
@PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract RegularFileProperty getPackageFile()
|
||||
|
||||
@InputFile
|
||||
@PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract RegularFileProperty getSignedConfigFile()
|
||||
|
||||
/**
|
||||
* RN/原生源码与打包配置共同决定 bundle、资源和 native baseline。
|
||||
* 显式建模后 Gradle 才能安全复用上次输出,禁止用永久 out-of-date 代替依赖声明。
|
||||
*/
|
||||
@InputFiles
|
||||
@PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract ConfigurableFileCollection getSourceFiles()
|
||||
|
||||
@Input
|
||||
abstract Property<String> getNodeExecutable()
|
||||
|
||||
@Input
|
||||
abstract Property<String> getAppVersion()
|
||||
|
||||
@Input
|
||||
abstract Property<String> getBuildId()
|
||||
|
||||
@Input
|
||||
abstract Property<String> getBugCollectMode()
|
||||
|
||||
@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)
|
||||
environment("XUQM_BUILD_ID", buildId.get())
|
||||
environment("XUQM_BUGCOLLECT_MODE", bugCollectMode.get())
|
||||
commandLine(
|
||||
nodeExecutable.get(),
|
||||
cliFile.get().asFile.absolutePath,
|
||||
@ -65,6 +92,23 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask {
|
||||
appVersion.get(),
|
||||
)
|
||||
}.assertNormalExitValue()
|
||||
project.copy {
|
||||
from(signedConfigFile.get().asFile)
|
||||
into(assetsOutputDirectory.get().dir("config"))
|
||||
rename { "config.xuqmconfig" }
|
||||
}
|
||||
|
||||
// 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,10 +117,43 @@ 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() }
|
||||
.orElse(false)
|
||||
def xuqmReleaseVersionCode = providers.gradleProperty("XUQM_VERSION_CODE")
|
||||
.orElse(providers.environmentVariable("XUQM_VERSION_CODE"))
|
||||
if (xuqmReleaseVersionCode.isPresent()) {
|
||||
def parsedVersionCode = Integer.parseInt(xuqmReleaseVersionCode.get())
|
||||
if (parsedVersionCode <= 0) {
|
||||
throw new GradleException("XUQM_VERSION_CODE must be a positive integer")
|
||||
}
|
||||
android.defaultConfig.versionCode = parsedVersionCode
|
||||
}
|
||||
def xuqmBuildId = providers.gradleProperty("XUQM_BUILD_ID")
|
||||
.orElse(providers.environmentVariable("XUQM_BUILD_ID"))
|
||||
.orElse("development")
|
||||
def xuqmBugCollectMode = providers.gradleProperty("XUQM_BUGCOLLECT_MODE")
|
||||
.orElse(providers.environmentVariable("XUQM_BUGCOLLECT_MODE"))
|
||||
.orElse("platform")
|
||||
if (!["platform", "disabled"].contains(xuqmBugCollectMode.get())) {
|
||||
throw new GradleException("XUQM_BUGCOLLECT_MODE must be platform or disabled")
|
||||
}
|
||||
def xuqmSignedConfigDirectory = new File(xuqmProjectRoot, "src/assets/config")
|
||||
def xuqmSignedConfigCandidates = xuqmSignedConfigDirectory.isDirectory()
|
||||
? (xuqmSignedConfigDirectory.listFiles()?.findAll {
|
||||
it.isFile() && it.name.toLowerCase(Locale.ROOT).endsWith(".xuqmconfig")
|
||||
} ?: []).sort { left, right -> left.name <=> right.name }
|
||||
: []
|
||||
if (xuqmSignedConfigCandidates.size() > 1) {
|
||||
throw new GradleException(
|
||||
"src/assets/config must contain exactly one .xuqmconfig file; found ${xuqmSignedConfigCandidates.size()}"
|
||||
)
|
||||
}
|
||||
|
||||
def prepareXuqmBundles = tasks.register(
|
||||
"prepareXuqmEmbeddedBundles",
|
||||
@ -86,8 +163,52 @@ def prepareXuqmBundles = tasks.register(
|
||||
description = "Builds and embeds all configured Xuqm RN plugin bundles."
|
||||
projectRootDirectory.set(xuqmProjectRoot)
|
||||
cliFile.set(new File(xuqmProjectRoot, "node_modules/@xuqm/rn-update/scripts/xuqm-rn.mjs"))
|
||||
configFile.set(new File(xuqmProjectRoot, "xuqm.config.json"))
|
||||
configFile.set(new File(xuqmProjectRoot, "xuqm.modules.json"))
|
||||
packageFile.set(new File(xuqmProjectRoot, "package.json"))
|
||||
signedConfigFile.set(
|
||||
xuqmSignedConfigCandidates
|
||||
? xuqmSignedConfigCandidates.first()
|
||||
: new File(xuqmSignedConfigDirectory, "config.xuqmconfig")
|
||||
)
|
||||
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 内容。
|
||||
// JS/TS 源码决定 bundle 内容,android/ios 决定 native baseline,缺一不可。
|
||||
xuqmLocalDependencyDirectories.each { dependencyDirectory ->
|
||||
sourceFiles.from(project.fileTree(dependencyDirectory) {
|
||||
include("src/**")
|
||||
include("metro/**")
|
||||
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 +217,21 @@ def prepareXuqmBundles = tasks.register(
|
||||
}
|
||||
version.toString()
|
||||
})
|
||||
outputDirectory.set(layout.buildDirectory.dir("generated/xuqm/assets"))
|
||||
outputs.upToDateWhen { false }
|
||||
buildId.set(xuqmBuildId)
|
||||
bugCollectMode.set(xuqmBugCollectMode)
|
||||
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 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
103
packages/update/metro/index.js
普通文件
103
packages/update/metro/index.js
普通文件
@ -0,0 +1,103 @@
|
||||
const fs = require('node:fs')
|
||||
const path = require('node:path')
|
||||
|
||||
const { withXuqmConfig } = require('@xuqm/rn-common/metro')
|
||||
|
||||
const MODULE_RANGE_SIZE = 10_000_000
|
||||
|
||||
function readCache(file) {
|
||||
if (!fs.existsSync(file)) return {}
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(file, 'utf8'))
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function writeCache(file, value) {
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true })
|
||||
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function hasModule(cache, modulePath) {
|
||||
return Object.prototype.hasOwnProperty.call(cache, modulePath)
|
||||
}
|
||||
|
||||
function isRuntimePrelude(modulePath) {
|
||||
return modulePath.includes('__prelude__') || modulePath.includes('polyfills')
|
||||
}
|
||||
|
||||
function createModuleSerializer(environment = process.env) {
|
||||
const moduleId = environment.XUQM_MODULE_ID
|
||||
const moduleType = environment.XUQM_MODULE_TYPE
|
||||
if (!moduleId || !moduleType) return null
|
||||
|
||||
const cacheFile = path.resolve(
|
||||
environment.XUQM_MODULE_CACHE_FILE ?? '.xuqm-cache/common-module-ids.json',
|
||||
)
|
||||
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
|
||||
? Object.values(sharedCache).reduce((maximum, value) => Math.max(maximum, Number(value)), -1) +
|
||||
1
|
||||
: (Number.isFinite(moduleIndex) && moduleIndex >= 0 ? moduleIndex + 1 : 1) * MODULE_RANGE_SIZE
|
||||
const localIds = new Map()
|
||||
|
||||
return {
|
||||
createModuleIdFactory() {
|
||||
return modulePath => {
|
||||
if (hasModule(sharedCache, modulePath)) return sharedCache[modulePath]
|
||||
const existing = localIds.get(modulePath)
|
||||
if (existing !== undefined) return existing
|
||||
const id = nextId++
|
||||
localIds.set(modulePath, id)
|
||||
if (writesSharedCache) {
|
||||
sharedCache[modulePath] = id
|
||||
writeCache(cacheFile, sharedCache)
|
||||
}
|
||||
return id
|
||||
}
|
||||
},
|
||||
processModuleFilter(module) {
|
||||
if (moduleType === 'startup') return true
|
||||
if (isRuntimePrelude(module.path)) return false
|
||||
return !inheritedSharedModules.has(module.path)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 同一份 Metro 配置同时服务日常开发和插件构建。CLI 构建插件时注入模块上下文;
|
||||
* 普通 `pnpm android/start` 没有这些变量,因此保持标准 React Native 行为。
|
||||
*/
|
||||
function withXuqmModuleConfig(metroConfig) {
|
||||
const configured = withXuqmConfig(metroConfig)
|
||||
const moduleSerializer = createModuleSerializer()
|
||||
if (!moduleSerializer) return configured
|
||||
const configuredFilter = configured.serializer?.processModuleFilter
|
||||
return {
|
||||
...configured,
|
||||
serializer: {
|
||||
...configured.serializer,
|
||||
...moduleSerializer,
|
||||
processModuleFilter(module) {
|
||||
return (
|
||||
(configuredFilter ? configuredFilter(module) : true) &&
|
||||
moduleSerializer.processModuleFilter(module)
|
||||
)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createModuleSerializer,
|
||||
withXuqmModuleConfig,
|
||||
}
|
||||
@ -6,9 +6,18 @@
|
||||
"main": "src/index.ts",
|
||||
"react-native": "src/index.ts",
|
||||
"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"
|
||||
},
|
||||
"files": [
|
||||
"src",
|
||||
"android",
|
||||
"metro",
|
||||
"scripts",
|
||||
"templates",
|
||||
"react-native.config.js",
|
||||
@ -27,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",
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
export const BUGCOLLECT_ARTIFACT_UPLOAD_PATH = '/bugcollect/v1/artifacts/upload'
|
||||
|
||||
/** Source Map 与插件发布使用同一流水线 Bearer,不允许匿名上传构建制品。 */
|
||||
export function createArtifactUploadRequest(apiToken, form) {
|
||||
if (typeof apiToken !== 'string' || apiToken.trim() === '') {
|
||||
throw new Error('artifact upload requires an API token')
|
||||
}
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
body: form,
|
||||
}
|
||||
}
|
||||
|
||||
/** RN Source Map 与 Android R8 Mapping 共用 artifacts 入口,但类型和身份字段明确分离。 */
|
||||
export function createRnSourceMapForm({
|
||||
appKey,
|
||||
platform,
|
||||
appVersion,
|
||||
buildId,
|
||||
moduleId,
|
||||
moduleVersion,
|
||||
bundleHash,
|
||||
fileBytes,
|
||||
fileName,
|
||||
}) {
|
||||
const form = new FormData()
|
||||
form.append('artifactType', 'RN_SOURCEMAP')
|
||||
form.append('appKey', appKey)
|
||||
form.append('platform', platform)
|
||||
form.append('appVersion', appVersion)
|
||||
form.append('buildId', buildId)
|
||||
form.append('moduleId', moduleId)
|
||||
form.append('moduleVersion', moduleVersion)
|
||||
form.append('bundleHash', bundleHash)
|
||||
form.append('file', new Blob([fileBytes]), fileName)
|
||||
return form
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
BUGCOLLECT_ARTIFACT_UPLOAD_PATH,
|
||||
createArtifactUploadRequest,
|
||||
createRnSourceMapForm,
|
||||
} from './artifact-upload.mjs'
|
||||
|
||||
test('RN Source Map uses the frozen artifact upload contract', () => {
|
||||
const form = createRnSourceMapForm({
|
||||
appKey: 'app',
|
||||
platform: 'android',
|
||||
appVersion: '8.0.0',
|
||||
buildId: 'build-1',
|
||||
moduleId: 'orders',
|
||||
moduleVersion: '1.0.2',
|
||||
bundleHash: 'a'.repeat(64),
|
||||
fileBytes: new Uint8Array([1, 2, 3]),
|
||||
fileName: 'orders.android.map',
|
||||
})
|
||||
assert.equal(BUGCOLLECT_ARTIFACT_UPLOAD_PATH, '/bugcollect/v1/artifacts/upload')
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(
|
||||
[...form.entries()]
|
||||
.filter(([, value]) => typeof value === 'string')
|
||||
.map(([key, value]) => [key, value]),
|
||||
),
|
||||
{
|
||||
artifactType: 'RN_SOURCEMAP',
|
||||
appKey: 'app',
|
||||
platform: 'android',
|
||||
appVersion: '8.0.0',
|
||||
buildId: 'build-1',
|
||||
moduleId: 'orders',
|
||||
moduleVersion: '1.0.2',
|
||||
bundleHash: 'a'.repeat(64),
|
||||
},
|
||||
)
|
||||
assert.equal(form.get('file') instanceof Blob, true)
|
||||
const request = createArtifactUploadRequest('release-token', form)
|
||||
assert.deepEqual(request.headers, { Authorization: 'Bearer release-token' })
|
||||
assert.equal(request.body, form)
|
||||
assert.throws(() => createArtifactUploadRequest('', form), /requires an API token/)
|
||||
})
|
||||
@ -0,0 +1,48 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, rmSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
const require = createRequire(import.meta.url)
|
||||
const { createModuleSerializer } = require('../metro')
|
||||
|
||||
function context(cacheFile, moduleId, moduleType, moduleIndex, reset = false) {
|
||||
return {
|
||||
XUQM_MODULE_CACHE_FILE: cacheFile,
|
||||
XUQM_MODULE_ID: moduleId,
|
||||
XUQM_MODULE_INDEX: String(moduleIndex),
|
||||
XUQM_MODULE_TYPE: moduleType,
|
||||
XUQM_RESET_MODULE_CACHE: reset ? 'true' : 'false',
|
||||
}
|
||||
}
|
||||
|
||||
test('Metro module ranges share startup/common and isolate every app or buz', () => {
|
||||
const directory = mkdtempSync(path.join(tmpdir(), 'xuqm-metro-'))
|
||||
const cacheFile = path.join(directory, 'ids.json')
|
||||
try {
|
||||
const startup = createModuleSerializer(context(cacheFile, 'startup', 'startup', 0, true))
|
||||
const startupFactory = startup.createModuleIdFactory()
|
||||
assert.equal(startupFactory('/runtime/prelude.js'), 0)
|
||||
|
||||
const common = createModuleSerializer(context(cacheFile, 'common', 'common', 1))
|
||||
assert.equal(common.processModuleFilter({ path: '/runtime/prelude.js' }), false)
|
||||
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))
|
||||
assert.equal(app.processModuleFilter({ path: '/shared/common.ts' }), false)
|
||||
assert.equal(app.createModuleIdFactory()('/app/entry.ts'), 30_000_000)
|
||||
assert.equal(buz.createModuleIdFactory()('/buz/entry.ts'), 40_000_000)
|
||||
} finally {
|
||||
rmSync(directory, { force: true, recursive: true })
|
||||
}
|
||||
})
|
||||
@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
@ -0,0 +1,48 @@
|
||||
const BUGCOLLECT_MODES = new Set(['platform', 'disabled'])
|
||||
|
||||
/**
|
||||
* Release 打包参数的唯一解析入口。CLI 同时接收标准的 `--bugcollect=disabled`
|
||||
* 和命令行工具常见的空格写法,但后续流程只消费归一化结果。
|
||||
*/
|
||||
export function parsePackageOptions(optionArgs) {
|
||||
const remaining = [...optionArgs]
|
||||
const apkIndex = remaining.indexOf('--apk')
|
||||
const apk = apkIndex >= 0
|
||||
if (apk) remaining.splice(apkIndex, 1)
|
||||
|
||||
const parsedBugCollect = parseBugCollectOption(remaining)
|
||||
if (parsedBugCollect.remaining.length) {
|
||||
throw new Error(`unknown option(s): ${parsedBugCollect.remaining.join(' ')}`)
|
||||
}
|
||||
return { apk, bugCollectMode: parsedBugCollect.bugCollectMode }
|
||||
}
|
||||
|
||||
export function parseBugCollectOption(optionArgs, defaultMode = 'platform') {
|
||||
const remaining = [...optionArgs]
|
||||
const equalsOptions = remaining.filter(option => option.startsWith('--bugcollect='))
|
||||
const spaceIndexes = remaining
|
||||
.map((option, index) => (option === '--bugcollect' ? index : -1))
|
||||
.filter(index => index >= 0)
|
||||
if (equalsOptions.length + spaceIndexes.length > 1) {
|
||||
throw new Error('--bugcollect must be specified only once')
|
||||
}
|
||||
|
||||
let bugCollectMode = defaultMode
|
||||
if (equalsOptions.length === 1) {
|
||||
const index = remaining.indexOf(equalsOptions[0])
|
||||
bugCollectMode = equalsOptions[0].slice('--bugcollect='.length)
|
||||
remaining.splice(index, 1)
|
||||
} else if (spaceIndexes.length === 1) {
|
||||
const index = spaceIndexes[0]
|
||||
if (remaining[index + 1] === undefined) {
|
||||
throw new Error('--bugcollect requires a value')
|
||||
}
|
||||
bugCollectMode = remaining[index + 1]
|
||||
remaining.splice(index, 2)
|
||||
}
|
||||
|
||||
if (!BUGCOLLECT_MODES.has(bugCollectMode)) {
|
||||
throw new Error('--bugcollect must be platform or disabled')
|
||||
}
|
||||
return { bugCollectMode, remaining }
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { parseBugCollectOption, parsePackageOptions } from './package-options.mjs'
|
||||
|
||||
test('package options normalize equals and space bugcollect syntax', () => {
|
||||
assert.deepEqual(parsePackageOptions(['--bugcollect=disabled']), {
|
||||
apk: false,
|
||||
bugCollectMode: 'disabled',
|
||||
})
|
||||
assert.deepEqual(parsePackageOptions(['--apk', '--bugcollect', 'disabled']), {
|
||||
apk: true,
|
||||
bugCollectMode: 'disabled',
|
||||
})
|
||||
assert.deepEqual(parsePackageOptions([]), {
|
||||
apk: false,
|
||||
bugCollectMode: 'platform',
|
||||
})
|
||||
})
|
||||
|
||||
test('publish and package share one BugCollect option parser', () => {
|
||||
assert.deepEqual(parseBugCollectOption(['--module', 'app', '--bugcollect=disabled']), {
|
||||
bugCollectMode: 'disabled',
|
||||
remaining: ['--module', 'app'],
|
||||
})
|
||||
assert.deepEqual(parseBugCollectOption(['--note', 'release'], 'disabled'), {
|
||||
bugCollectMode: 'disabled',
|
||||
remaining: ['--note', 'release'],
|
||||
})
|
||||
})
|
||||
|
||||
test('package options reject duplicate, missing and invalid values', () => {
|
||||
assert.throws(
|
||||
() => parsePackageOptions(['--bugcollect=disabled', '--bugcollect', 'platform']),
|
||||
/only once/,
|
||||
)
|
||||
assert.throws(() => parsePackageOptions(['--bugcollect']), /requires a value/)
|
||||
assert.throws(() => parsePackageOptions(['--bugcollect=unknown']), /platform or disabled/)
|
||||
assert.throws(() => parsePackageOptions(['--unknown']), /unknown option/)
|
||||
})
|
||||
@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* 整包与插件发布共用的配置验证门禁。只阻断新产物;已安装 App 不在线查询吊销状态。
|
||||
*/
|
||||
export async function validateReleaseConfig({ projectRoot, packageName }) {
|
||||
const hostRequire = createRequire(path.join(projectRoot, 'package.json'))
|
||||
const commonMetro = hostRequire('@xuqm/rn-common/metro')
|
||||
const configFile = commonMetro.findConfigFile(projectRoot)
|
||||
if (!configFile) {
|
||||
throw new Error('exactly one .xuqmconfig file is required in src/assets/config for Release')
|
||||
}
|
||||
|
||||
const content = readFileSync(configFile, 'utf8').trim()
|
||||
const resolved = commonMetro.decryptConfigAtBuildTime(content)
|
||||
if (typeof packageName !== 'string' || packageName.trim() === '') {
|
||||
throw new Error('A package identity is required for Release validation')
|
||||
}
|
||||
const response = await fetch(
|
||||
`${String(resolved.serverUrl).replace(/\/$/, '')}/api/sdk/build/config/validate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, packageName }),
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Release config validation service failed: HTTP ${response.status}`)
|
||||
}
|
||||
const payload = await response.json()
|
||||
const result = payload.data ?? payload
|
||||
if (result.valid !== true) {
|
||||
throw new Error(
|
||||
`Release config rejected (${result.errorCode ?? 'CONFIG_INVALID'}): ${result.message ?? 'unknown error'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const MAX_ANDROID_VERSION_CODE = 2_100_000_000
|
||||
|
||||
function readPreviousVersionCode(cacheFile) {
|
||||
try {
|
||||
return Number(JSON.parse(readFileSync(cacheFile, 'utf8')).versionCode) || 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为同一 versionName 的连续测试包生成可安装的新构建身份。
|
||||
*
|
||||
* 本地缓存只负责同一工作区内的单调递增;CI 若需要跨工作区全局唯一,必须通过
|
||||
* XUQM_VERSION_CODE 注入流水线唯一编号。
|
||||
*/
|
||||
export function createAndroidReleaseIdentity({
|
||||
projectRoot,
|
||||
environment = process.env,
|
||||
now = Date.now(),
|
||||
entropy = `${process.pid}:${Math.random()}`,
|
||||
}) {
|
||||
const cacheFile = path.join(projectRoot, '.xuqm-cache', 'release-build.json')
|
||||
const previousVersionCode = readPreviousVersionCode(cacheFile)
|
||||
const requestedText = environment.XUQM_VERSION_CODE
|
||||
const requested = requestedText === undefined ? undefined : Number(requestedText)
|
||||
if (
|
||||
requestedText !== undefined &&
|
||||
(!Number.isInteger(requested) || requested <= 0 || requested > MAX_ANDROID_VERSION_CODE)
|
||||
) {
|
||||
throw new Error(
|
||||
`XUQM_VERSION_CODE must be an integer between 1 and ${MAX_ANDROID_VERSION_CODE}`,
|
||||
)
|
||||
}
|
||||
const epochSeconds = Math.floor(now / 1_000)
|
||||
const versionCode = requested ?? Math.max(epochSeconds, previousVersionCode + 1)
|
||||
if (versionCode > MAX_ANDROID_VERSION_CODE) {
|
||||
throw new Error('Generated Android versionCode exceeds limit')
|
||||
}
|
||||
|
||||
const buildId =
|
||||
environment.XUQM_BUILD_ID ??
|
||||
createHash('sha256').update(`${versionCode}:${now}:${entropy}`).digest('hex').slice(0, 32)
|
||||
if (!/^[A-Za-z0-9._-]{1,128}$/.test(buildId)) {
|
||||
throw new Error('XUQM_BUILD_ID must contain only letters, numbers, dot, underscore or hyphen')
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(cacheFile), { recursive: true })
|
||||
writeFileSync(
|
||||
cacheFile,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
versionCode,
|
||||
buildId,
|
||||
generatedAt: new Date(now).toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
)
|
||||
return { versionCode, buildId }
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { createAndroidReleaseIdentity } from './release-identity.mjs'
|
||||
|
||||
test('release identity increases for repeated builds with the same app version', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-identity-'))
|
||||
try {
|
||||
const first = createAndroidReleaseIdentity({
|
||||
projectRoot: root,
|
||||
environment: {},
|
||||
now: 1_800_000_000_000,
|
||||
entropy: 'first',
|
||||
})
|
||||
const second = createAndroidReleaseIdentity({
|
||||
projectRoot: root,
|
||||
environment: {},
|
||||
now: 1_800_000_000_000,
|
||||
entropy: 'second',
|
||||
})
|
||||
assert.equal(second.versionCode, first.versionCode + 1)
|
||||
assert.notEqual(second.buildId, first.buildId)
|
||||
const cached = JSON.parse(
|
||||
readFileSync(path.join(root, '.xuqm-cache', 'release-build.json'), 'utf8'),
|
||||
)
|
||||
assert.deepEqual({ versionCode: cached.versionCode, buildId: cached.buildId }, second)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('release identity accepts explicit CI identity and validates its contract', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-identity-ci-'))
|
||||
try {
|
||||
assert.deepEqual(
|
||||
createAndroidReleaseIdentity({
|
||||
projectRoot: root,
|
||||
environment: {
|
||||
XUQM_VERSION_CODE: '123456',
|
||||
XUQM_BUILD_ID: 'jenkins-123456',
|
||||
},
|
||||
now: 1_800_000_000_000,
|
||||
}),
|
||||
{ versionCode: 123456, buildId: 'jenkins-123456' },
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
createAndroidReleaseIdentity({
|
||||
projectRoot: root,
|
||||
environment: { XUQM_VERSION_CODE: 'invalid' },
|
||||
}),
|
||||
/XUQM_VERSION_CODE/,
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
createAndroidReleaseIdentity({
|
||||
projectRoot: root,
|
||||
environment: { XUQM_BUILD_ID: 'contains whitespace' },
|
||||
}),
|
||||
/XUQM_BUILD_ID/,
|
||||
)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,105 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { strFromU8, unzipSync } from 'fflate'
|
||||
|
||||
function requiredString(manifest, key) {
|
||||
const value = manifest?.[key]
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`plugin package manifest ${key} is missing`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布身份只能来自即将上传的 ZIP,不能根据环境变量或缓存再次推导。这样 Bundle、
|
||||
* Source Map 和服务端登记始终使用同一个构建产生的精确身份。
|
||||
*/
|
||||
export function readReleasePackageIdentity(packageFile, expected) {
|
||||
const archive = unzipSync(new Uint8Array(readFileSync(packageFile)))
|
||||
const manifestBytes = archive['rn-manifest.json']
|
||||
if (!manifestBytes) throw new Error('plugin package manifest is missing')
|
||||
|
||||
let manifest
|
||||
try {
|
||||
manifest = JSON.parse(strFromU8(manifestBytes))
|
||||
} catch {
|
||||
throw new Error('plugin package manifest is invalid JSON')
|
||||
}
|
||||
|
||||
const packageName = requiredString(manifest, 'packageName')
|
||||
const moduleId = requiredString(manifest, 'moduleId')
|
||||
const platform = requiredString(manifest, 'platform')
|
||||
const moduleVersion = requiredString(manifest, 'version')
|
||||
const appVersion = requiredString(manifest, 'appVersion')
|
||||
const buildId = requiredString(manifest, 'buildId')
|
||||
const bundleFile = requiredString(manifest, 'bundleFile')
|
||||
const bundleHash = requiredString(manifest, 'bundleSha256')
|
||||
const bundleFormat = requiredString(manifest, 'bundleFormat')
|
||||
const nativeBaselineId = requiredString(manifest, 'builtAgainstNativeBaselineId')
|
||||
const appVersionRange = requiredString(manifest, 'appVersionRange')
|
||||
const minNativeApiLevel = manifest.minNativeApiLevel
|
||||
|
||||
if (buildId === 'development') {
|
||||
throw new Error('plugin package was built without a release buildId; rebuild before publishing')
|
||||
}
|
||||
if (expected.packageName !== packageName) {
|
||||
throw new Error(
|
||||
`plugin package packageName mismatch: expected ${expected.packageName}, got ${packageName}`,
|
||||
)
|
||||
}
|
||||
if (expected.moduleId !== moduleId) {
|
||||
throw new Error(
|
||||
`plugin package moduleId mismatch: expected ${expected.moduleId}, got ${moduleId}`,
|
||||
)
|
||||
}
|
||||
if (expected.platform !== platform) {
|
||||
throw new Error(
|
||||
`plugin package platform mismatch: expected ${expected.platform}, got ${platform}`,
|
||||
)
|
||||
}
|
||||
if (expected.moduleVersion !== moduleVersion) {
|
||||
throw new Error(
|
||||
`plugin package version mismatch: expected ${expected.moduleVersion}, got ${moduleVersion}`,
|
||||
)
|
||||
}
|
||||
if (expected.appVersion !== appVersion) {
|
||||
throw new Error(
|
||||
`plugin package appVersion mismatch: expected ${expected.appVersion}, got ${appVersion}`,
|
||||
)
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/i.test(bundleHash)) {
|
||||
throw new Error('plugin package bundleSha256 is invalid')
|
||||
}
|
||||
if (!Number.isInteger(minNativeApiLevel) || minNativeApiLevel < 1) {
|
||||
throw new Error('plugin package minNativeApiLevel is invalid')
|
||||
}
|
||||
if (
|
||||
manifest.commonVersionRange !== undefined &&
|
||||
(typeof manifest.commonVersionRange !== 'string' || !manifest.commonVersionRange.trim())
|
||||
) {
|
||||
throw new Error('plugin package commonVersionRange is invalid')
|
||||
}
|
||||
|
||||
const bundleBytes = archive[bundleFile]
|
||||
if (!bundleBytes) throw new Error(`plugin package bundle is missing: ${bundleFile}`)
|
||||
const actualBundleHash = createHash('sha256').update(bundleBytes).digest('hex')
|
||||
if (actualBundleHash !== bundleHash.toLowerCase()) {
|
||||
throw new Error('plugin package bundleSha256 does not match bundle content')
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
packageName,
|
||||
moduleId,
|
||||
platform,
|
||||
moduleVersion,
|
||||
appVersion,
|
||||
buildId,
|
||||
bundleHash: actualBundleHash,
|
||||
bundleFormat,
|
||||
nativeBaselineId,
|
||||
appVersionRange,
|
||||
commonVersionRange: manifest.commonVersionRange,
|
||||
minNativeApiLevel,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,99 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { readReleasePackageIdentity } from './release-package.mjs'
|
||||
|
||||
function createPackage(root, overrides = {}, bundle = 'bundle') {
|
||||
const bundleBytes = strToU8(bundle)
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
packageName: 'com.example.host',
|
||||
moduleId: 'app',
|
||||
platform: 'android',
|
||||
version: '1.0.1',
|
||||
appVersion: '8.0.0',
|
||||
buildId: 'build-20260726',
|
||||
bundleFile: 'app.android.bundle',
|
||||
bundleSha256: createHash('sha256').update(bundleBytes).digest('hex'),
|
||||
bundleFormat: 'hermes-bytecode',
|
||||
builtAgainstNativeBaselineId: 'native-1',
|
||||
appVersionRange: '>=8.0.0 <9.0.0',
|
||||
commonVersionRange: '>=1.0.0 <2.0.0',
|
||||
minNativeApiLevel: 2,
|
||||
...overrides,
|
||||
}
|
||||
const file = path.join(root, 'app.android.xuqm.zip')
|
||||
writeFileSync(
|
||||
file,
|
||||
zipSync({
|
||||
'rn-manifest.json': strToU8(JSON.stringify(manifest)),
|
||||
'app.android.bundle': bundleBytes,
|
||||
}),
|
||||
)
|
||||
return file
|
||||
}
|
||||
|
||||
const expected = {
|
||||
packageName: 'com.example.host',
|
||||
moduleId: 'app',
|
||||
platform: 'android',
|
||||
moduleVersion: '1.0.1',
|
||||
appVersion: '8.0.0',
|
||||
}
|
||||
|
||||
test('release identity is read from and verified against the packaged Bundle', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-'))
|
||||
try {
|
||||
const identity = readReleasePackageIdentity(createPackage(root), expected)
|
||||
assert.equal(identity.packageName, 'com.example.host')
|
||||
assert.equal(identity.buildId, 'build-20260726')
|
||||
assert.equal(identity.bundleFormat, 'hermes-bytecode')
|
||||
assert.equal(identity.nativeBaselineId, 'native-1')
|
||||
assert.equal(identity.appVersionRange, '>=8.0.0 <9.0.0')
|
||||
assert.equal(identity.commonVersionRange, '>=1.0.0 <2.0.0')
|
||||
assert.equal(identity.minNativeApiLevel, 2)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('publishing requires and exactly matches the packaged packageName', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-name-'))
|
||||
try {
|
||||
assert.throws(
|
||||
() => readReleasePackageIdentity(createPackage(root, { packageName: undefined }), expected),
|
||||
/manifest packageName is missing/,
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
readReleasePackageIdentity(
|
||||
createPackage(root, { packageName: 'com.example.other' }),
|
||||
expected,
|
||||
),
|
||||
/packageName mismatch: expected com\.example\.host, got com\.example\.other/,
|
||||
)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('publishing rejects development identity and mismatched packaged Bundle hash', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-invalid-'))
|
||||
try {
|
||||
assert.throws(
|
||||
() => readReleasePackageIdentity(createPackage(root, { buildId: 'development' }), expected),
|
||||
/without a release buildId/,
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
readReleasePackageIdentity(createPackage(root, { bundleSha256: 'a'.repeat(64) }), expected),
|
||||
/does not match bundle content/,
|
||||
)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,45 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
function normalize(value) {
|
||||
return value.replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
function externalSourceName(normalized) {
|
||||
const nodeModulesMarker = '/node_modules/'
|
||||
const markerIndex = normalized.lastIndexOf(nodeModulesMarker)
|
||||
if (markerIndex >= 0) {
|
||||
return `external/node_modules/${normalized.slice(markerIndex + nodeModulesMarker.length)}`
|
||||
}
|
||||
return `external/${normalized.split('/').filter(Boolean).at(-1) ?? 'unknown-source'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* root 内绝对路径变成项目相对路径;root 外绝对路径只保留稳定的外部标识,
|
||||
* 绝不把 `../` 剥掉后伪装成项目源码,也不泄露开发机用户名或目录。
|
||||
*/
|
||||
export function sanitizeSourcePath(source, projectRoot) {
|
||||
if (typeof source !== 'string') return source
|
||||
const normalizedSource = normalize(source)
|
||||
const normalizedRoot = normalize(projectRoot).replace(/\/+$/, '')
|
||||
const windowsAbsolute = path.win32.isAbsolute(source)
|
||||
const posixAbsolute = path.posix.isAbsolute(normalizedSource)
|
||||
if (!windowsAbsolute && !posixAbsolute) return normalizedSource
|
||||
|
||||
const pathApi = windowsAbsolute ? path.win32 : path.posix
|
||||
const comparableSource = windowsAbsolute ? source : normalizedSource
|
||||
const comparableRoot = windowsAbsolute ? projectRoot : normalizedRoot
|
||||
const relative = pathApi.relative(comparableRoot, comparableSource)
|
||||
const outside =
|
||||
relative === '..' || relative.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(relative)
|
||||
return outside ? externalSourceName(normalizedSource) : normalize(relative || '.')
|
||||
}
|
||||
|
||||
export function sanitizeSourceMapFile(sourceMapFile, projectRoot) {
|
||||
const sourceMap = JSON.parse(readFileSync(sourceMapFile, 'utf8'))
|
||||
delete sourceMap.sourcesContent
|
||||
if (Array.isArray(sourceMap.sources)) {
|
||||
sourceMap.sources = sourceMap.sources.map(source => sanitizeSourcePath(source, projectRoot))
|
||||
}
|
||||
writeFileSync(sourceMapFile, JSON.stringify(sourceMap))
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { sanitizeSourcePath } from './source-map.mjs'
|
||||
|
||||
test('Unix sources distinguish project files from external absolute paths', () => {
|
||||
assert.equal(sanitizeSourcePath('/workspace/app/src/Home.tsx', '/workspace/app'), 'src/Home.tsx')
|
||||
assert.equal(
|
||||
sanitizeSourcePath('/Users/alice/shared/secret.ts', '/workspace/app'),
|
||||
'external/secret.ts',
|
||||
)
|
||||
assert.equal(
|
||||
sanitizeSourcePath('/Users/alice/app/node_modules/react/index.js', '/workspace/app'),
|
||||
'external/node_modules/react/index.js',
|
||||
)
|
||||
})
|
||||
|
||||
test('Windows sources distinguish project files without leaking host directories', () => {
|
||||
assert.equal(
|
||||
sanitizeSourcePath('C:\\workspace\\app\\src\\Home.tsx', 'C:\\workspace\\app'),
|
||||
'src/Home.tsx',
|
||||
)
|
||||
assert.equal(
|
||||
sanitizeSourcePath('D:\\Users\\alice\\private\\secret.ts', 'C:\\workspace\\app'),
|
||||
'external/secret.ts',
|
||||
)
|
||||
assert.equal(
|
||||
sanitizeSourcePath('D:\\cache\\node_modules\\react\\index.js', 'C:\\workspace\\app'),
|
||||
'external/node_modules/react/index.js',
|
||||
)
|
||||
})
|
||||
@ -2,7 +2,8 @@ import { existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import semver from 'semver'
|
||||
|
||||
export const CONFIG_FILE_NAME = 'xuqm.config.json'
|
||||
/** 插件构建清单;与平台签发的 config.xuqmconfig 初始化文件没有关系。 */
|
||||
export const CONFIG_FILE_NAME = 'xuqm.modules.json'
|
||||
export const SUPPORTED_PLATFORMS = new Set(['android', 'ios'])
|
||||
export const SUPPORTED_MODULE_TYPES = new Set(['startup', 'common', 'app', 'buz'])
|
||||
|
||||
@ -26,6 +27,18 @@ export function readConfig(root = process.cwd()) {
|
||||
export function validateConfig(config, root = process.cwd()) {
|
||||
const errors = []
|
||||
if (!config.appId) errors.push('appId is required')
|
||||
if (
|
||||
config.packageName !== undefined &&
|
||||
(typeof config.packageName !== 'string' || config.packageName.trim() === '')
|
||||
) {
|
||||
errors.push('packageName must be a non-empty string')
|
||||
}
|
||||
if (
|
||||
config.iosBundleId !== undefined &&
|
||||
(typeof config.iosBundleId !== 'string' || config.iosBundleId.trim() === '')
|
||||
) {
|
||||
errors.push('iosBundleId must be a non-empty string')
|
||||
}
|
||||
if (!config.mainModuleName) errors.push('mainModuleName is required')
|
||||
if (typeof config.pluginVersion !== 'string' || !semver.valid(config.pluginVersion)) {
|
||||
errors.push('pluginVersion must be SemVer')
|
||||
@ -33,10 +46,19 @@ export function validateConfig(config, root = process.cwd()) {
|
||||
if (typeof config.appVersionRange !== 'string' || !semver.validRange(config.appVersionRange)) {
|
||||
errors.push('appVersionRange must be a valid SemVer range')
|
||||
}
|
||||
if (config.release && Object.prototype.hasOwnProperty.call(config.release, 'apiToken')) {
|
||||
errors.push('release.apiToken is forbidden; Jenkins must inject XUQM_API_TOKEN')
|
||||
}
|
||||
if (!Array.isArray(config.modules) || config.modules.length === 0) {
|
||||
errors.push('modules must contain at least one module')
|
||||
return errors
|
||||
}
|
||||
if (config.modules.length > 1 && !config.metroConfig) {
|
||||
errors.push('metroConfig is required for multi-module builds')
|
||||
}
|
||||
if (config.metroConfig && !existsSync(path.resolve(root, config.metroConfig))) {
|
||||
errors.push(`Metro config not found: ${config.metroConfig}`)
|
||||
}
|
||||
|
||||
const ids = new Set()
|
||||
for (const module of config.modules) {
|
||||
@ -73,10 +95,45 @@ 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
|
||||
}
|
||||
|
||||
/**
|
||||
* wire 协议统一使用 packageName,但值必须是目标平台自己的宿主身份。所有构建、校验
|
||||
* 与发布入口只能通过这里解析,避免 Android applicationId 泄漏到 iOS 插件。
|
||||
*/
|
||||
export function resolveHostPackageId(config, platform) {
|
||||
if (!SUPPORTED_PLATFORMS.has(platform)) {
|
||||
throw new Error(`unsupported platform: ${platform}`)
|
||||
}
|
||||
const key = platform === 'ios' ? 'iosBundleId' : 'packageName'
|
||||
const value = config?.[key]
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`${key} is required for ${platform} build and release`)
|
||||
}
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
export function readPackageVersion(root = process.cwd()) {
|
||||
const packageFile = path.join(root, 'package.json')
|
||||
if (!existsSync(packageFile)) throw new Error('package.json is missing')
|
||||
@ -111,7 +168,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,15 +1,16 @@
|
||||
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'
|
||||
import { resolveVersionedModules, validateConfig } from './xuqm-config.mjs'
|
||||
import { resolveHostPackageId, resolveVersionedModules, validateConfig } from './xuqm-config.mjs'
|
||||
|
||||
function fixture() {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-config-'))
|
||||
writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version: '7.2.14' }))
|
||||
writeFileSync(path.join(root, 'common.ts'), '')
|
||||
writeFileSync(path.join(root, 'app.ts'), '')
|
||||
writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n')
|
||||
return root
|
||||
}
|
||||
|
||||
@ -18,9 +19,11 @@ test('plugins start at the configured plugin version independently from the app
|
||||
try {
|
||||
const config = {
|
||||
appId: 'example',
|
||||
packageName: 'com.example.host',
|
||||
mainModuleName: 'Example',
|
||||
pluginVersion: '1.0.0',
|
||||
appVersionRange: '>=7.2.14 <8.0.0',
|
||||
metroConfig: './metro.config.js',
|
||||
modules: [
|
||||
{ id: 'common', type: 'common', entry: './common.ts' },
|
||||
{ id: 'app', type: 'app', entry: './app.ts' },
|
||||
@ -32,7 +35,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 })
|
||||
}
|
||||
@ -61,3 +64,93 @@ 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',
|
||||
packageName: 'com.example.host',
|
||||
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 })
|
||||
}
|
||||
})
|
||||
|
||||
test('release token cannot be stored in xuqm.modules.json', () => {
|
||||
const root = fixture()
|
||||
try {
|
||||
const errors = validateConfig(
|
||||
{
|
||||
appId: 'example',
|
||||
packageName: 'com.example.host',
|
||||
mainModuleName: 'Example',
|
||||
pluginVersion: '1.0.0',
|
||||
appVersionRange: '>=1.0.0 <2.0.0',
|
||||
metroConfig: './metro.config.js',
|
||||
release: { apiToken: 'must-not-enter-source' },
|
||||
modules: [{ id: 'common', type: 'common', entry: './common.ts' }],
|
||||
},
|
||||
root,
|
||||
)
|
||||
assert.match(errors.join('\n'), /release\.apiToken is forbidden.*XUQM_API_TOKEN/)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('host package identity resolves exactly once for each target platform', () => {
|
||||
const config = {
|
||||
packageName: 'com.example.android',
|
||||
iosBundleId: 'com.example.ios',
|
||||
}
|
||||
assert.equal(resolveHostPackageId(config, 'android'), 'com.example.android')
|
||||
assert.equal(resolveHostPackageId(config, 'ios'), 'com.example.ios')
|
||||
assert.throws(
|
||||
() => resolveHostPackageId({ packageName: 'com.example.android' }, 'ios'),
|
||||
/iosBundleId is required for ios build and release/,
|
||||
)
|
||||
assert.throws(
|
||||
() => resolveHostPackageId({ iosBundleId: 'com.example.ios' }, 'android'),
|
||||
/packageName is required for android build and release/,
|
||||
)
|
||||
})
|
||||
|
||||
@ -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,
|
||||
@ -22,11 +24,16 @@ import {
|
||||
compatibleAppRange,
|
||||
parseModuleOptions,
|
||||
readConfig,
|
||||
resolveHostPackageId,
|
||||
resolveVersionedModules,
|
||||
selectModules,
|
||||
validateConfig,
|
||||
} from './xuqm-config.mjs'
|
||||
import { computeNativeBaseline } from './native-baseline.mjs'
|
||||
import { parseBugCollectOption, parsePackageOptions } from './package-options.mjs'
|
||||
import { validateReleaseConfig } from './release-config.mjs'
|
||||
import { createAndroidReleaseIdentity } from './release-identity.mjs'
|
||||
import { sanitizeSourceMapFile } from './source-map.mjs'
|
||||
|
||||
const root = process.cwd()
|
||||
const args = process.argv.slice(2)
|
||||
@ -41,8 +48,15 @@ function writeJson(file, value) {
|
||||
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function childEnvironment() {
|
||||
const environment = { ...process.env }
|
||||
async function validateReleaseConfigOnline(config, platform) {
|
||||
await validateReleaseConfig({
|
||||
projectRoot: root,
|
||||
packageName: resolveHostPackageId(config, platform),
|
||||
})
|
||||
}
|
||||
|
||||
function childEnvironment(overrides = {}) {
|
||||
const environment = { ...process.env, ...overrides }
|
||||
// Metro may set FORCE_COLOR in its transformer workers. Forward a single
|
||||
// explicit color flag so those workers never inherit a contradictory pair.
|
||||
if (environment.NO_COLOR !== undefined) {
|
||||
@ -52,13 +66,13 @@ function childEnvironment() {
|
||||
return environment
|
||||
}
|
||||
|
||||
function runReactNative(cliArgs) {
|
||||
function runReactNative(cliArgs, environment = {}) {
|
||||
const reactNativeCli = path.join(root, 'node_modules', 'react-native', 'cli.js')
|
||||
if (!existsSync(reactNativeCli)) fail('react-native CLI is missing; install dependencies first')
|
||||
execFileSync(process.execPath, [reactNativeCli, ...cliArgs], {
|
||||
stdio: 'inherit',
|
||||
cwd: root,
|
||||
env: childEnvironment(),
|
||||
env: childEnvironment(environment),
|
||||
})
|
||||
}
|
||||
|
||||
@ -138,10 +152,12 @@ function init() {
|
||||
writeJson(configFile, {
|
||||
schemaVersion: 3,
|
||||
appId: pkg.name ?? 'react-native-app',
|
||||
packageName: pkg.name ?? 'react-native-app',
|
||||
mainModuleName: pkg.name ?? 'ReactNativeApp',
|
||||
pluginVersion: '1.0.0',
|
||||
appVersionRange: compatibleAppRange(pkg.version ?? '1.0.0'),
|
||||
outputDir: './bundle',
|
||||
metroConfig: './metro.config.js',
|
||||
embeddedOutput: {
|
||||
android: './android/app/src/main/assets/rn-bundles',
|
||||
ios: './bundle/embedded/ios/rn-bundles',
|
||||
@ -233,12 +249,88 @@ function parseSelectedModules(config, optionArgs) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 独立构建 app/buz 时也必须先生成 startup/common 的共享模块表。
|
||||
* 依赖 bundle 只参与本次构建,不会因为选择了单个 buz 就被一并发布。
|
||||
*/
|
||||
function resolveBuildModules(config, selectedModules) {
|
||||
const selectedIds = new Set(selectedModules.map(module => module.id))
|
||||
const needsCommon = selectedModules.some(module => ['app', 'buz'].includes(module.type))
|
||||
const needsStartup = selectedModules.some(module => module.type !== 'startup')
|
||||
const priorities = { startup: 0, common: 1, app: 2, buz: 3 }
|
||||
return config.modules
|
||||
.filter(
|
||||
module =>
|
||||
selectedIds.has(module.id) ||
|
||||
(needsCommon && module.type === 'common') ||
|
||||
(needsStartup && module.type === 'startup'),
|
||||
)
|
||||
.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)
|
||||
resolveHostPackageId(config, platform)
|
||||
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()
|
||||
for (const module of modules) {
|
||||
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 })
|
||||
mkdirSync(outputDirectory, { recursive: true })
|
||||
@ -250,7 +342,7 @@ function build(platform, optionArgs = []) {
|
||||
'--dev',
|
||||
'false',
|
||||
'--entry-file',
|
||||
module.entry,
|
||||
buildEntryFile(module, platform),
|
||||
'--bundle-output',
|
||||
bundleFile(root, config, platform, module.id),
|
||||
'--assets-dest',
|
||||
@ -259,13 +351,30 @@ function build(platform, optionArgs = []) {
|
||||
]
|
||||
const metroConfig = module.metroConfig ?? config.metroConfig
|
||||
if (metroConfig) cliArgs.push('--config', metroConfig)
|
||||
if (module.sourceMap === true) {
|
||||
// 每个 Release 模块始终生成独立 Source Map;是否上传由发布阶段决定。
|
||||
cliArgs.push('--sourcemap-output', `${bundleFile(root, config, platform, module.id)}.map`)
|
||||
}
|
||||
console.log(`xuqm-rn: building ${module.id}/${platform}`)
|
||||
runReactNative(cliArgs.slice(1))
|
||||
if (useHermes)
|
||||
compileHermesBundle(bundleFile(root, config, platform, module.id), module.sourceMap === true)
|
||||
runReactNative(cliArgs.slice(1), {
|
||||
XUQM_MODULE_CACHE_FILE: path.join(root, '.xuqm-cache', 'common-module-ids.json'),
|
||||
XUQM_MODULE_ID: module.id,
|
||||
XUQM_MODULE_INDEX: String(config.modules.findIndex(item => item.id === module.id)),
|
||||
XUQM_MODULE_TYPE: module.type,
|
||||
XUQM_RESET_MODULE_CACHE: buildIndex === 0 ? 'true' : 'false',
|
||||
})
|
||||
if (useHermes) compileHermesBundle(bundleFile(root, config, platform, module.id), true)
|
||||
sanitizeSourceMapFile(`${bundleFile(root, config, platform, module.id)}.map`, root)
|
||||
// startup 是随宿主 APK 固化的最小引导代码,不属于可在线发布插件。为它生成
|
||||
// 发布包既没有安装入口,也容易让发布人员误认为它可以脱离整包升级。
|
||||
if (module.type !== 'startup') {
|
||||
createModulePackage(
|
||||
config,
|
||||
platform,
|
||||
module,
|
||||
versioned.appVersion,
|
||||
nativeBaselineId,
|
||||
useHermes ? 'hermes-bytecode' : 'javascript',
|
||||
)
|
||||
}
|
||||
}
|
||||
return { config, modules }
|
||||
}
|
||||
@ -283,10 +392,68 @@ 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,
|
||||
packageName: resolveHostPackageId(config, platform),
|
||||
moduleId: module.id,
|
||||
type: module.type,
|
||||
platform,
|
||||
version: module.moduleVersion,
|
||||
appVersion,
|
||||
appVersionRange: module.appVersionRange,
|
||||
builtAgainstNativeBaselineId: nativeBaselineId,
|
||||
bundleFormat,
|
||||
buildId: process.env.XUQM_BUILD_ID ?? 'development',
|
||||
bugCollectMode: process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
|
||||
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)) {
|
||||
@ -307,11 +474,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,
|
||||
@ -333,6 +503,8 @@ function createManifest(config, platform, modules, appVersion, nativeBaselineId)
|
||||
bundleFormat: platform === 'android' && androidUsesHermes() ? 'hermes-bytecode' : 'javascript',
|
||||
version: config.manifestVersion ?? 1,
|
||||
nativeBaselineId,
|
||||
buildId: process.env.XUQM_BUILD_ID ?? 'development',
|
||||
bugCollectMode: process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
|
||||
modules: entries,
|
||||
}
|
||||
}
|
||||
@ -375,11 +547,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),
|
||||
@ -398,33 +581,82 @@ function embed(platform, optionArgs = []) {
|
||||
}
|
||||
|
||||
function publish(platform, optionArgs) {
|
||||
const { moduleIds } = parseModuleOptions(optionArgs)
|
||||
const { bugCollectMode, remaining } = parseBugCollectOption(
|
||||
optionArgs,
|
||||
process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
|
||||
)
|
||||
const { moduleIds } = parseModuleOptions(remaining)
|
||||
const buildOptions = moduleIds.flatMap(moduleId => ['--module', moduleId])
|
||||
const previousBuildId = process.env.XUQM_BUILD_ID
|
||||
const previousVersionCode = process.env.XUQM_VERSION_CODE
|
||||
const previousBugCollectMode = process.env.XUQM_BUGCOLLECT_MODE
|
||||
if (platform === 'android' && !previousBuildId) {
|
||||
const identity = createAndroidReleaseIdentity({ projectRoot: root })
|
||||
process.env.XUQM_BUILD_ID = identity.buildId
|
||||
process.env.XUQM_VERSION_CODE = String(identity.versionCode)
|
||||
}
|
||||
process.env.XUQM_BUGCOLLECT_MODE = bugCollectMode
|
||||
try {
|
||||
build(platform, buildOptions)
|
||||
const releaseScript = new URL('./xuqm_release.mjs', import.meta.url)
|
||||
execFileSync(process.execPath, [releaseScript.pathname, '--platform', platform, ...optionArgs], {
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[releaseScript.pathname, '--platform', platform, ...optionArgs],
|
||||
{
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
if (previousBuildId === undefined) delete process.env.XUQM_BUILD_ID
|
||||
else process.env.XUQM_BUILD_ID = previousBuildId
|
||||
if (previousVersionCode === undefined) delete process.env.XUQM_VERSION_CODE
|
||||
else process.env.XUQM_VERSION_CODE = previousVersionCode
|
||||
if (previousBugCollectMode === undefined) delete process.env.XUQM_BUGCOLLECT_MODE
|
||||
else process.env.XUQM_BUGCOLLECT_MODE = previousBugCollectMode
|
||||
}
|
||||
}
|
||||
|
||||
function packageApp(platform, optionArgs) {
|
||||
async function packageApp(platform, optionArgs) {
|
||||
assertPlatform(platform)
|
||||
if (platform !== 'android') fail('native package command currently supports android only')
|
||||
const apkIndex = optionArgs.indexOf('--apk')
|
||||
const apk = apkIndex >= 0
|
||||
if (apk) optionArgs.splice(apkIndex, 1)
|
||||
if (optionArgs.length) fail(`unknown option(s): ${optionArgs.join(' ')}`)
|
||||
let packageOptions
|
||||
try {
|
||||
packageOptions = parsePackageOptions(optionArgs)
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
const { apk, bugCollectMode } = packageOptions
|
||||
const config = doctor()
|
||||
await validateReleaseConfigOnline(config, platform)
|
||||
const buildIdentity = createAndroidReleaseIdentity({ projectRoot: root })
|
||||
const androidDirectory = path.join(root, 'android')
|
||||
const wrapper = path.join(
|
||||
androidDirectory,
|
||||
process.platform === 'win32' ? 'gradlew.bat' : 'gradlew',
|
||||
)
|
||||
if (!existsSync(wrapper)) fail('Android Gradle wrapper is missing')
|
||||
execFileSync(wrapper, [apk ? 'assembleRelease' : 'bundleRelease'], {
|
||||
execFileSync(
|
||||
wrapper,
|
||||
[
|
||||
apk ? 'assembleRelease' : 'bundleRelease',
|
||||
`-PXUQM_VERSION_CODE=${buildIdentity.versionCode}`,
|
||||
`-PXUQM_BUILD_ID=${buildIdentity.buildId}`,
|
||||
`-PXUQM_BUGCOLLECT_MODE=${bugCollectMode}`,
|
||||
],
|
||||
{
|
||||
cwd: androidDirectory,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
env: childEnvironment({
|
||||
XUQM_BUGCOLLECT_MODE: bugCollectMode,
|
||||
XUQM_BUILD_ID: buildIdentity.buildId,
|
||||
XUQM_VERSION_CODE: String(buildIdentity.versionCode),
|
||||
}),
|
||||
},
|
||||
)
|
||||
console.log(
|
||||
`xuqm-rn: release identity versionCode=${buildIdentity.versionCode} buildId=${buildIdentity.buildId}`,
|
||||
)
|
||||
}
|
||||
|
||||
function startMetro(optionArgs) {
|
||||
@ -485,7 +717,7 @@ try {
|
||||
publish(args.shift() ?? 'android', args)
|
||||
break
|
||||
case 'package':
|
||||
packageApp(args.shift() ?? 'android', args)
|
||||
await packageApp(args.shift() ?? 'android', args)
|
||||
break
|
||||
default:
|
||||
printHelp()
|
||||
|
||||
某些文件未显示,因为此 diff 中更改的文件太多 显示更多
正在加载...
在新工单中引用
屏蔽一个用户