feat(sdk): harden automatic config and plugin releases

这个提交包含在:
XuqmGroup 2026-07-26 23:47:30 +08:00
父节点 10977341d1
当前提交 3d2134e08a
共有 81 个文件被更改,包括 2845 次插入753 次删除

1
.gitignore vendored
查看文件

@ -7,6 +7,7 @@ target/
build/
.gradle/
.native-gradle/
.xuqm-cache/
*.iml
.idea/
*.log

查看文件

@ -4,14 +4,14 @@ XuqmGroup 通用 React Native SDK 工作区。`@xuqm/rn-common` 是唯一基础
## 包结构
| 包 | 用途 | 可独立使用 |
| --- | --- | --- |
| `@xuqm/rn-common` | 可选 SDK 上下文、网络、文件、时间、加密、设备与通用 UI | 是,且无需初始化或登录 |
| `@xuqm/rn-update` | App 更新、RN 插件注册、下载、校验与运行 | 否,依赖 common |
| `@xuqm/rn-xwebview` | WebView、JSBridge、权限与下载 | 否,依赖 common |
| `@xuqm/rn-bugcollect` | 错误、事件、漏斗和批量上报 | 否,依赖 common |
| `@xuqm/rn-im` | IM 连接、消息和本地数据 | 否,依赖 common |
| `@xuqm/rn-push` | 多厂商原生推送桥接 | 否,依赖 common |
| 包 | 用途 | 可独立使用 |
| --------------------- | ------------------------------------------------------ | ---------------------- |
| `@xuqm/rn-common` | 可选 SDK 上下文、网络、文件、时间、加密、设备与通用 UI | 是,且无需初始化或登录 |
| `@xuqm/rn-update` | App 更新、RN 插件注册、下载、校验与运行 | 否,依赖 common |
| `@xuqm/rn-xwebview` | WebView、JSBridge、权限与下载 | 否,依赖 common |
| `@xuqm/rn-bugcollect` | 错误、事件、漏斗和批量上报 | 否,依赖 common |
| `@xuqm/rn-im` | IM 连接、消息和本地数据 | 否,依赖 common |
| `@xuqm/rn-push` | 多厂商原生推送桥接 | 否,依赖 common |
根包 `@xuqm/rn-sdk` 只用于仓库内聚合开发,不对外发布。应用应按需安装具体包,避免引入未使用的原生能力。
@ -46,34 +46,31 @@ pnpm add @xuqm/rn-update @xuqm/rn-xwebview @xuqm/rn-bugcollect
把平台生成的 `config.xuqmconfig` 放在 `src/assets/config/`,然后只包装一次 Metro 配置。使用 update 插件化时采用 update 组合器(它已包含 common 自动初始化):
```js
const {getDefaultConfig} = require('@react-native/metro-config');
const {withXuqmModuleConfig} = require('@xuqm/rn-update/metro');
const { getDefaultConfig } = require('@react-native/metro-config')
const { withXuqmModuleConfig } = require('@xuqm/rn-update/metro')
module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname));
module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname))
```
不使用 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 宿主不放配置,也不初始化。
## 开发与验证

查看文件

@ -4,6 +4,78 @@
> 当前实施范围:`@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` 缺失时安全默认禁止匿名检查。
- 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;
同一工作区通过 `.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 数据。
- 已执行全部包类型检查、common 30 项、bugcollect 11 项、update CLI 25 项、update
领域 13 项、xwebview 7 项测试、update 包内容校验、Prettier 和 `git diff --check`
均通过;update 发布包内容为 49 个文件。`pnpm validate` 本次因 Corepack/pnpm
尝试联网读取 registry 且要求交互确认重建 `node_modules` 而未执行成功,已使用仓内
已安装工具逐项执行等价门禁。`native-test` 的 Gradle 在任务配置前因本机
`libnative-platform.dylib` 无法加载而失败,不能据此声称本轮原生 Java 已编译。
尚未执行宿主 Android 构建、设备运行、开发环境接口和真实 Source Map 上传;这些
结果不得从静态检查推断。
- License 离线激活 SDK 已审计RNSDK 不存在对应包、API、依赖、脚本、文档或空目录;
npm 的 `license: UNLICENSED` 元数据和 RN 插件事务 `activation` 不属于离线授权。
- 配置中的 `signingKey` 明确是客户端可提取的请求完整性/缓存派生材料,不是 License、
用户授权或服务端秘密;敏感 API 仍依赖登录 `accessToken`,构建制品上传依赖独立
Jenkins Bearer,并由服务端执行授权。
## 1. 本轮目标
1. `rn-common` 可以完全独立使用公共工具,不要求初始化或登录。
@ -75,9 +147,9 @@
- 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。
- 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.config.json` 重复声明CLI 直接读取宿主 `android/gradle.properties``hermesEnabled`。启用时所有插件统一编译为 Hermes bytecode、组合 source map,并在 manifest/上传表单记录 `bundleFormat`
- 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 恢复内嵌基线。
@ -238,7 +310,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 整包安装桥接

查看文件

@ -18,19 +18,25 @@ await XuqmSDK.login({
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 通用能力
@ -39,17 +45,21 @@ 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。
原生最终恢复页使用 `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 会话变化时同步原生推送绑定。

查看文件

@ -9,7 +9,7 @@ pnpm exec xuqm-rn init
pnpm xuqm:doctor
```
`init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件配置文件为根目录 `xuqm.config.json`
`init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件构建清单为根目录 `xuqm.modules.json`。它与租户平台签发、宿主不可编辑的 `config.xuqmconfig` 初始化文件职责完全分离
开发启动也使用同一 CLI,不由宿主复制 Node 包装脚本:
@ -30,7 +30,7 @@ pnpm exec xuqm-rn plugin create prescription
- 以小写字母开头
- 只包含小写字母、数字和连字符
- 在 `xuqm.config.json` 中唯一
- 在 `xuqm.modules.json` 中唯一
CLI 会生成:
@ -40,7 +40,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,14 +18,12 @@ 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 配置只包装一次:
@ -44,9 +42,11 @@ 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,29 +56,34 @@ 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`。`serverUrl` 优先、`baseUrl` 次之,二者都表示 Xuqm 租户平台地址并在运行时归一为 `platformUrl`;它们不是 App 业务接口地址。明文结构由平台负责签发,业务仓库不得自行构造。
明文必须是键按字典序排列、无空白、忽略 null 的 canonical JSON。必要字段为
`schemaVersion=2`、`configId`、正整数 `revision`、`issuedAt`、`appKey`、`appName`、
`serverUrl``signingKey`;`expiresAt` 省略表示长期有效。平台重新生成配置后,
`configId/revision` 只会阻止后续 Release 构建,不会追溯影响已经安装的 App。
远程配置响应中的 `apiUrl` 是业务扩展服务地址,与配置文件中的平台地址职责不同:
@ -121,7 +126,9 @@ await XuqmSDK.logout()
## 失败规则
- 扩展项目缺少配置:`awaitInitialization()` 明确报错。
- 解密失败或远程配置失败:初始化 Promise 拒绝并保留原始错误。
- 签名、V2 Schema、canonical JSON 或有效期校验失败:构建立即失败。
- 远程配置失败且存在七天内的最后成功配置:进入 `degraded` 并继续启动。
- 首次启动没有缓存:扩展能力返回 `XUQM_NOT_READY`,宿主核心业务继续运行。
- 不自动切换到硬编码配置,不静默创建默认租户,不提供多级兼容回退。
- common-only 项目没有配置时不会执行初始化,也不会报错。
@ -131,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,并由服务端执行真实授权。

查看文件

@ -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 包上传 SourceMapdev 模式跳过)
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) {
const cfg = getConfig()
if (!cfg.bugCollectApiUrl || !cfg.bugCollectEnabled) return
_queue = new LogQueue({ bugCollectApiUrl: cfg.bugCollectApiUrl, appKey: cfg.appKey })
}
function _createQueue(): LogQueue {
const cfg = getConfig()
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'

查看文件

@ -26,6 +26,16 @@ function getRequestMetadata(input: RequestInfo | URL, init?: RequestInit) {
}
}
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
@ -38,6 +48,7 @@ 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) {

查看文件

@ -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,30 +75,38 @@ 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 {
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])
}
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)
},
{
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 {
if (this.flushTimer !== null) {
clearInterval(this.flushTimer)
@ -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

查看文件

@ -30,3 +30,23 @@ test('HTTP interception removes host, query and request contents before reportin
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')
@ -40,15 +41,7 @@ module.exports = withXuqmConfig(
使用 update、bugcollect、xwebview 等扩展包时,`withXuqmConfig()` 会在 Node/Metro 构建进程中解密配置,生成只读的虚拟配置模块,并把 common 的初始化入口注册为 Metro pre-main module,确保初始化早于业务入口且不受 `inlineRequires` 影响。Metro 插件还会把同一份加密配置同步到 Android assets;宿主不需要在多个位置维护副本。PBKDF2 与 AES-GCM 解密不在 Hermes 启动线程执行,应用启动时只消费构建已经解析的配置并发起共享初始化。
common-only 项目不要放置 `.xuqmconfig`,也不要使用 `withXuqmConfig()`,即可保持零初始化。是否初始化只由这一条规则决定,不提供额外 alias 或多套配置约定。
无法使用配置文件时,才调用一次手动初始化:
```ts
await XuqmSDK.initialize({ appKey: 'app-key' })
```
同一配置的并发初始化会复用同一个 Promise;重复使用不同配置会直接报错。
common-only 项目不要放置 `.xuqmconfig`,也不要使用 `withXuqmConfig()`,即可保持零初始化。是否初始化只由这一条规则决定,不提供手动初始化、额外 alias 或多套配置约定。
Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败,common 会终止该 Promise,避免形成未处理异常。随后第一个真正需要配置的扩展调用共享 `awaitInitialization()` 时,会使用同一份已解析配置重新尝试;配置、appKey 和平台地址不需要由页面或子 SDK 再次传入。持续失败由宿主入口统一记录一次,SDK 不重复打印同一错误。构建期配置损坏、密钥不匹配或 Schema 非法会直接阻断构建,禁止打出运行时才失败的安装包。
@ -80,7 +73,8 @@ 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` 扩展包接入公共生命周期。

查看文件

@ -6,8 +6,8 @@
* 宿主只需将平台下载的 .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}
@ -16,17 +16,13 @@ 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_MAGIC = 'XUQM-CONFIG-V1'
const CONFIG_PASSPHRASE = 'xuqm-config-file-v1.2026.internal'
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('./trusted-config-keys.json')
function decodeBase64Url(value) {
return Buffer.from(value, 'base64url')
@ -36,15 +32,92 @@ function decodeBase64Url(value) {
* 配置文件只在 Node/Metro 构建期解密移动端运行时直接消费解析后的配置
* 避免 Hermes 在应用启动阶段执行 12 万次 PBKDF2 并阻塞 JS 线程
*/
function decryptConfigAtBuildTime(content) {
function canonicalJson(value) {
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
if (value && typeof value === 'object') {
return `{${Object.keys(value)
.filter(key => value[key] !== null && value[key] !== undefined)
.sort()
.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
.join(',')}}`
}
return JSON.stringify(value)
}
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 !== 4 || parts[0] !== CONFIG_MAGIC) {
throw new Error('配置文件格式无效')
if (parts.length !== 6 || parts[0] !== CONFIG_MAGIC) {
throw new Error('仅支持 XUQM-CONFIG-V2;请从租户平台重新下载配置文件')
}
const salt = decodeBase64Url(parts[1])
const iv = decodeBase64Url(parts[2])
const encrypted = decodeBase64Url(parts[3])
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.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('配置文件加密参数无效')
}
@ -57,30 +130,30 @@ function decryptConfigAtBuildTime(content) {
try {
const plaintext = Buffer.concat([decipher.update(body), decipher.final()])
return JSON.parse(plaintext.toString('utf8'))
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)
}
// 否则取第一个 .xuqmconfig
const fallback = files.find(f => f.endsWith('.xuqmconfig'))
if (fallback) {
return path.join(absDir, fallback)
}
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}`,
)
}
return null
return candidates[0]
}
function applyBugCollect(metroConfig) {
@ -90,56 +163,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)
@ -152,9 +175,6 @@ function withXuqmConfig(metroConfig) {
// 读取加密配置内容
const encryptedContent = fs.readFileSync(configFile, 'utf-8').trim()
// 单点放置:自动同步到原生 Android/iOS缺失或不一致才复制
syncNativeConfig(projectRoot, encryptedContent)
let resolvedConfig
try {
resolvedConfig = decryptConfigAtBuildTime(encryptedContent)
@ -171,11 +191,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 RESOLVED_CONFIG = ${JSON.stringify(resolvedConfig)} as const;\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
@ -211,4 +231,4 @@ function withXuqmConfig(metroConfig) {
})
}
module.exports = { withXuqmConfig }
module.exports = { decryptConfigAtBuildTime, findConfigFile, withXuqmConfig }

查看文件

@ -0,0 +1,3 @@
{
"xuqm-config-dev-2026-07": "MCowBQYDK2VwAyEAFt9MBN8jNwCfalex4wg7kuy2DRAbXkGxZyA/Jaz+6YA="
}

查看文件

@ -8,12 +8,14 @@
"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",
"./metro": "./metro/index.js",
"./package.json": "./package.json",
"./secure-cache": "./src/secureCache.ts",
"./version": "./src/version.ts"
},
"metro": "metro/index.js",

查看文件

@ -27,11 +27,20 @@ export type SafeApiErrorReport = {
* / bodyheaders URL
* userIdsessionId
*/
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),
}
}
@ -60,7 +69,7 @@ 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),
}
}

查看文件

@ -8,7 +8,7 @@
* 2. metro.config.js 使 withXuqmConfig(metroConfig)
* 3. Metro
*
* require
* 使 withXuqmConfig Metro Bundle
*/
import { isInitialized } from './config'
@ -17,15 +17,22 @@ import { _initializeFromResolvedConfig } from './sdk'
let _autoInitAttempted = false
function tryRequireConfig(): DecryptedConfig | null {
interface AutoInitPayload {
config: DecryptedConfig
buildOptions?: { bugCollectMode?: 'platform' | 'disabled' }
}
function tryRequireConfig(): AutoInitPayload | null {
try {
// withXuqmConfig 将此模块映射到构建期已解密、已校验的配置传输模块。
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('@xuqm/autoinit-config') as {
const mod = require('@xuqm/rn-common/auto-init-config') as {
RESOLVED_CONFIG?: DecryptedConfig
default?: DecryptedConfig
BUILD_OPTIONS?: { bugCollectMode?: 'platform' | 'disabled'; buildId?: string }
}
return mod?.RESOLVED_CONFIG ?? mod?.default ?? null
const config = mod?.RESOLVED_CONFIG ?? mod?.default ?? null
return config ? { config, buildOptions: mod.BUILD_OPTIONS } : null
} catch {
return null
}
@ -42,10 +49,13 @@ export function tryAutoInit(): void {
_autoInitAttempted = true
if (isInitialized()) return
const config = tryRequireConfig()
if (!config) return
const payload = tryRequireConfig()
if (!payload) return
void _initializeFromResolvedConfig(config, { 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,7 +1,14 @@
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 {
@ -19,10 +26,24 @@ export interface XuqmConfig {
// 崩溃采集服务rn-bugcollect 使用)
bugCollectApiUrl: string
bugCollectEnabled: 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 时可不传
@ -39,13 +60,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 ────────────────────────────────────────────────────────────────────
@ -58,10 +83,11 @@ export interface XuqmRemoteConfig {
pushEnabled?: boolean
bugCollectApiUrl?: string
bugCollectEnabled?: boolean
updateRequiresLogin?: boolean
}
export function initConfigFromRemote(
options: XuqmInitOptions,
options: InternalInitOptions,
remote: XuqmRemoteConfig,
signingKey?: string,
): void {
@ -77,13 +103,18 @@ 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),
// 服务端未下发匿名策略时按安全默认禁止匿名更新检查。
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
}
@ -99,6 +130,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,
@ -162,11 +229,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,36 +1,22 @@
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
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 {
let binary = ''
@ -45,41 +31,6 @@ function base64UrlEncode(value: Uint8Array): string {
return bytesToBase64(value).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 }
}
export function hmacSha256Base64Url(secret: string, data: string): string {
return base64UrlEncode(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(data)))
}
@ -88,30 +39,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)
}

查看文件

@ -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
@ -83,7 +84,7 @@ export async function apiRequest<T>(
if (_debugHttp) {
console.debug('[XuqmSDK][HTTP] request', {
method: options.method ?? 'GET',
url,
path: safeRequestPath(url),
hasBody: Boolean(options.body),
})
}
@ -118,10 +119,9 @@ export async function apiRequest<T>(
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}`)
@ -130,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,16 +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 {
bytesToBase64,
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'

查看文件

@ -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,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,152 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import { z } from 'zod'
import {
_notifyInitialized,
_setInitializationSnapshot,
_setPrivacyConsent,
_setUserInfo,
getInitializationSnapshot,
getUserId as getCommonUserId,
getUserInfo as getCommonUserInfo,
initConfigFromRemote,
isInitialized,
type XuqmInitOptions,
type InternalInitOptions,
type XuqmLoginOptions,
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 { retryWithBackoff } from './retry'
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from './secureCache'
import type { DecryptedConfig } from './crypto'
let _initPromise: Promise<void> | null = null
let _resolvedConfigInitPromise: Promise<void> | null = null
let _resolvedConfigRequest: {
config: DecryptedConfig
options?: { debug?: boolean }
options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' }
} | null = null
let _initializationKey: string | null = null
let _sessionTransition: Promise<void> = Promise.resolve()
let _activeSessionKey: string | null = null
const REMOTE_CONFIG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000
const REMOTE_CONFIG_CACHE_PREFIX = '@xuqm_common:remote_config:v1:'
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(),
updateRequiresLogin: z.boolean().optional(),
})
const remoteConfigResponseSchema = 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(),
updateRequiresLogin: z.boolean().optional(),
allowAnonymousUpdateCheck: z.boolean().optional(),
features: z
.object({
im: z.boolean().optional(),
push: z.boolean().optional(),
bugCollect: z.boolean().optional(),
})
.optional(),
})
.loose()
function initializationKey(options: XuqmInitOptions): string {
return `${options.appKey.trim()}\n${(options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')}`
interface CachedRemoteConfig {
cachedAt: number
remote: {
apiUrl?: string
imApiUrl?: string
imWsUrl?: string
fileServiceUrl?: string
imEnabled?: boolean
pushEnabled?: boolean
bugCollectApiUrl?: string
bugCollectEnabled?: boolean
updateRequiresLogin?: boolean
}
}
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 = flatRemoteConfigSchema.safeParse(cached?.remote)
if (
!cached ||
!isCacheRecordFresh(cached.cachedAt, Date.now(), REMOTE_CONFIG_CACHE_TTL_MS) ||
!parsedRemote.success
) {
await AsyncStorage.removeItem(storageKey(options))
return null
}
return { cachedAt: cached.cachedAt, remote: parsedRemote.data }
} 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,27 +165,66 @@ function startInitialization(options: XuqmInitOptions, signingKey?: string): Pro
_initializationKey = nextKey
_initPromise = (async () => {
_setInitializationSnapshot({ state: 'initializing' })
const platformUrl = (options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')
const configUrl = `${platformUrl}/api/sdk/config?appKey=${encodeURIComponent(options.appKey)}`
const res = await fetch(configUrl)
if (!res.ok) {
throw new Error(`[XuqmSDK] Platform config request failed: ${res.status}`)
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}`)
}
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>
const raw = remoteConfigResponseSchema.parse(json.data ?? json)
const features = raw.features ?? {}
remote = {
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,
updateRequiresLogin:
raw.updateRequiresLogin ??
(raw.allowAnonymousUpdateCheck === undefined
? undefined
: !raw.allowAnonymousUpdateCheck),
}
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'
}
const json = (await res.json()) as Record<string, unknown>
const remote = (json.data ?? json) as Record<string, unknown>
initConfigFromRemote(
{ ...options, platformUrl },
{
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,
)
initConfigFromRemote({ ...options, platformUrl, configSource: source }, remote, signingKey)
// Xuqm 平台接口始终回到租户平台。remote.apiUrl 是业务扩展服务地址,
// 二者职责不同;混用会把版本检查错误地发送到 App 自身的业务服务。
configureHttp({
@ -74,10 +232,22 @@ function startInitialization(options: XuqmInitOptions, signingKey?: string): Pro
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
}
@ -89,23 +259,6 @@ function enqueueSessionTransition(action: () => Promise<void>): Promise<void> {
}
export const XuqmSDK = {
/**
* B
*
* appKey IMPush 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
*/
@ -113,6 +266,17 @@ export const XuqmSDK = {
await awaitInitialization()
},
getInitializationState() {
return getInitializationSnapshot()
},
/**
* 宿SDK
*/
async setPrivacyConsent(consented: boolean): Promise<void> {
await _setPrivacyConsent(consented)
},
getUserId(): string | null {
return getCommonUserId()
},
@ -160,8 +324,9 @@ export async function awaitInitialization(): Promise<void> {
? _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
@ -170,15 +335,24 @@ export async function awaitInitialization(): Promise<void> {
/** Metro 自动初始化专用入口;配置已在可信构建进程中完成解密和格式校验。 */
export function _initializeFromResolvedConfig(
config: DecryptedConfig,
options?: { debug?: boolean },
options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' },
): Promise<void> {
_resolvedConfigRequest = { config, options }
if (_resolvedConfigInitPromise) return _resolvedConfigInitPromise
_resolvedConfigInitPromise = (async () => {
const platformUrl = config.serverUrl ?? config.baseUrl ?? undefined
const platformUrl = config.serverUrl
await startInitialization(
{ appKey: config.appKey, platformUrl, debug: options?.debug },
{
appKey: config.appKey,
platformUrl,
debug: options?.debug,
bugCollectMode: options?.bugCollectMode,
packageName:
runtimePlatform() === 'IOS'
? (config.iosBundleId ?? config.packageName)
: config.packageName,
},
config.signingKey,
)
})().catch(error => {

查看文件

@ -0,0 +1,70 @@
import { gcm } from '@noble/ciphers/aes.js'
import { sha256 } from '@noble/hashes/sha2.js'
import { utf8ToBytes } from '@noble/hashes/utils.js'
const CACHE_FORMAT = 'XUQM-CACHE-V1'
const NONCE_BYTES = 12
function bytesToBase64Url(value: Uint8Array): string {
let binary = ''
for (let offset = 0; offset < value.length; offset += 0x8000) {
binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000))
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
function base64UrlToBytes(value: string): Uint8Array {
const padded =
value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4)
const binary = atob(padded)
return Uint8Array.from(binary, character => character.charCodeAt(0))
}
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 = base64UrlToBytes(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(
base64UrlToBytes(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
}

查看文件

@ -6,6 +6,7 @@ import {
createResponseDiagnostic,
createSafeApiErrorReport,
createValidationDiagnostic,
safeRequestPath,
} from '../src/api/diagnostics'
import { RequestError } from '../src/api/errors'
@ -41,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(), [
{

查看文件

@ -1,55 +1,15 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { bytesToBase64, 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('bytesToBase64 supports plugin-sized byte arrays', () => {
const bytes = Uint8Array.from({ length: 100_000 }, (_, index) => index % 251)
assert.equal(Buffer.from(bytes).toString('base64'), bytesToBase64(bytes))
})
test('decryptXuqmFile remains compatible with existing PBKDF2 + AES-256-GCM files', async () => {
await assert.doesNotReject(async () => {
const result = await decryptXuqmFile(CONFIG_VECTOR)
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_-]+$/)
})

查看文件

@ -0,0 +1,4 @@
// 真实 Metro fixture不使用 withXuqmConfig,也不提供 .xuqmconfig。
const config = require('@xuqm/rn-common/auto-init-config')
module.exports = config.RESOLVED_CONFIG === undefined

查看文件

@ -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,7 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["./public-api.ts"]
}

查看文件

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

查看文件

@ -7,7 +7,7 @@ const test = require('node:test')
const { withXuqmConfig } = require('../metro')
const CONFIG_VECTOR =
'XUQM-CONFIG-V1.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.vhdKh3AhEdeftXCdXp9KusVySkrBNAzmdaA747uf1tVWGFnAsJeCLe2mHKk0dxxez8SRTknIbk1UuRibnXY0Gbot1fmkR-jSN2ssMJO0_zQ8dUu8'
'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-'))
@ -32,14 +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')
const generatedSource = fs.readFileSync(virtualModule.filePath, 'utf8')
assert.match(generatedSource, /RESOLVED_CONFIG/)
assert.match(generatedSource, /"appKey":"test-app"/)
assert.equal(generatedSource.includes('XUQM-CONFIG-V1'), false)
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 })
}
@ -60,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,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)
})

查看文件

@ -2,9 +2,14 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import { apiRequest } from '../src/http'
import { _initializeFromResolvedConfig, awaitInitialization } from '../src/sdk'
import { getConfig, getInitializationSnapshot } from '../src/config'
import { _initializeFromResolvedConfig, awaitInitialization, XuqmSDK } from '../src/sdk'
test('awaitInitialization observes in-flight work and retries a transient config request failure', async () => {
test('public SDK has no second manual initialization entry', () => {
assert.equal('initialize' in XuqmSDK, false)
})
test('awaitInitialization retries a transient config request without leaking failure to the host', async () => {
const originalFetch = globalThis.fetch
let requestCount = 0
const requestedUrls: string[] = []
@ -38,17 +43,20 @@ test('awaitInitialization observes in-flight work and retries a transient config
{
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.rejects(() => awaitInitialization(), /Network request failed/)
await assert.rejects(() => autoInitialization, /Network request failed/)
// 配置内容由 common 保存,扩展包再次等待时可以自行重试,无需宿主传递配置。
await assert.doesNotReject(() => autoInitialization)
await assert.doesNotReject(() => awaitInitialization())
assert.equal(requestCount, 2)
assert.deepEqual(getInitializationSnapshot(), { state: 'ready', source: 'remote' })
assert.equal(getConfig().updateRequiresLogin, true)
const result = await apiRequest<{ available: boolean }>('/api/v1/rn/release-set/check', {
skipAuth: true,

查看文件

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

查看文件

@ -13,10 +13,20 @@ 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`
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。
@ -38,6 +48,10 @@ module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname))
- `pnpm release:android -- --apk`:生成包含同一批插件的 Release APK。
- `pnpm publish:android`上传插件产物;SDK npm 制品发布只能通过 Jenkins。
发布使用的 `XUQM_API_TOKEN` 只能由 Jenkins Credentials 注入环境变量,不属于
`xuqm.modules.json``config.xuqmconfig`。CLI 不读取项目文件中的 Token,避免可用凭据
进入源码、插件包或构建日志。
Android 宿主只应用一次 SDK 脚本:
```groovy
@ -71,7 +85,7 @@ import '@xuqm/rn-update/plugin-registry'
插件产物的引擎只有一个事实来源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`
## 运行时规则

查看文件

@ -1,6 +1,7 @@
package com.xuqm.update;
import android.content.Context;
import android.content.SharedPreferences;
import android.util.Base64;
import androidx.annotation.NonNull;
@ -54,6 +55,9 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
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;
@ -73,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 {
@ -229,6 +279,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
JSONObject state = readState(moduleId);
state.put("moduleId", moduleId);
state.put("pendingVersion", version);
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"));
@ -332,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");
@ -453,7 +504,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
File activeAssets = new File(moduleDirectory, ACTIVE_ASSETS_DIR);
deleteRecursively(activeAssets);
restoreEmbeddedAssets(activeAssets, entry.optJSONArray("assetFiles"));
writeEmbeddedState(moduleId, entry, embeddedHash, bytes.length);
writeEmbeddedState(moduleId, manifest, entry, embeddedHash, bytes.length);
return active;
}
@ -488,14 +539,26 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
if (embeddedLength >= 0L && active.length() != embeddedLength) return false;
if (!isEmbeddedStateCurrent(state, manifest, entry, embeddedHash, active.length())) {
// 新版 state 字段或兼容元数据变化时只刷新小状态文件不读取/复制 Bundle
writeEmbeddedState(moduleId, entry, embeddedHash, active.length());
writeEmbeddedState(moduleId, manifest, entry, embeddedHash, active.length());
}
return true;
}
String installedVersion = state.optString(metadataPrefix + "Version", "");
String embeddedVersion = entry.optString("moduleVersion", "0.0.0");
if (!XuqmSemanticVersion.isAtLeast(installedVersion, embeddedVersion)) return false;
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", "");
@ -517,6 +580,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
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", ""))
@ -530,16 +594,23 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
private void writeEmbeddedState(
String moduleId,
JSONObject manifest,
JSONObject entry,
String embeddedHash,
long byteLength) throws Exception {
writeState(
moduleId,
createEmbeddedState(moduleId, entry, embeddedHash, byteLength));
createEmbeddedState(
moduleId,
manifest.optString("buildId", ""),
entry,
embeddedHash,
byteLength));
}
private JSONObject createEmbeddedState(
String moduleId,
String buildId,
JSONObject entry,
String embeddedHash,
long byteLength) throws Exception {
@ -547,6 +618,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
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));
}
@ -721,6 +793,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
}
return createEmbeddedState(
moduleId,
manifest.optString("buildId", ""),
entry,
entry.optString("sha256", ""),
entry.optLong("byteLength", -1L));
@ -774,6 +847,7 @@ 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");
@ -787,6 +861,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
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");
@ -800,6 +875,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
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");
@ -817,15 +893,15 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
}
private void clearActiveMetadata(JSONObject state) {
for (String key : new String[]{"activeVersion", "activeHash", "activeByteLength", "activeType", "activeAppVersionRange", "activeBuiltAgainstNativeBaselineId", "activeCommonVersionRange", "activeMinNativeApiLevel", "activePackageHash", "activeAssetFiles"}) 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", "pendingByteLength", "pendingType", "pendingAppVersionRange", "pendingBuiltAgainstNativeBaselineId", "pendingCommonVersionRange", "pendingMinNativeApiLevel", "pendingPackageHash", "pendingAssetFiles"}) 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", "rollbackByteLength", "rollbackType", "rollbackAppVersionRange", "rollbackBuiltAgainstNativeBaselineId", "rollbackCommonVersionRange", "rollbackMinNativeApiLevel", "rollbackPackageHash", "rollbackAssetFiles"}) 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) {

查看文件

@ -137,6 +137,9 @@ final class XuqmPluginPackageStager {
|| !"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;
}

查看文件

@ -1,6 +1,8 @@
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;
@ -23,4 +25,25 @@ public final class XuqmBundleStorageTest {
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);
}
}

查看文件

@ -40,6 +40,10 @@ 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
@ -54,6 +58,12 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask {
@Input
abstract Property<String> getAppVersion()
@Input
abstract Property<String> getBuildId()
@Input
abstract Property<String> getBugCollectMode()
@OutputDirectory
abstract DirectoryProperty getAssetsOutputDirectory()
@ -69,6 +79,8 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask {
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,
@ -80,6 +92,11 @@ 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
@ -108,6 +125,35 @@ 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",
@ -117,8 +163,13 @@ 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/**")
@ -166,6 +217,8 @@ def prepareXuqmBundles = tasks.register(
}
version.toString()
})
buildId.set(xuqmBuildId)
bugCollectMode.set(xuqmBugCollectMode)
assetsOutputDirectory.set(layout.buildDirectory.dir("generated/xuqm/assets"))
resourcesOutputDirectory.set(layout.buildDirectory.dir("generated/xuqm/res"))
}

查看文件

@ -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 @@
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,40 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
/**
* 整包与插件发布共用的配置验证门禁只阻断新产物已安装 App 不在线查询吊销状态
*/
export async function validateReleaseConfig({ projectRoot, packageName, platform }) {
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)
const targetPackageName =
packageName ??
(platform === 'ios' ? (resolved.iosBundleId ?? resolved.packageName) : resolved.packageName)
if (!targetPackageName) 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: targetPackageName }),
},
)
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,98 @@
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 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.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,
moduleId,
platform,
moduleVersion,
appVersion,
buildId,
bundleHash: actualBundleHash,
bundleFormat,
nativeBaselineId,
appVersionRange,
commonVersionRange: manifest.commonVersionRange,
minNativeApiLevel,
}
}

查看文件

@ -0,0 +1,76 @@
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,
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 = {
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.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 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'])

查看文件

@ -29,6 +29,10 @@ import {
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)
@ -43,6 +47,26 @@ function writeJson(file, value) {
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
}
function androidApplicationId() {
const gradleFile = ['android/app/build.gradle', 'android/app/build.gradle.kts']
.map(file => path.resolve(root, file))
.find(existsSync)
if (!gradleFile) throw new Error('Android app Gradle file is missing')
const match = readFileSync(gradleFile, 'utf8').match(
/\bapplicationId\s*(?:=|\s)\s*["']([^"']+)["']/,
)
if (!match?.[1]) throw new Error('Android applicationId could not be resolved')
return match[1]
}
async function validateReleaseConfigOnline(platform) {
await validateReleaseConfig({
projectRoot: root,
packageName: platform === 'android' ? androidApplicationId() : undefined,
platform,
})
}
function childEnvironment(overrides = {}) {
const environment = { ...process.env, ...overrides }
// Metro may set FORCE_COLOR in its transformer workers. Forward a single
@ -337,9 +361,8 @@ function build(platform, optionArgs = []) {
]
const metroConfig = module.metroConfig ?? config.metroConfig
if (metroConfig) cliArgs.push('--config', metroConfig)
if (module.sourceMap === true) {
cliArgs.push('--sourcemap-output', `${bundleFile(root, config, platform, module.id)}.map`)
}
// 每个 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), {
XUQM_MODULE_CACHE_FILE: path.join(root, '.xuqm-cache', 'common-module-ids.json'),
@ -348,8 +371,8 @@ function build(platform, optionArgs = []) {
XUQM_MODULE_TYPE: module.type,
XUQM_RESET_MODULE_CACHE: buildIndex === 0 ? 'true' : 'false',
})
if (useHermes)
compileHermesBundle(bundleFile(root, config, platform, module.id), module.sourceMap === true)
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') {
@ -421,6 +444,8 @@ function createModulePackage(config, platform, module, appVersion, nativeBaselin
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'),
@ -487,6 +512,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,
}
}
@ -563,33 +590,81 @@ 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])
build(platform, buildOptions)
const releaseScript = new URL('./xuqm_release.mjs', import.meta.url)
execFileSync(process.execPath, [releaseScript.pathname, '--platform', platform, ...optionArgs], {
cwd: root,
stdio: 'inherit',
})
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],
{
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
await validateReleaseConfigOnline(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'], {
cwd: androidDirectory,
stdio: 'inherit',
})
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) {
@ -650,7 +725,7 @@ try {
publish(args.shift() ?? 'android', args)
break
case 'package':
packageApp(args.shift() ?? 'android', args)
await packageApp(args.shift() ?? 'android', args)
break
default:
printHelp()

查看文件

@ -21,6 +21,17 @@ import { strFromU8, unzipSync } from 'fflate'
const cli = fileURLToPath(new URL('./xuqm-rn.mjs', import.meta.url))
const workspaceRequire = createRequire(import.meta.url)
test('Gradle models and forwards the exact embedded release identity', () => {
const gradle = readFileSync(
fileURLToPath(new URL('../android/xuqm-bundles.gradle', import.meta.url)),
'utf8',
)
assert.match(gradle, /@Input\s+abstract Property<String> getBuildId\(\)/)
assert.match(gradle, /@Input\s+abstract Property<String> getBugCollectMode\(\)/)
assert.match(gradle, /environment\("XUQM_BUILD_ID", buildId\.get\(\)\)/)
assert.match(gradle, /environment\("XUQM_BUGCOLLECT_MODE", bugCollectMode\.get\(\)\)/)
})
test('embed creates a complete versioned manifest from a host fixture', () => {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-'))
const output = path.join(root, 'generated', 'rn-bundles')
@ -37,7 +48,7 @@ test('embed creates a complete versioned manifest from a host fixture', () => {
writeFileSync(path.join(root, 'app.ts'), '')
writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n')
writeFileSync(
path.join(root, 'xuqm.config.json'),
path.join(root, 'xuqm.modules.json'),
JSON.stringify({
schemaVersion: 3,
appId: 'fixture.app',
@ -104,7 +115,7 @@ test('Android Hermes hosts compile plugin bundles to bytecode', () => {
mkdirSync(path.join(root, 'android'), { recursive: true })
writeFileSync(path.join(root, 'android', 'gradle.properties'), 'hermesEnabled=true\n')
writeFileSync(
path.join(root, 'xuqm.config.json'),
path.join(root, 'xuqm.modules.json'),
JSON.stringify({
schemaVersion: 3,
appId: 'fixture.app',
@ -147,8 +158,24 @@ test('Android Hermes hosts compile plugin bundles to bytecode', () => {
const path = require('node:path');
const args = process.argv.slice(2);
const output = args[args.indexOf('--bundle-output') + 1];
const sourceMap = args[args.indexOf('--sourcemap-output') + 1];
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, 'javascript');
fs.writeFileSync(sourceMap, JSON.stringify({
version: 3,
sources: ['/fixture/common.ts'],
names: [],
mappings: '',
}));
`,
)
writeFileSync(
path.join(reactNativeDirectory, 'scripts', 'compose-source-maps.js'),
`
const fs = require('node:fs');
const args = process.argv.slice(2);
const output = args[args.indexOf('-o') + 1];
fs.copyFileSync(args[0], output);
`,
)
writeFileSync(
@ -188,6 +215,11 @@ test('Android Hermes hosts compile plugin bundles to bytecode', () => {
execFileSync(process.execPath, [cli, 'embed', 'android'], {
cwd: root,
stdio: 'pipe',
env: {
...process.env,
XUQM_BUILD_ID: 'release-fixture-1',
XUQM_BUGCOLLECT_MODE: 'disabled',
},
})
const bytecode = readFileSync(path.join(root, 'generated', 'common.android.bundle'))
@ -195,6 +227,16 @@ test('Android Hermes hosts compile plugin bundles to bytecode', () => {
assert.notEqual(bytecode.toString('utf8'), 'javascript')
const manifest = JSON.parse(readFileSync(path.join(root, 'generated', 'manifest.json'), 'utf8'))
assert.equal(manifest.bundleFormat, 'hermes-bytecode')
assert.equal(manifest.buildId, 'release-fixture-1')
assert.equal(manifest.bugCollectMode, 'disabled')
const packaged = unzipSync(
new Uint8Array(
readFileSync(path.join(root, 'bundle/android/common/common.android.xuqm.zip')),
),
)
const packagedManifest = JSON.parse(strFromU8(packaged['rn-manifest.json']))
assert.equal(packagedManifest.buildId, manifest.buildId)
assert.equal(packagedManifest.bugCollectMode, manifest.bugCollectMode)
} finally {
rmSync(root, { recursive: true, force: true })
}
@ -223,7 +265,7 @@ test('embed rejects assets owned by more than one module', () => {
}
writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n')
writeFileSync(
path.join(root, 'xuqm.config.json'),
path.join(root, 'xuqm.modules.json'),
JSON.stringify({
schemaVersion: 3,
appId: 'fixture.app',
@ -266,7 +308,7 @@ test('build invokes the host-local React Native CLI with a consistent color envi
)
writeFileSync(path.join(root, 'common.ts'), '')
writeFileSync(
path.join(root, 'xuqm.config.json'),
path.join(root, 'xuqm.modules.json'),
JSON.stringify({
schemaVersion: 3,
appId: 'fixture.app',
@ -287,8 +329,15 @@ test('build invokes the host-local React Native CLI with a consistent color envi
const path = require('node:path');
const args = process.argv.slice(2);
const output = args[args.indexOf('--bundle-output') + 1];
const sourceMap = args[args.indexOf('--sourcemap-output') + 1];
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, 'bundle');
fs.writeFileSync(sourceMap, JSON.stringify({
version: 3,
sources: ['/fixture/common.ts'],
names: [],
mappings: '',
}));
fs.writeFileSync(path.join(process.cwd(), 'child.json'), JSON.stringify({
args,
forceColor: process.env.FORCE_COLOR ?? null,
@ -331,7 +380,7 @@ test('building one buz first rebuilds startup and common module maps', () => {
writeFileSync(path.join(root, 'shared', 'ignored.test.ts'), 'throw new Error()\n')
writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n')
writeFileSync(
path.join(root, 'xuqm.config.json'),
path.join(root, 'xuqm.modules.json'),
JSON.stringify({
schemaVersion: 3,
appId: 'fixture.app',
@ -374,8 +423,15 @@ test('building one buz first rebuilds startup and common module maps', () => {
});
fs.writeFileSync(callsFile, JSON.stringify(calls));
const output = args[args.indexOf('--bundle-output') + 1];
const sourceMap = args[args.indexOf('--sourcemap-output') + 1];
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, 'bundle');
fs.writeFileSync(sourceMap, JSON.stringify({
version: 3,
sources: ['/fixture/' + process.env.XUQM_MODULE_ID + '.ts'],
names: [],
mappings: '',
}));
`,
)

查看文件

@ -1,6 +1,7 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs'
import { createHash } from 'node:crypto'
import path from 'node:path'
import { createInterface } from 'node:readline'
import process from 'node:process'
@ -12,7 +13,14 @@ import {
resolveVersionedModules,
selectModules,
} from './xuqm-config.mjs'
import { computeNativeBaseline } from './native-baseline.mjs'
import {
BUGCOLLECT_ARTIFACT_UPLOAD_PATH,
createArtifactUploadRequest,
createRnSourceMapForm,
} from './artifact-upload.mjs'
import { validateReleaseConfig } from './release-config.mjs'
import { readReleasePackageIdentity } from './release-package.mjs'
import { parseBugCollectOption } from './package-options.mjs'
const root = process.cwd()
const rawArgs = process.argv.slice(2)
@ -28,13 +36,21 @@ function takeOption(name) {
const platform = takeOption('--platform') ?? 'android'
const noteOption = takeOption('--note')
const minNativeApiLevelOption = takeOption('--min-native-api-level')
const parsedBugCollect = parseBugCollectOption(
rawArgs,
process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
)
const bugCollectMode = parsedBugCollect.bugCollectMode
rawArgs.splice(0, rawArgs.length, ...parsedBugCollect.remaining)
const publishOptionIndex = rawArgs.indexOf('--publish')
const publishOption = publishOptionIndex >= 0
if (publishOption) rawArgs.splice(publishOptionIndex, 1)
const { moduleIds, remaining } = parseModuleOptions(rawArgs)
if (remaining.length) throw new Error(`unknown option(s): ${remaining.join(' ')}`)
if (!SUPPORTED_PLATFORMS.has(platform)) throw new Error('platform must be android or ios')
if (!['platform', 'disabled'].includes(bugCollectMode)) {
throw new Error('--bugcollect must be platform or disabled')
}
const config = readConfig(root)
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
@ -44,20 +60,14 @@ if (moduleIds.length > 0 && selectedModules.some(module => module.type === 'star
}
const modules = selectedModules.filter(module => module.type !== 'startup')
if (modules.length === 0) throw new Error('no publishable common/app/buz module selected')
const nativeBaselineId = computeNativeBaseline(root, platform)
const serverUrl = process.env.XUQM_SERVER_URL ?? config.release?.serverUrl
const appKey = process.env.XUQM_APP_KEY ?? config.release?.appKey
const apiToken = process.env.XUQM_API_TOKEN ?? config.release?.apiToken
const androidPropertiesFile = path.join(root, 'android', 'gradle.properties')
const bundleFormat =
platform === 'android' &&
existsSync(androidPropertiesFile) &&
/^\s*hermesEnabled\s*=\s*true\s*$/im.test(readFileSync(androidPropertiesFile, 'utf8'))
? 'hermes-bytecode'
: 'javascript'
// API Token 只允许由 CI/当前进程注入,禁止把可用凭据写进会进入版本库的插件清单。
const apiToken = process.env.XUQM_API_TOKEN
const bugCollectUrl = process.env.XUQM_BUGCOLLECT_URL
if (!serverUrl || !appKey || !apiToken) {
throw new Error(
'publish requires XUQM_SERVER_URL, XUQM_APP_KEY and XUQM_API_TOKEN (or release.* config)',
'publish requires XUQM_API_TOKEN and XUQM_SERVER_URL/XUQM_APP_KEY (environment or non-secret release config)',
)
}
@ -69,22 +79,42 @@ function apiHeaders() {
return { Authorization: `Bearer ${apiToken}` }
}
async function uploadBundle(module, note, minNativeApiLevel) {
async function validateReleaseConfigOnline() {
await validateReleaseConfig({
projectRoot: root,
packageName: config.packageName,
platform,
})
}
function releasePackage(module) {
const file = path.join(
bundleOutputDirectory(root, config, platform, module.id),
`${module.id}.${platform}.xuqm.zip`,
)
if (!existsSync(file)) throw new Error(`plugin package is missing: ${file}`)
const identity = readReleasePackageIdentity(file, {
moduleId: module.id,
platform,
moduleVersion: module.moduleVersion,
appVersion: versioned.appVersion,
})
return { file, identity }
}
async function uploadBundle(module, release, note) {
const { file, identity } = release
const form = new FormData()
form.append('appKey', appKey)
form.append('moduleId', module.id)
form.append('platform', platform.toUpperCase())
form.append('version', module.moduleVersion)
form.append('appVersionRange', module.appVersionRange)
form.append('builtAgainstNativeBaselineId', nativeBaselineId)
form.append('bundleFormat', bundleFormat)
form.append('commonVersionRange', module.commonVersionRange ?? '')
form.append('minNativeApiLevel', String(module.minNativeApiLevel ?? minNativeApiLevel))
form.append('version', identity.moduleVersion)
form.append('appVersionRange', identity.appVersionRange)
form.append('builtAgainstNativeBaselineId', identity.nativeBaselineId)
form.append('buildId', identity.buildId)
form.append('bundleFormat', identity.bundleFormat)
form.append('commonVersionRange', identity.commonVersionRange ?? '')
form.append('minNativeApiLevel', String(identity.minNativeApiLevel))
form.append('note', note)
if (config.packageName) form.append('packageName', config.packageName)
form.append('bundle', new Blob([readFileSync(file)]), `${module.id}.${platform}.xuqm.zip`)
@ -97,14 +127,47 @@ async function uploadBundle(module, note, minNativeApiLevel) {
return response.json()
}
async function main() {
const note = noteOption ?? (await ask('Release notes: '))
const minNativeApiLevel = Number(
minNativeApiLevelOption ?? config.release?.minNativeApiLevel ?? 2,
)
if (!Number.isInteger(minNativeApiLevel) || minNativeApiLevel < 1) {
throw new Error('minNativeApiLevel must be a positive integer')
async function uploadSourceMap(module, release) {
const bundleDirectory = bundleOutputDirectory(root, config, platform, module.id)
const bundleFile = path.join(bundleDirectory, `${module.id}.${platform}.bundle`)
const sourceMapFile = `${bundleFile}.map`
if (!existsSync(sourceMapFile)) throw new Error(`Source Map is missing: ${sourceMapFile}`)
const sourceMapBundleHash = createHash('sha256').update(readFileSync(bundleFile)).digest('hex')
if (sourceMapBundleHash !== release.identity.bundleHash) {
throw new Error(
`Source Map Bundle identity mismatch for ${module.id}; rebuild before publishing`,
)
}
if (bugCollectMode === 'disabled') {
console.log(` ${module.id}/${platform} Source Map archived locally (BugCollect disabled)`)
return
}
if (!bugCollectUrl) {
throw new Error('XUQM_BUGCOLLECT_URL is required when BugCollect Source Map upload is enabled')
}
const form = createRnSourceMapForm({
appKey,
platform,
appVersion: versioned.appVersion,
buildId: release.identity.buildId,
moduleId: module.id,
moduleVersion: module.moduleVersion,
bundleHash: release.identity.bundleHash,
fileBytes: readFileSync(sourceMapFile),
fileName: `${module.id}.${platform}.map`,
})
const response = await fetch(
`${bugCollectUrl.replace(/\/$/, '')}${BUGCOLLECT_ARTIFACT_UPLOAD_PATH}`,
createArtifactUploadRequest(apiToken, form),
)
if (!response.ok) {
throw new Error(`Source Map upload failed for ${module.id}: ${await response.text()}`)
}
}
async function main() {
await validateReleaseConfigOnline()
const note = noteOption ?? (await ask('Release notes: '))
const publishImmediately = publishOption || config.release?.publishImmediately === true
console.log('\n\x1b[36m--- Xuqm RN plugin publish ---\x1b[0m')
console.log(` Platform: ${platform}`)
@ -112,12 +175,16 @@ async function main() {
` Modules: ${modules.map(module => `${module.id}@${module.moduleVersion}`).join(', ')}`,
)
console.log(` Publish immediately: ${publishImmediately}`)
console.log(` BugCollect: ${bugCollectMode}`)
if (!(await confirm('Proceed?'))) return
for (const module of modules) {
const result = await uploadBundle(module, note, minNativeApiLevel)
const release = releasePackage(module)
const result = await uploadBundle(module, release, note)
const bundleId = result.data?.id
console.log(`\x1b[32m✓ ${module.id}/${platform} uploaded, ID: ${bundleId}\x1b[0m`)
// Source Map 是正式发布硬门禁,必须在插件进入 published 状态前完成。
await uploadSourceMap(module, release)
if (publishImmediately) {
const response = await fetch(`${serverUrl}/api/v1/rn/${bundleId}/publish`, {
method: 'POST',

查看文件

@ -0,0 +1,42 @@
import type { XuqmBundleIdentity } from '@xuqm/rn-common/internal'
import { NativeBundle } from './NativeBundle'
import { selectStackModuleId, type BundleManifestEntry } from './bundleIdentityPolicy'
interface EmbeddedManifest {
buildId?: string
modules?: Record<string, BundleManifestEntry>
}
/**
* manifest/state stack null
* BugCollect latest Source Map
*/
export async function resolveCurrentBundleIdentity(
stacktrace: string,
): Promise<XuqmBundleIdentity | null> {
let manifest: EmbeddedManifest
try {
manifest = JSON.parse(await NativeBundle.getManifest()) as EmbeddedManifest
} catch {
return null
}
const modules = manifest.modules ?? {}
const moduleId = selectStackModuleId(stacktrace, modules)
if (!moduleId) return null
const embedded = modules[moduleId]
const state = await NativeBundle.getState(moduleId).catch(() => null)
const pending = state?.status === 'pending_confirmation'
const moduleVersion =
(pending ? state.pendingVersion : state?.activeVersion) ?? embedded.moduleVersion
const bundleHash = (pending ? state.pendingHash : state?.activeHash) ?? embedded.sha256
const buildId = (pending ? state.pendingBuildId : state?.activeBuildId) ?? manifest.buildId
if (!buildId || !moduleVersion || !bundleHash || !/^[a-f0-9]{64}$/i.test(bundleHash)) {
return null
}
return {
buildId,
moduleId,
moduleVersion,
bundleHash: bundleHash.toLowerCase(),
}
}

查看文件

@ -5,6 +5,7 @@ export interface BundleModuleState {
moduleId: string
status: 'embedded' | 'active' | 'staged' | 'pending_confirmation'
activeVersion?: string
activeBuildId?: string
activeHash?: string
activeByteLength?: number
activeType?: PluginModuleType
@ -13,6 +14,8 @@ export interface BundleModuleState {
activeCommonVersionRange?: string
activeMinNativeApiLevel?: number
pendingVersion?: string
pendingBuildId?: string
pendingHash?: string
releaseId?: string
launchAttempts?: number
}
@ -54,6 +57,10 @@ interface XuqmBundleModuleInterface {
preparePendingRelease(): Promise<string[]>
getLaunchBundlePath(moduleId: string): Promise<string>
getAssetIndex(): Promise<string>
resetToEmbedded(): Promise<boolean>
getResetGeneration(): Promise<number>
getConfirmedResetGeneration(): Promise<number>
confirmResetGeneration(generation: number): Promise<boolean>
}
function getModule(): XuqmBundleModuleInterface {
@ -140,4 +147,21 @@ export const NativeBundle = {
getFileName(moduleId: string): string {
return `${moduleId}.${Platform.OS}.bundle`
},
/** 只清理 Update SDK 私有 Bundle 状态,下一次访问按 APK 内置 manifest 恢复。 */
async resetToEmbedded(): Promise<void> {
await getModule().resetToEmbedded()
},
getResetGeneration(): Promise<number> {
return getModule().getResetGeneration()
},
getConfirmedResetGeneration(): Promise<number> {
return getModule().getConfirmedResetGeneration()
},
async confirmResetGeneration(generation: number): Promise<void> {
await getModule().confirmResetGeneration(generation)
},
}

查看文件

@ -41,6 +41,11 @@ export const XuqmRuntime = (() => {
if (!next.plugins.length) {
throw new Error('[XuqmRuntime] At least one plugin registration is required')
}
const commonCount = next.plugins.filter(plugin => plugin.type === 'common').length
const appCount = next.plugins.filter(plugin => plugin.type === 'app').length
if (commonCount !== 1 || appCount !== 1) {
throw new Error('[XuqmRuntime] Exactly one common and one app plugin must be registered')
}
options = next
UpdateSDK.registerPlugins(next.plugins)
},
@ -48,7 +53,7 @@ export const XuqmRuntime = (() => {
async start() {
if (!options) throw new Error('[XuqmRuntime] configure() must be called before start()')
await UpdateSDK.preparePendingRelease()
if (options.checkUpdatesOnStart !== false) {
if (!__DEV__ && options.checkUpdatesOnStart !== false) {
return UpdateSDK.checkStartupUpdate()
}
return { kind: 'none' as const }

查看文件

@ -21,6 +21,7 @@ import {
type PluginReleaseCandidate,
type ReleaseSetPlan,
} from './releaseSet'
import { shouldSkipUpdateForLogin } from './updateLoginPolicy'
export interface PluginRegistration {
moduleId: string
@ -43,6 +44,7 @@ export interface AppUpdateInfo {
requiresLogin?: boolean
alreadyDownloaded?: boolean
sha256?: string
skippedReason?: 'LOGIN_REQUIRED'
}
export type UpdateDownloadProgress = DownloadProgress
@ -91,7 +93,36 @@ const pluginRegistry = new Map<string, PluginRegistration>()
const confirmedReleaseModules = new Set<string>()
const UPDATE_APP_CACHE_KEY = 'xuqm_update_app_cache'
const UPDATE_IGNORED_VERSION_KEY = 'xuqm_update_ignored_version_code'
const QUARANTINED_RELEASES_KEY = 'xuqm_update_quarantined_releases'
const UPDATE_CACHE_TTL_MS = 30 * 60 * 1000
let sessionGeneration = 0
let sessionNonce = `${Date.now()}-${Math.random().toString(36).slice(2)}`
let automaticLoginCheckGeneration = -1
let activeSessionAbort = new AbortController()
const automaticResultListeners = new Set<(result: StartupUpdateResult) => void>()
function sessionCacheKey(base: string): string {
const userId = getUserId()?.trim()
return userId ? `${base}:user:${userId}:session:${sessionNonce}` : `${base}:anonymous`
}
function currentSessionSignal(signal?: AbortSignal): AbortSignal {
return signal ? AbortSignal.any([signal, activeSessionAbort.signal]) : activeSessionAbort.signal
}
function isLoginRequiredWithoutSession(): boolean {
return shouldSkipUpdateForLogin(getConfig().updateRequiresLogin, getUserId())
}
async function clearSessionUpdateState(): Promise<void> {
const previousCacheKey = sessionCacheKey(UPDATE_APP_CACHE_KEY)
activeSessionAbort.abort()
activeSessionAbort = new AbortController()
sessionGeneration += 1
sessionNonce = `${Date.now()}-${Math.random().toString(36).slice(2)}`
automaticLoginCheckGeneration = -1
await AsyncStorage.removeItem(previousCacheKey).catch(() => undefined)
}
async function getIgnoredVersionCode(): Promise<number | null> {
const raw = await AsyncStorage.getItem(UPDATE_IGNORED_VERSION_KEY).catch(() => null)
@ -115,6 +146,29 @@ async function writeUpdateCache<T>(key: string, data: T): Promise<void> {
await AsyncStorage.setItem(key, JSON.stringify({ ts: Date.now(), data })).catch(() => undefined)
}
async function readQuarantinedReleases(): Promise<Record<string, string>> {
try {
const raw = await AsyncStorage.getItem(QUARANTINED_RELEASES_KEY)
return raw ? (JSON.parse(raw) as Record<string, string>) : {}
} catch {
return {}
}
}
async function quarantineRelease(releaseId: string, moduleIds: readonly string[]): Promise<void> {
const quarantined = await readQuarantinedReleases()
for (const moduleId of moduleIds) quarantined[moduleId] = releaseId
await AsyncStorage.setItem(QUARANTINED_RELEASES_KEY, JSON.stringify(quarantined)).catch(
() => undefined,
)
}
async function clearUpdateStorage(): Promise<void> {
const keys = await AsyncStorage.getAllKeys().catch(() => [])
const updateKeys = keys.filter(key => key.startsWith('xuqm_update_'))
if (updateKeys.length > 0) await AsyncStorage.removeMany(updateKeys)
}
function normalizeDownloadUrl(rawUrl?: string): string | undefined {
if (!rawUrl) return rawUrl
if (rawUrl.includes('/api/v1/updates/api/v1/rn/files/')) {
@ -200,6 +254,35 @@ function assertSha256(bytes: Uint8Array | string, expected: string, label: strin
}
export const UpdateSDK = {
developer: {
/** Debug 开发页单次检查;不改变后续启动的默认关闭策略。 */
async checkStartupOnce(): Promise<StartupUpdateResult> {
await awaitInitialization()
if (!getConfig().debug) {
throw new Error('[UpdateSDK] developer.checkStartupOnce is only available in Debug.')
}
return UpdateSDK.checkStartupUpdate()
},
/** Debug 开发页单次检查并安装目标插件。 */
async checkAndInstallPluginOnce(
moduleId: string,
options: {
signal?: AbortSignal
checkTimeoutMs?: number
onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void
} = {},
): Promise<ReleaseSetPlan | null> {
await awaitInitialization()
if (!getConfig().debug) {
throw new Error(
'[UpdateSDK] developer.checkAndInstallPluginOnce is only available in Debug.',
)
}
return UpdateSDK.checkAndInstallPlugin(moduleId, options)
},
},
registerPlugins(plugins: PluginRegistration[]): void {
for (const plugin of plugins) UpdateSDK.registerPlugin(plugin)
},
@ -227,6 +310,9 @@ export const UpdateSDK = {
async checkAppUpdate(bypassIgnore = false): Promise<AppUpdateInfo> {
await awaitInitialization()
if (isLoginRequiredWithoutSession()) {
return { needsUpdate: false, requiresLogin: true, skippedReason: 'LOGIN_REQUIRED' }
}
const applyIgnore = async (info: AppUpdateInfo): Promise<AppUpdateInfo> => {
if (bypassIgnore || !info.needsUpdate || info.forceUpdate) return info
const ignored = await getIgnoredVersionCode()
@ -235,7 +321,7 @@ export const UpdateSDK = {
: info
}
if (!bypassIgnore) {
const cached = await readUpdateCache<AppUpdateInfo>(UPDATE_APP_CACHE_KEY)
const cached = await readUpdateCache<AppUpdateInfo>(sessionCacheKey(UPDATE_APP_CACHE_KEY))
if (cached) return applyIgnore(cached)
}
@ -258,13 +344,14 @@ export const UpdateSDK = {
const raw = await apiRequest<RawAppUpdateInfo>('/api/v1/updates/app/check', {
skipAuth: true,
params,
signal: activeSessionAbort.signal,
})
const normalized: AppUpdateInfo = {
...raw,
downloadUrl: normalizeDownloadUrl(raw.downloadUrl),
sha256: raw.sha256 ?? raw.apkHash ?? undefined,
}
await writeUpdateCache(UPDATE_APP_CACHE_KEY, normalized)
await writeUpdateCache(sessionCacheKey(UPDATE_APP_CACHE_KEY), normalized)
return applyIgnore(normalized)
},
@ -292,7 +379,10 @@ export const UpdateSDK = {
options: { signal?: AbortSignal; onProgress?: (progress: UpdateDownloadProgress) => void } = {},
): Promise<ArrayBuffer> {
if (!updateInfo.downloadUrl) throw new Error('[UpdateSDK] App update has no downloadUrl')
const bytes = await downloadBytes(updateInfo.downloadUrl, options)
const bytes = await downloadBytes(updateInfo.downloadUrl, {
...options,
signal: currentSessionSignal(options.signal),
})
if (updateInfo.sha256) assertSha256(bytes, updateInfo.sha256, 'APK')
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
},
@ -314,7 +404,7 @@ export const UpdateSDK = {
versionCode: updateInfo.versionCode,
sha256: updateInfo.sha256,
},
options,
{ ...options, signal: currentSessionSignal(options.signal) },
)
},
@ -324,6 +414,7 @@ export const UpdateSDK = {
options: { signal?: AbortSignal; timeoutMs?: number } = {},
): Promise<ReleaseSetPlan | null> {
await awaitInitialization()
if (isLoginRequiredWithoutSession()) return null
const target = requireRegistration(targetModuleId)
if (target.type === 'common') {
throw new Error(
@ -344,7 +435,7 @@ export const UpdateSDK = {
const raw = await apiRequest<RawPluginReleaseSet>('/api/v1/rn/release-set/check', {
method: 'POST',
skipAuth: true,
signal: options.signal,
signal: currentSessionSignal(options.signal),
timeoutMs: options.timeoutMs,
body: {
appKey: config.appKey,
@ -370,7 +461,7 @@ export const UpdateSDK = {
minNativeApiLevel: candidate.minNativeApiLevel ?? registration.minNativeApiLevel,
})
})
return planReleaseSet({
const plan = planReleaseSet({
installed,
candidates,
nativeApiLevel: await NativeBundle.getNativeApiLevel(),
@ -378,6 +469,10 @@ export const UpdateSDK = {
appVersion: getAppVersionName() ?? '0.0.0',
releaseId: raw.releaseId,
})
if (plan && (await readQuarantinedReleases())[targetModuleId] === plan.releaseId) {
return null
}
return plan
},
/** Installs a previously checked plan without performing another network version check. */
@ -388,6 +483,7 @@ export const UpdateSDK = {
onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void
} = {},
): Promise<ReleaseSetPlan | null> {
const authorizedGeneration = sessionGeneration
const registrations = UpdateSDK.getRegisteredPlugins()
const installed = await Promise.all(
registrations.map(registration => installedModule(registration)),
@ -410,9 +506,12 @@ export const UpdateSDK = {
const nativeModules: NativeReleaseModule[] = []
for (const module of verifiedPlan.modules) {
const archive = await downloadBytes(module.downloadUrl, {
signal: options.signal,
signal: currentSessionSignal(options.signal),
onProgress: progress => options.onProgress?.(module.moduleId, progress),
})
if (authorizedGeneration !== sessionGeneration) {
throw new Error('[UpdateSDK] User session changed while downloading a release.')
}
assertSha256(archive, module.sha256, `${module.moduleId} plugin package`)
nativeModules.push({
moduleId: module.moduleId,
@ -481,7 +580,10 @@ export const UpdateSDK = {
async reportPluginLaunchFailure(moduleId: string, reason?: string): Promise<void> {
const pending = await NativeBundle.getPendingRelease()
if (pending) await NativeBundle.rollbackReleaseSet(pending.releaseId)
if (pending) {
await quarantineRelease(pending.releaseId, pending.modules)
await NativeBundle.rollbackReleaseSet(pending.releaseId)
}
confirmedReleaseModules.clear()
if (reason) console.warn(`[UpdateSDK] Rolled back release after ${moduleId} failed: ${reason}`)
},
@ -491,10 +593,53 @@ export const UpdateSDK = {
return NativeBundle.preparePendingRelease()
},
/**
* Update SDK APK
* 宿BugCollect SDK
*/
async resetToEmbedded(): Promise<void> {
activeSessionAbort.abort()
activeSessionAbort = new AbortController()
confirmedReleaseModules.clear()
await NativeBundle.resetToEmbedded()
await UpdateSDK._reconcileNativeReset()
},
/** @internal common/app 初始化后补齐最小原生恢复页留下的 JS 状态清理。 */
async _reconcileNativeReset(): Promise<void> {
const [generation, confirmed] = await Promise.all([
NativeBundle.getResetGeneration(),
NativeBundle.getConfirmedResetGeneration(),
])
if (generation <= confirmed) return
await clearUpdateStorage()
await NativeBundle.confirmResetGeneration(generation)
},
getAppVersionCode,
getAppVersionName,
_devSetAppVersion(versionCode: number, versionName?: string): void {
_devSetAppVersion(versionCode, versionName)
},
/** 订阅登录后自动补检结果;SDK 只返回数据,不展示更新 UI。 */
onAutomaticCheck(listener: (result: StartupUpdateResult) => void): () => void {
automaticResultListeners.add(listener)
return () => automaticResultListeners.delete(listener)
},
/** @internal 共享登录态变化时取消旧灰度授权,并为新用户自动补检一次。 */
async _handleSessionChange(loggedIn: boolean): Promise<void> {
await clearSessionUpdateState()
if (!loggedIn || !getConfig().updateRequiresLogin || getConfig().debug) return
if (automaticLoginCheckGeneration === sessionGeneration) return
automaticLoginCheckGeneration = sessionGeneration
try {
const result = await UpdateSDK.checkStartupUpdate()
for (const listener of automaticResultListeners) listener(result)
} catch {
// 后台补检失败必须与宿主登录隔离。
}
},
}

查看文件

@ -0,0 +1,27 @@
export interface BundleManifestEntry {
moduleVersion?: string
sha256?: string
bundleFile?: string
}
function stackMatchesModule(stacktrace: string, moduleId: string, bundleFile?: string): boolean {
const normalized = stacktrace.replace(/\\/g, '/')
return (
normalized.includes(`/${moduleId}/active.bundle`) ||
normalized.includes(`/${moduleId}/embedded.bundle`) ||
normalized.includes(`/${moduleId}.android.bundle`) ||
normalized.includes(`/${moduleId}.ios.bundle`) ||
Boolean(bundleFile && normalized.includes(`/${bundleFile}`))
)
}
/** 只有 stack 唯一命中一个模块时才允许精确符号化。 */
export function selectStackModuleId(
stacktrace: string,
modules: Record<string, BundleManifestEntry>,
): string | null {
const matches = Object.entries(modules)
.filter(([moduleId, module]) => stackMatchesModule(stacktrace, moduleId, module.bundleFile))
.map(([moduleId]) => moduleId)
return matches.length === 1 ? matches[0] : null
}

查看文件

@ -1,4 +1,14 @@
import '@xuqm/rn-common/internal'
import {
_registerBundleIdentityProvider,
_registerInitializationHandler,
_registerUserInfoHandler,
} from '@xuqm/rn-common/internal'
import { UpdateSDK } from './UpdateSDK'
import { resolveCurrentBundleIdentity } from './BundleIdentity'
_registerUserInfoHandler('rn-update', info => UpdateSDK._handleSessionChange(Boolean(info)))
_registerInitializationHandler('rn-update-reset', () => UpdateSDK._reconcileNativeReset())
_registerBundleIdentityProvider('rn-update', resolveCurrentBundleIdentity)
export { StaleReleaseSetError, UpdateSDK } from './UpdateSDK'
export type {

查看文件

@ -0,0 +1,7 @@
/** 缺省策略由 common 收敛为 requiresLogin=true;这里只判断当前会话是否可检查。 */
export function shouldSkipUpdateForLogin(
requiresLogin: boolean,
userId: string | null | undefined,
): boolean {
return requiresLogin && !userId?.trim()
}

查看文件

@ -0,0 +1,29 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { selectStackModuleId } from '../src/bundleIdentityPolicy'
const modules = {
common: { bundleFile: 'common.android.bundle' },
app: { bundleFile: 'app.android.bundle' },
orders: { bundleFile: 'orders.android.bundle' },
}
test('stack identity resolves only one exact active or embedded bundle', () => {
assert.equal(
selectStackModuleId(
'at render (/data/user/0/host/files/rn-bundles/orders/active.bundle:10:20)',
modules,
),
'orders',
)
assert.equal(
selectStackModuleId('at boot (/assets/rn-bundles/app.android.bundle:1:2)', modules),
'app',
)
})
test('stack identity stays unavailable for unknown or ambiguous sources', () => {
assert.equal(selectStackModuleId('at render (index.js:10:20)', modules), null)
assert.equal(selectStackModuleId('app.android.bundle -> orders.android.bundle', modules), null)
})

查看文件

@ -0,0 +1,14 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { shouldSkipUpdateForLogin } from '../src/updateLoginPolicy'
test('login-required update checks skip anonymous sessions and resume after login', () => {
assert.equal(shouldSkipUpdateForLogin(true, null), true)
assert.equal(shouldSkipUpdateForLogin(true, ' '), true)
assert.equal(shouldSkipUpdateForLogin(true, 'user-1'), false)
})
test('platform can explicitly allow anonymous update checks', () => {
assert.equal(shouldSkipUpdateForLogin(false, null), false)
})

查看文件

@ -1,7 +1,8 @@
export { XuqmSDK } from './sdk'
export type {
XuqmConfig,
XuqmInitOptions,
XuqmInitializationSnapshot,
XuqmInitializationState,
XuqmLoginOptions,
XuqmUserInfo,
DeviceInfo,
@ -24,11 +25,7 @@ export type {
UpdateDownloadProgress,
} from '@xuqm/rn-update'
export { BugCollect } from '@xuqm/rn-bugcollect'
export {
XWebViewHost,
XWebViewView,
openWebView,
} from '@xuqm/rn-xwebview'
export { XWebViewHost, XWebViewView, openWebView } from '@xuqm/rn-xwebview'
export type {
XWebViewBridge,
XWebViewBridgePageContext,

查看文件

@ -1,11 +0,0 @@
declare module '@react-native-async-storage/async-storage' {
export interface AsyncStorageStatic {
getItem(key: string): Promise<string | null>
setItem(key: string, value: string): Promise<void>
removeItem(key: string): Promise<void>
clear(): Promise<void>
}
const AsyncStorage: AsyncStorageStatic
export default AsyncStorage
}

查看文件

@ -9,6 +9,7 @@
"@xuqm/rn-common/download": ["./packages/common/src/download.ts"],
"@xuqm/rn-common/file-name": ["./packages/common/src/fileName.ts"],
"@xuqm/rn-common/internal": ["./packages/common/src/internal.ts"],
"@xuqm/rn-common/secure-cache": ["./packages/common/src/secureCache.ts"],
"@xuqm/rn-common/version": ["./packages/common/src/version.ts"],
"@xuqm/rn-im": ["./packages/im/src"],
"@xuqm/rn-bugcollect": ["./packages/bugcollect/src"],