比较提交

...

6 次代码提交

作者 SHA1 备注 提交日期
XuqmGroup
daa96302dd feat(update): secure signed plugin release sets 2026-07-27 00:39:26 +08:00
XuqmGroup
3d2134e08a feat(sdk): harden automatic config and plugin releases 2026-07-26 23:47:30 +08:00
XuqmGroup
10977341d1 feat(xwebview): 收敛 WebView 宿主与桥接 2026-07-26 19:41:03 +08:00
XuqmGroup
d009c3b616 fix(common): ValidationError 改为继承 Error 修复 Hermes 校验崩溃
- Babel 转译子类 apply 调用原生 ZodError 在 Hermes 抛 TypeError,掩盖真实校验失败
- unexpected 分支补安全诊断日志(构造名+截断消息)
- xuqm-bundles.gradle 将 file: 依赖的 src/metro 纳入任务输入
2026-07-21 01:10:41 +08:00
XuqmGroup
507ce3d2ee feat(update): release-set 事务制品与原生层职责拆分
- 插件包改为 .xuqm.zip 事务制品,manifest/bundle/资源/SHA-256 统一校验
- 原生层拆分为桥接编排、插件包暂存、底层存储三个单一职责类
- 状态查询改为纯读取,不再误触发安装;新增 NATIVE_BASELINE_INCOMPATIBLE 拒绝
- SemVer 解析拆分为独立原生类并补原生测试
- XWebView 收口唯一内核,错误态统一中文层与重试
- common 增加请求头/运行时契约收口与 http 测试
2026-07-20 19:31:43 +08:00
XuqmGroup
d677a9ee52 feat(update): centralize Metro module builds 2026-07-18 08:59:40 +08:00
共有 141 个文件被更改,包括 7897 次插入2843 次删除

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` 只用于仓库内聚合开发,不对外发布。应用应按需安装具体包,避免引入未使用的原生能力。
@ -43,37 +43,34 @@ pnpm add @xuqm/rn-update @xuqm/rn-xwebview @xuqm/rn-bugcollect
## 扩展包一次初始化、一次登录
把平台生成的 `config.xuqmconfig` 放在 `src/assets/config/`,然后只包装一次 Metro 配置:
把平台生成的 `config.xuqmconfig` 放在 `src/assets/config/`,然后只包装一次 Metro 配置。使用 update 插件化时采用 update 组合器(它已包含 common 自动初始化)
```js
const {getDefaultConfig} = require('@react-native/metro-config');
const {withXuqmConfig} = require('@xuqm/rn-common/metro');
const { getDefaultConfig } = require('@react-native/metro-config')
const { withXuqmModuleConfig } = require('@xuqm/rn-update/metro')
module.exports = withXuqmConfig(getDefaultConfig(__dirname));
module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname))
```
`withXuqmConfig()` 会把初始化模块注册为 Metro pre-main module,避免 `inlineRequires` 延迟扩展包加载。应用登录成功后只更新一次公共会话:
不使用 update 时仍调用 `@xuqm/rn-common/metro``withXuqmConfig()`。两种组合器都会把初始化模块注册为 Metro pre-main module,避免 `inlineRequires` 延迟扩展包加载。应用登录成功后只更新一次公共会话:
```ts
import {XuqmSDK} from '@xuqm/rn-common';
import { XuqmSDK } from '@xuqm/rn-common'
await XuqmSDK.awaitInitialization();
await XuqmSDK.awaitInitialization()
await XuqmSDK.login({
userId: 'user-id',
accessToken: 'http-access-token',
userSig: 'optional-im-credential',
});
})
await XuqmSDK.logout();
await XuqmSDK.logout()
```
扩展包通过 `@xuqm/rn-common/internal` 接收同一次会话变化。宿主业务代码不得导入该内部子路径,也不得逐级传递公共 token 或用户状态。
无法使用配置文件自动初始化时,才调用一次:
```ts
await XuqmSDK.initialize({appKey: 'app-key'});
```
平台签发配置与 Metro 自动初始化是扩展 SDK 的唯一初始化入口;不提供手动
`initialize`。common-only 宿主不放配置,也不初始化。
## 开发与验证
@ -84,11 +81,7 @@ corepack pnpm install
corepack pnpm validate
```
`validate` 会执行全部包的 TypeScript 校验和测试。开发版本发布到 Nexus hosted 仓库:
```bash
corepack pnpm --filter @xuqm/rn-common publish --tag next --no-git-checks
```
`validate` 会执行全部包的 TypeScript 校验和测试。开发 alpha 及正式包都只能由 Jenkins 发布到 Nexus,开发机不执行 npm publish。
集成 update 的宿主由 `xuqm-rn start``xuqm-rn run android` 统一代理 Metro 与 Android Debug 启动;宿主只保留简短的 `start/android` package script。

查看文件

@ -1,9 +1,118 @@
# RN SDK 重构实施接管文档
> 状态更新时间2026-07-18
> 状态更新时间2026-07-27
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
> 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。
## 2026-07-26 / 当前实施状态
- common 已增加 `idle / initializing / ready / degraded / failed` 初始化状态、
`XuqmError` 结构化错误、七天最后成功配置缓存,以及最多三次的指数退避抖动。
平台暂不可用时优先进入 degraded;没有缓存时扩展返回 `XUQM_NOT_READY`,不向宿主
制造未处理异常。最后成功配置使用 AES-GCM 完整性加密,密钥由签发配置和
app/package/environment 上下文派生且不写入缓存;这里是应用沙箱内防明文与防篡改
边界,不宣称具备 Android Keystore/iOS Keychain 的硬件保密性。
- 扩展 SDK 的唯一正式初始化入口是平台签发 `.xuqmconfig` 与 Metro 自动初始化;
`XuqmSDK.initialize(options)` 及公开 `XuqmInitOptions` 已删除,不保留兼容壳。
common-only 不初始化。未使用 `withXuqmConfig` 时,公开
`@xuqm/rn-common/auto-init-config` 空实现保证 Metro 能完成静态解析;真实 Metro
fixture 已覆盖无配置构建。
- 唯一隐私入口为 `XuqmSDK.setPrivacyConsent(boolean)`。BugCollect 只有在平台启用、
用户已同意且非 Debug 时自动注册采集;Debug 单次验证使用
`BugCollect.sendTestErrorAndFlush()`,宿主不再手动 `startCapture()`
- BugCollect 收到 `BUGCOLLECT_DISABLED` 会立即停用并清除队列;上传请求不进入自身
HTTP 拦截链。部署中的 `{code:403,status:'1',message:'BUGCOLLECT_DISABLED'}` 同样
识别。持久化事件最多 500 条、保留七天,复用 common AES-GCM 沙箱加密,失败不会
先删除队列。冷启动首次获得“平台关闭”配置时,即使尚未创建内存 LogQueue,也会
直接删除该 appKey 的遗留加密队列;Debug 单次测试上传同样要求隐私同意。
- update 从原生 manifest/state 提供当前 Bundle 精确身份;BugCollect 仅在 stack 唯一
命中模块时附带 `buildId/moduleId/moduleVersion/bundleHash`。无法唯一归属时标记
`symbolicationStatus=unavailable`,不伪造 latest 身份。
- Update 使用平台动态 `updateRequiresLogin`。需要登录时未登录正常跳过;登录后自动
补检一次并通过 `onAutomaticCheck` 交给宿主决定 UI。账号变化会取消旧下载授权和
灰度缓存;后台补检失败不影响宿主登录。服务端权威字段
`allowAnonymousUpdateCheck` 缺失时安全默认禁止匿名检查。
- Update 服务开关统一解析 `features.update`(兼容平铺 `updateEnabled`),缺失安全
默认关闭并进入最后成功配置缓存。所有显式检查/下载/安装入口在网络和原生调用前
抛出 `UPDATE_DISABLED`;自动启动路径正常跳过。
- 插件候选已实现 Ed25519 不可变 manifest 验签common 集中维护 canonical JSON、
可信 keyId 与 Hermes 运行时验签,Metro 构建期复用同一规则和密钥集合。候选在计划
前与安装前双重验签,Android stager 进一步绑定 buildId、bundleFormat 和
bundleSha256;响应 ZIP `sha256``archiveSha256` 纳入同一签名,下载后仍使用
同值校验归档。未知 keyId、签名错误和字段篡改测试均已覆盖。
common 加密、Ed25519 与 secureCache 复用 common/security 唯一纯 JS
Base64/Base64URL codec,不依赖 Hermes 全局 `atob/btoa`;非法字母表、长度、填充和
尾随位直接拒绝。canonical
JSON 对函数、symbol、非有限数、非普通对象、数组空位/undefined 确定性失败,避免
Node、Hermes 与服务端实现各自容错产生不同签名正文。
Server 的 `update-service/src/test/resources/rn-release-signature-vector.json`
唯一规范源。RN 测试只读明确标记 GENERATED/DO NOT EDIT 的仓内生成副本,保证独立
Git/Jenkins checkout 可运行;`scripts/sync-server-signature-vector.mjs
--check|--write` 负责跨仓字节级同步。相邻 Server 缺失时常规单仓 validate 跳过跨仓
比较,发布前必须提供 Server checkout 并执行 `--check`。测试会逐字节比较 canonical
UTF-8 正文,并以同一 keyId、原始公钥和 Base64URL 签名完成验签。
- release-set 检查已强制携带 appVersion、nativeApiLevel、nativeBaselineId;缺少
baseline 在发请求前失败。发布 Token 只读取 Jenkins 注入的 XUQM_API_TOKEN,
`release.apiToken` 配置会被构建校验拒绝。
- `xuqm.modules.json` 使用 `packageName` 保存 Android applicationId、使用
`iosBundleId` 保存 iOS Bundle Identifier;相应字段仅在目标平台
build/package/release 时必填。唯一 `resolveHostPackageId(config, platform)`
构建、在线配置验证、ZIP 期望身份和发布上传共同复用。wire 仍使用
`packageName`,但不会再把 Android 身份写入 iOS ZIP。
- 插件 ZIP v1 协议限制已与 Server 对齐:归档 50 MiB、单 entry 100 MiB、总解压
100 MiB、最多 4096 entries。Android stager 不再保留 5000 上限,4096/4097 和单
entry 100 MiB 边界已有原生单测;CLI `zipSync` 不生成目录 entry,Server 与 stager
均明确拒绝目录 entry。
- Debug 的 `XuqmRuntime.start()` 不做远程整包或插件检查;开发页仍可显式调用公开
check/install API 做一次性验证。
- 插件构建清单已从 `xuqm.config.json` 更名为 `xuqm.modules.json`;租户平台签发的
`.xuqmconfig` 仍是唯一初始化源。Metro 使用 `src/assets/config/` 内唯一文件,
保留平台下载文件名;零个保持 common-only,多个构建失败。Android/iOS 生成目录
中的构建副本才固定命名为 `config.xuqmconfig`
- Metro 只接受严格 `XUQM-CONFIG-V2`:使用内置 `keyId` 公钥先做 Ed25519 验签,再
解密并校验 canonical JSON、Schema、UUID、revision 和有效期。V1、未知 keyId 与
签名失败在构建期阻断。当前内置开发公钥为 `xuqm-config-dev-2026-07`,私钥未入仓。
- Release 每个模块始终生成独立 Source Map,删除 `sourcesContent` 并清理绝对路径。
`--bugcollect=disabled` 只归档不上传;启用时按完整 Bundle 身份上传,失败阻断发布。
上传契约固定为 `POST /bugcollect/v1/artifacts/upload`
`artifactType=RN_SOURCEMAP`;旧 Metro serializer 上传器已删除。发布登记和
Source Map 上传的 `buildId/moduleId/moduleVersion/bundleHash` 均从待上传 ZIP 的
`rn-manifest.json` 和真实 Bundle 校验后取得,禁止根据环境变量二次推导身份;
artifact 上传与插件发布使用同一 Jenkins 发布 Bearer,匿名请求不会发出。
- 整包和插件发布前调用 `/api/sdk/build/config/validate`。服务不可达、配置过期、
revision 撤销或包名不匹配均阻断新 Release;已安装 App 运行时不查询吊销状态。
- 相同 versionName 的连续 Android Release 自动生成递增 versionCode 和独立 buildId;
- 2026-07-27 本地全量 `pnpm validate` 通过:六个 workspace TypeScript、common 42、
bugcollect 11、xwebview 7、IM 3、update CLI/Metro 28 与 update domain 18 项测试
全部通过;update 发布包内容校验为 51 个文件。common npm dry-run 未包含测试目录及
生成签名向量。Server 相邻仓库存在时签名向量逐字节检查通过,模拟 Server checkout
缺失的单仓 CI 也按契约独立通过。
同一工作区通过 `.xuqm-cache/release-build.json` 保证单调递增,跨工作区 CI 通过
`XUQM_VERSION_CODE``XUQM_BUILD_ID` 注入全局唯一流水线身份。
- Gradle 内嵌任务把 `buildId``bugCollectMode` 声明为显式 `@Input` 并传入 embed
子进程;测试验证 APK 内嵌 manifest 和模块 ZIP 使用同一 Release identity。
`package``publish` 共用唯一 `--bugcollect` 参数解析器,等号和空格写法一致。
- Android 相同模块版本使用 SHA-256 识别新版 APK 内容;真正更高且兼容 native
baseline 的远程插件继续保留。新 release 允许两次进程启动握手,失败 releaseId
本机隔离,服务端内容未变化前不重复安装。
- 原生最终恢复入口只删除 SDK 私有 `files/rn-bundles`,并递增 reset generation;
common/app 初始化后仅清除 `xuqm_update_*` 状态并确认该 generation,重试不会误清
宿主、BugCollect 或签名 SDK 数据。
- 已执行 `pnpm validate`全部包类型检查、common 42 项、bugcollect 11 项、update
CLI 28 项、update 领域 18 项、xwebview 7 项和 IM 3 项测试均通过;update 包内容
校验通过,发布包为 51 个文件;Prettier 和 `git diff --check` 通过。
独立 `native-test` 的系统 Gradle 仍在任务配置前因本机
`libnative-platform.dylib` 无法加载;改用 App4 Gradle 9.3.1 wrapper 后,
`:xuqm_rn-update:testDebugUnitTest --tests
com.xuqm.update.XuqmPluginPackageStagerTest` 共 25 tasks 构建并测试成功。
尚未执行完整宿主 Android 构建、设备运行、开发环境接口和真实 Source Map 上传;
这些结果不得从当前门禁推断。
- License 离线激活 SDK 已审计RNSDK 不存在对应包、API、依赖、脚本、文档或空目录;
npm 的 `license: UNLICENSED` 元数据和 RN 插件事务 `activation` 不属于离线授权。
- 配置中的 `signingKey` 明确是客户端可提取的请求完整性/缓存派生材料,不是 License、
用户授权或服务端秘密;敏感 API 仍依赖登录 `accessToken`,构建制品上传依赖独立
Jenkins Bearer,并由服务端执行授权。
## 1. 本轮目标
1. `rn-common` 可以完全独立使用公共工具,不要求初始化或登录。
@ -13,6 +122,28 @@
5. update SDK 统一承担插件 manifest、兼容性、原子激活、启动确认、崩溃回滚和内嵌恢复;宿主不得再实现平行版本管理器。
6. Android 整包更新必须具备应用内下载、SHA-256 校验、安装权限处理、进度、取消、重试和确定性错误码。
7. 宿主通过最少配置完成插件创建、开发运行、打包、随 APK 内嵌、发布和回滚。
8. XWebView 收敛为 `XWebViewHost + openWebView` 的 SDK 自管页面栈;宿主不再注册路由或传 navigate 回调。
## 0. 2026-07-26 / XWebView 1.0 实施状态
- 已取得用户实施授权,目标为 `@xuqm/rn-xwebview@1.0.0`
- 旧 `openXWebView(navigate, config)`、common 内全局 `_config/_controller` 和公开
`XWebViewScreen` 已删除,不保留转发兼容层;迁移规则已写入包 README。
- 新公开入口固定为根部一次挂载 `XWebViewHost`、业务直接调用
`openWebView(config)`,并返回页面级句柄和 `closed` Promise。
- 配置改为 `source/navigationBar/statusBar/behavior/permissions/downloads/bridge`
分组强类型结构;URL 与 HTML 互斥。
- 页面栈、Host 等待/卸载、页面级动态导航、系统返回、外链、下载、权限和 Bridge
异常已收口到 xwebview;common 中错误归属的 WebView 状态与业务 Bridge 已删除。
- H5 媒体权限由页面白名单、可选宿主决策和 Android 系统权限三层控制;SDK 不向
Manifest 注入摄像头或麦克风权限。
- 下载自动复用 URI 首屏鉴权 headers;Bridge 原始 payload 不写日志。
- `react-native-svg` 已收敛为 peer;宿主必须直接声明这一原生依赖以参与 autolink,
禁止由 xwebview 私自安装第二份。App4 运行验收曾据此捕获并修复
`RNSVGSvgViewAndroid` 未注册问题。
- xwebview 类型检查和 7 项独立测试已通过。App4 在 Android 36.1
`emulator-5554` 通过“移动报销”真实 H5 页面验证页面打开、SDK 初始化、
`userInfo`、右上角菜单和关闭返回均正常,窗口日志无 Java/React/Fabric FATAL。
## 2. 已冻结的设计约束
@ -39,10 +170,10 @@
- 本地已使用 JDK 21 + Gradle 9.3.1 编译通过;RN Bridge 明确输出 Java 17 字节码,不再隐式回落到 Java 8。
- RN 包不再内置第二份 AGP buildscript;宿主负责插件版本,独立夹具使用与 Android SDK 一致的 AGP 9.1.0。
- `pnpm validate`:通过。
- common16 个测试通过。
- bugcollect4 个独立测试通过。
- xwebview4 个独立测试通过。
- update CLI9 个测试通过;release-set6 个测试通过。
- common20 个测试通过。
- bugcollect5 个独立测试通过。
- xwebview7 个独立测试通过。
- update CLI/Metro11 个测试通过;release-set6 个测试通过。
- IM 现有测试3 个通过;IM 不在本轮开发范围。
- 所有 workspace TypeScript typecheck通过。
@ -53,15 +184,17 @@
- common-only 宿主缺少配置文件时不会触发自动初始化,已有测试覆盖。
- 配置文件自动初始化遇到临时网络失败时会清理失败 Promise;扩展下一次等待初始化时使用同一加密配置重试,不要求宿主重新传 appKey/URL,也不会产生未处理 Promise 或重复错误日志。
- bugcollect、update、xwebview 通过 `@xuqm/rn-common/internal` 触发一次自动初始化入口。
- update CLI 已能从 schema v3 `xuqm.config.json` 构建 startup/common/app/buz,生成携带 SemVer 兼容范围和原生 API 等级的内嵌 manifest。
- Android bundle 引擎不在 `xuqm.config.json` 重复声明CLI 直接读取宿主 `android/gradle.properties``hermesEnabled`。启用时所有插件统一编译为 Hermes bytecode、组合 source map,并在 manifest/上传表单记录 `bundleFormat`
- update CLI 已能从 schema v3 `xuqm.modules.json` 构建 startup/common/app/buz,生成携带 SemVer 兼容范围和原生 API 等级的内嵌 manifest。
- update 的 `withXuqmModuleConfig()` 是多 Bundle 模块编号的唯一实现:日常 Metro 不变,CLI 构建时自动先生成 startup/common 共享模块表,并按配置序号为每个 app/buz 分配独立区间;宿主不再维护多份 Metro 配置或业务名硬编码 offset。
- Android bundle 引擎不在 `xuqm.modules.json` 重复声明CLI 直接读取宿主 `android/gradle.properties``hermesEnabled`。启用时所有插件统一编译为 Hermes bytecode、组合 source map,并在 manifest/上传表单记录 `bundleFormat`
- common 的唯一 XWebView 契约新增实时导航状态;全屏与内嵌容器统一提供 `canGoBack`、`canGoForward`、当前标题和 URL,并支持宿主配置回调。Bridge 不再重复声明第二份 controller 类型。
- RN update 已有 release-set 纯领域规划器;Android 原生模块已改为整组 staging/激活/确认/回滚,并从 APK assets 恢复内嵌基线。
### 3.3 已确认缺口
- RN update 已编码 `XuqmAppUpdateModule`,只桥接 XuqmGroup Android `sdk-update:2.0.0-SNAPSHOT`;Android Snapshot、Bridge 编译和 App4 完整 debug 构建已通过,模拟器安装事务仍待验证。
- release-set 已统一使用 SHA-256,但签名 manifest 尚待实现。
- release-set 已统一使用 SHA-256 和 Ed25519 签名 manifest;服务端仍需按同一
canonical 字段契约签发 `keyId/signature` 并完成联调。
- 原生 state 已成为 Bundle 版本与事务状态唯一真相;App 更新提示缓存仍可使用 AsyncStorage,但不保存 Bundle 版本。
- 客户端已改为 `/api/v1/rn/release-set/check` 单次请求目标入口依赖闭包;租户平台服务端接口尚待同步实现。
- `nativeBaselineId` 已自动计算并进入内嵌 manifest/插件上传:覆盖 Android 原生源码、Gradle 配置、RN 与含原生代码的 npm 依赖,普通业务 JS 不影响指纹;Jenkins/服务端登记校验尚待实现。
@ -72,17 +205,17 @@
## 4. 实施进度
| 工作项 | 状态 | 说明 |
| ----------------------------- | -------- | ------------------------------------------------------ |
| 基线审计与门禁 | 完成 | 2026-07-17 本地 validate 全通过 |
| 实时接管文档 | 进行中 | 本文件为唯一实施状态入口 |
| common 公共上下文收敛 | 完成 | 公共基础与共享生命周期 16 测试 |
| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本与策略已统一,4 测试 |
| xwebview 文件/权限能力与测试 | 完成 | 文件下沉 common,H5 摄像头/麦克风统一授权,4 测试通过 |
| update release set 与原生事务 | 进行中 | Android Snapshot、Bridge、App4 构建通过;待事务 E2E |
| package 内容校验 | 待实施 | 四包完成后执行 |
| Jenkins alpha 发布 | 完成 | `#59/#60` 已发布 common、xwebview、update |
| App4 接入 | 完成构建 | 精确 alpha 已接入,六 Hermes 插件与 debug APK 构建通过 |
| 工作项 | 状态 | 说明 |
| ----------------------------- | ------ | ----------------------------------------------------- |
| 基线审计与门禁 | 完成 | 2026-07-17 本地 validate 全通过 |
| 实时接管文档 | 进行中 | 本文件为唯一实施状态入口 |
| common 公共上下文收敛 | 完成 | 公共基础、共享生命周期与安全 API 诊断 19 测试 |
| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本、策略与 HTTP 脱敏已统一,5 测试 |
| xwebview 1.0 Host/权限/下载 | 完成 | 页面栈、分组配置、7 项测试及 App4 Android 运行通过 |
| update release set 与原生事务 | 进行中 | Android Snapshot、Bridge、App4 构建通过;待事务 E2E |
| package 内容校验 | 完成 | update 包 42 个发布文件已校验 |
| Jenkins alpha 发布 | 完成 | `#59/#60` 已发布 common、xwebview、update |
| App4 接入 | 完成 | V5 Host、8 模块、Debug APK 与真实 H5 Android 链路通过 |
## 5. 下一步操作
@ -90,7 +223,7 @@
2. 在 Android 宿主补充 release-set 安装、冷启动确认和崩溃回滚仪器测试。
3. 收敛 Jenkins 四包依赖发布顺序和 package 内容检查。
4. 后续租户平台实现 `/api/v1/rn/release-set/check`,服务端只返回目标入口与 common 的兼容闭包。
5. App4 保持使用 Jenkins 精确版本,禁止链接 SDK 源码、手改 `node_modules` 或本地发布正式制品。
5. App4 当前使用相邻源码联调本轮未发布变更;验证完成后必须由 Jenkins 发布新 alpha,再恢复 Nexus 精确版本。禁止手改 `node_modules` 或本地发布正式制品。
## 6. 常用验证命令
@ -113,6 +246,31 @@ pnpm --dir packages/update test
- 新增测试使用带手机号、sessionId、userId、token 的伪响应,强制验证
序列化后的诊断结果不包含这些敏感值。
### 2026-07-19 / common Bundle 模块过滤修复
- App4 模拟器首次加载内嵌插件时出现 `Requiring unknown module "726"`。定位结果不是宿主页面问题,而是 Metro 的模块编号工厂把本轮首次发现的 common 模块写入共享缓存后,过滤器又读取同一份可变缓存并把该模块从当前 Bundle 删除。
- `withXuqmModuleConfig()` 现在只使用构建开始前的共享模块快照做去重common 本轮新分配的模块保留在 common Bundle;后续 app/buz 仍过滤 startup/common 已有模块。宿主不增加补丁、模块白名单或第二套编号逻辑。
- Metro 测试新增“common 本轮新模块必须保留”断言;格式、全部 workspace 类型检查及 48 项测试通过。
- 根工作区脚本统一通过 Corepack 调用锁定的 pnpm,校验不再依赖开发机预装全局 pnpm。
- 当前下一步:刷新 App4 的 `file:` SDK 联调副本,重建并安装 debug APK;确认六个 Hermes Bundle 可连续加载后继续双模拟器像素校准。修复尚未通过 Jenkins 发布,不得把相邻源码依赖作为正式发版基线。
### 2026-07-19 / Android 插件图片资源恢复
- App4 common/app 连续加载成功后,登录 logo 和单选框仍为空。根因是 Metro 生成的 `drawable-*` 仅位于 `assets/rn-bundles`,同时原生恢复只复制 `active.bundle`;动态 `loadBundle(file)` 会相对当前 Bundle 文件目录解析图片。
- Gradle 生成任务现在公开 assets 与 res 两个独立 `DirectoryProperty`Bundle/manifest 进入 APK assets,drawable 同时进入 aapt2 资源表,符合 AGP 生成源 API 的类型约束。
- `XuqmBundleModule` 恢复内嵌 Bundle 时同步读取该模块 manifest 的 `assetFiles`,校验路径只允许标准 `drawable-*/resource_name.ext`,再原子写入同一模块目录;图片恢复不由宿主页面逐个处理。
- App4 arm64 debug 已真实编译该 Java 实现并安装验证;app 模块私有目录共恢复 289 个 Bundle/状态/资源文件,登录 logo 与单选框显示正常。
- 当前只取得内嵌基线恢复证据;远程 release set 的二进制 Bundle 与资源下载、校验、事务激活仍需作为同一组实现和验证,不能把本次结果扩大为 OTA 资源已完成。
- 全局 API 错误回调不再交付带 Axios cause 的 `RequestError`,只交付不可逆的安全诊断报告;宿主无法误把请求体、headers 或响应上传到采集平台。
- bugcollect 的全局 fetch 拦截器只上报 method、相对 path 和 HTTP status;完整 host、query、请求体、headers 及原始网络异常对象全部丢弃,测试覆盖敏感查询参数和鉴权头。
### 2026-07-18 / 多 Bundle Metro 能力下沉
- 新增公开入口 `@xuqm/rn-update/metro`,`withXuqmModuleConfig()` 内部复用 common 的 `withXuqmConfig()`,宿主只保留一份 Metro 配置。
- `xuqm-rn build/embed/publish` 为每个模块注入 id、type、配置序号和共享缓存位置;模块区间不再依赖 `szyx/miniapp` 等业务目录名称。
- 独立构建 app/buz 时 CLI 自动先构建 startup/common 依赖以重建共享模块表,但仍只发布用户选择的模块,适用于全新 Jenkins 工作区。
- 测试覆盖 0 号模块存在性、startup/common 去重、app/buz 唯一区间,以及单独构建 buz 的依赖顺序;update CLI/Metro 11 项、release-set 6 项和 42 文件包内容校验通过。
### 2026-07-18 / Jenkins #58—#60 与 App4 精确版本接入
- `sdk-rn-publish #58` 被 Windows Hermes 测试正确阻断:测试曾把文本写成 `hermesc.exe`,Windows `spawnSync` 无法执行;该构建未发布任何包。
@ -190,7 +348,7 @@ pnpm --dir packages/update test
- 更新检查改为按入口依赖闭包:启动时整包优先,其后 app+common;buz 进入前只检查当前 buz+common。
- SDK 不决定宿主 UI整包与插件均提供独立检查/安装 API;App4 进入 buz 使用 `checkAndInstallPlugin`,需要弹窗的宿主使用 `checkPluginRelease` 后再调用 `installPluginRelease`
- release-set 记录检查时的全部本地基线版本;弹窗停留期间状态变化会触发 `StaleReleaseSetError`,不会安装过期计划。
- `xuqm.config.json` 统一升级到 schema v3`commonVersionRange` + `minNativeApiLevel`,删除 `minCommonVersion`/`minNativeVersion` 双重语义。
- `xuqm.modules.json` 统一使用 schema v3`commonVersionRange` + `minNativeApiLevel`,删除 `minCommonVersion`/`minNativeVersion` 双重语义。
- 验证update typecheck、CLI 6 测试、release-set 6 测试通过;Android 原生代码尚待宿主工程编译验证。
### 2026-07-17 / Android 整包安装桥接
@ -205,3 +363,87 @@ pnpm --dir packages/update test
- Jenkins Windows 节点直接访问外部 Android 仓库会出现无输出的长时间依赖等待;`native-test` 的 plugin management 已与 AndroidSDK 对齐,统一优先使用 Nexus `android` 聚合仓库,再回退官方仓库。
- Windows CLI 测试路径统一使用 `fileURLToPath`,禁止把 `file:` URL 的 pathname 直接作为本机路径。
- native baseline 现在按 Android/iOS 分别计算,并覆盖全部运行时依赖的实际安装版本、平台原生源码/资源、`src`/`assets` 内会随安装包打入的图片、字体和媒体文件;这些内容变化后必须先发布完整 App,不能只发布 JS 插件。
### 2026-07-19 / 同版本内嵌基线覆盖修复
- App4 覆盖安装新 debug APK 后仍加载手机内旧 `active.bundle`。根因是 `ensureEmbeddedBundle()` 只判断文件存在,插件版本仍为 `1.0.0` 时无法识别整包内的代码已经变化。
- `xuqm-rn embed` 现在为每个模块记录实际 bundle 的 SHA-256;Android state 同步记录 `activeHash`。状态仍为 `embedded` 且摘要变化时,SDK 原子替换 bundle、清理该模块旧 drawable 目录并按 manifest 恢复新资源。
- 状态为 `active` 或事务中的远程插件不会被覆盖安装改写;线上更新仍只能经过 stage/activate/confirm/rollback。该分支避免以开发便利破坏生产事务边界。
- RN SDK 全量门禁通过。App4 重新编译 Java Bridge 与六模块共 450 tasks 成功;第一次不清 `rn-bundles` 覆盖安装把旧 state 自动写入新 manifest 摘要,第二次相同 APK 覆盖后 bundle mtime 未变化,证明变化替换和相同复用均生效。下一步为 Jenkins 发布新 alpha 后恢复 App4 精确 Nexus 版本。
### 2026-07-19 / 平台地址与更新检查超时收敛
- `XuqmConfig` 新增职责明确的 `platformUrl`;配置文件 `serverUrl/baseUrl` 只在解密入口归一为该字段。租户配置、整包和插件更新统一访问 `platformUrl`,远程 `apiUrl` 只表示业务扩展服务,禁止两者混用。
- common HTTP 增加唯一 30 秒默认超时,并将调用方 `AbortSignal` 与内部超时合并;定时器在成功、失败和取消后统一清理,不要求每个 SDK 复制 `Promise.race`
- `checkPluginRelease()` 支持 `signal/timeoutMs`;`checkAndInstallPlugin()` 支持 `signal/checkTimeoutMs`,宿主可为“进入插件前检查”设置更短交互边界,同时保留纯检查、确认后安装和自动检查安装三种既有 API 语义。
- App4 使用 5 秒检查边界:平台暂时不可达且本地 Bundle 健康时继续进入本地插件,网络异常不被误报为 Bundle 崩溃;真实 `szyx/active.bundle` 已在 Android 模拟器加载成功。
- common 新增停滞请求超时测试,并扩展生命周期/初始化测试覆盖 `platformUrl``apiUrl` 分离;全量 `pnpm validate` 再次通过:六个 workspace 类型检查、common 20、bugcollect 5、xwebview 4、IM 3、update CLI/Metro 11 与 release-set 6 项测试全部无回归。
### 2026-07-20 / 启动壳轻量原生分包入口
- `@xuqm/rn-update` 新增公开子路径 `@xuqm/rn-update/native-bundle`,只导出原生 Bundle 状态与路径桥,不加载 AsyncStorage、HTTP、SemVer、整包更新或 release-set 编排。
- App4 startup 已改用 `NativeBundle.preparePendingRelease()` / `getLaunchPath()`;完整 `UpdateSDK` 仍只在 app 更新和插件激活阶段加载,不存在第二套状态实现。
- App4 的 startup Android Hermes bytecode 从此前约 2.2 MB 降至约 1.27 MB。该数据只证明启动入口依赖收敛;common/app 动态装载和模拟器交互首屏仍需独立计时,不能只用 startup 文件大小宣称启动性能完成。
- `packages/update/package.json` 的 exports、README 与 App4 实际调用已同步。该变更仍是 main 本地源码联调状态,未通过 Jenkins 发布新 alpha,外部项目不得提前写入一个尚不存在的 Nexus 版本号。
### 2026-07-20 / common 构建期配置解析与 XWebView 权限收口
- App4 冷启动日志证明 common 请求与 app 请求之间曾固定阻塞约 10.6 秒;直接读取原生 Bundle 仅约 1ms。根因是 common 自动初始化在 Hermes 主线程执行 120,000 次纯 JavaScript PBKDF2,而不是 Bundle IO 或 App 页面渲染。
- `withXuqmConfig()` 现在使用 Node `crypto` 在 Metro 构建期完成 PBKDF2、AES-256-GCM 解密和 Schema 校验,虚拟模块只导出解析后的只读配置。设备端自动初始化不再执行解密;临时网络失败重试复用同一份解析结果,不恢复第二套初始化入口。
- App4 重新生成六模块并安装后,common 请求到 app 请求的间隔由约 10.6 秒降至约 405ms;一次 Android 冷启动 `Displayed` 为 783ms。该结果是 API 34 ARM64 软件模拟器观测值,只作为本轮相对回归证据,不替代真机性能验收。
- XWebView 默认权限策略只接受摄像头和麦克风两类已知 WebView 资源,并逐项核对 Android 运行时授权;未知资源明确拒绝。单测覆盖摄像头、麦克风、混合资源、拒绝和未知资源场景。
- common 18 项 TypeScript 测试、2 项 Metro 测试、类型检查,以及 xwebview 4 项测试和类型检查均通过。App4 当前使用相邻 `file:` 源码联调;完成设备 H5 权限闭环后仍需由 Jenkins 发布新 alpha,再恢复精确 Nexus 版本。
### 2026-07-20 / 多 Bundle 插件注册表单例收口
- App4 内嵌 `miniapp` 设备验证暴露 `[XuqmRuntime] Plugin did not call definePlugin()`。bundle 摘要与内嵌 manifest 完全一致,插件入口也确实调用 `definePlugin()`;根因是 app/buz 模块区间隔离后,各自复制的 `PluginRuntime` 创建了不同 `definitions` Map。
- update SDK 将插件定义 Map 拆到轻量 `PluginRegistry.ts`,并导出 `@xuqm/rn-update/plugin-registry` 子路径。宿主 common 入口必须副作用导入该子路径,使 app 与所有 buz 通过共享模块编号读写同一个注册表;不得在 App 内写兼容 Map 或改成全局变量。
- 新增注册、读取、删除和重复定义测试;RNSDK 完整 `pnpm validate` 已通过common 20、bugcollect 5、xwebview 4、IM 3、update CLI/Metro 11 与 update domain 8 项测试全部通过。
- App4 重建六模块并进入真实“医信课堂”后,注册表错误消失;设备随后进一步暴露并修复两类相同边界缺陷app/miniapp 重复执行 `BVLinearGradient` 原生 View 注册,以及 miniapp 反向导入 host 后复制 `UpdateContext`。宿主 common 现统一持有跨 Bundle 原生依赖;插件通过 common 更新命令端口调用 host 实现,不再导入宿主 Context,也未增加全局变量兼容层。
- 最终 `emulator-5556` 的 common/app/miniapp 均加载成功,日志无插件注册、重复 View、Context 或 Java/React FATAL;XWebView 容器已显示真实服务标题和 DNS 错误页。该结果只证明多 Bundle 与容器运行时通过,远端 H5 内容及摄像头/麦克风授权仍需网络正常的开发环境设备验收。
### 2026-07-20 / XWebView 浏览器内核与错误态唯一实现
- 设备 DNS 失败复测发现 Android 系统英文错误页会在 `onError` 后再次触发加载事件,旧实现因此清空自定义错误状态;同时错误分支卸载 WebView,按钮持有的 ref 已为空,重试实际无效。
- `XWebViewView` 现在始终保留 WebView,并以白底中文错误层覆盖系统页;刷新、前进、后退和重试统一先清理错误状态再操作同一控制器。错误页不再由原生事件先后顺序随机决定。
- 删除 `XWebViewScreen` 内 600 余行重复的 WebView、下载、Bridge、权限和错误处理,
并最终删除该公开入口。全屏页面由 `XWebViewHost` 组合统一导航栏、系统返回与
`XWebViewView`;全屏和内嵌场景共享同一个浏览器内核。
- 当时的 xwebview 类型检查与 4 项下载/权限测试、RNSDK 全量门禁和 App4 457 tasks
构建均通过;该历史结果不能替代 1.0.0 当前实现的最终验证。当前 1.0.0 已扩展为
7 项独立测试,Android 运行验证仍待执行。
### 2026-07-20 / Android 内嵌 Bundle 启动短路
- 审计确认旧 `ensureEmbeddedBundle()` 每次查询模块路径或状态都会先从 APK assets 读取整份 Bundle,再用摘要判断是否需要覆盖;虽然不会每次写盘,但六模块冷启动仍产生无效的大文件读取和摘要对象创建。
- Android 原生模块现将 APK `manifest.json` 按进程缓存,并先读取本地 `state.json`:下载版本 SemVer 大于等于内嵌版本、原生 baseline/API 等级兼容且记录长度正常时直接加载;APK 自有 embedded 状态则使用 manifest SHA-256 短路,同版本新开发构建仍能正确替换。
- 恢复 API 始终按传入的单个 moduleId 工作,不预装 manifest 中的全部模块。宿主冷启动只请求 common、app;buz 必须在用户进入相应入口后,通过目标插件的检查/安装与 `XuqmRuntime.activate()` 按需恢复和执行。
- 设备清数据复核进一步发现,启动期 app/common 更新检查会收集全部已登记模块版本,而旧 `getBundleState()` 内部错误调用 `ensureEmbeddedBundle()`,导致“查询状态”顺带把三个 buz 全部释放到私有目录。该副作用已删除:状态存在时只读 state,未访问模块从 manifest 构造只读内嵌状态;只有 `getLaunchBundlePath(moduleId)` 能触发恢复。
- manifest 新增 `byteLength`;远程 staging 同步记录 pending/active/rollback 长度。首次安装、本地落后、baseline 不匹配、长度异常或摘要变化时才读取内嵌 Bundle,且写盘前强制校验 manifest SHA-256,再原子恢复 Bundle 与 drawable 资源。
- release-set 规划器新增 `NATIVE_BASELINE_INCOMPATIBLE`,候选缺失或不匹配当前 `builtAgainstNativeBaselineId` 时拒绝安装,避免修改原生依赖后仍向旧完整包发布 JS 插件。
- RNSDK 全量格式、六 workspace TypeScript、common 20、bugcollect 5、xwebview 4、IM 3、update CLI/Metro 11 与 update domain 9 项测试通过;App4 宿主真实执行 `:xuqm_rn-update:compileDebugJavaWithJavac`,19 tasks 构建成功,完整六模块 Debug APK 457 tasks 构建成功。
- `emulator-5556` 覆盖安装后的第一次启动只为旧状态补写 `activeByteLength` 等小型元数据,未重写已有 common `active.bundle`;第二次 `force-stop` 冷启动前后,common/app 的 `active.bundle``state.json` mtime、长度均完全不变,日志确认两者直接从应用私有目录 `rn-bundles/*/active.bundle` 装载,common 到 app 请求间隔约 15ms,且无 Java/React FATAL。两次 `am start -W` TotalTime 分别为 1052ms、1085ms,只作为模拟器回归记录;系统首帧耗时不能单独归因于 Bundle 短路,也不替代真机性能验收。
- 修复状态查询副作用后重新构建 457 tasks APK,并在 `emulator-5556` 清除数据验证:首次启动私有目录只有 `rn-bundles/common`、`rn-bundles/app`,不再出现 szyx/miniapp/workbench;第二次冷启动仍只有这两个目录,四个 bundle/state 文件的 mtime 与长度完全一致。两次清数据/二次启动 TotalTime 分别为 1137ms、1028ms,只作为正确性和相对回归记录。
### 2026-07-20 / 本地原生依赖 baseline 与原生 SemVer 收口
- `XuqmBundleModule` 不再内嵌一套 80 余行 SemVer 算法。Android 启动门禁统一调用包内 `XuqmSemanticVersion`;原生单测覆盖 SemVer 官方预发布顺序、构建元数据、超大数字标识和非法旧状态,非法状态固定返回“不可信”并恢复 APK 基线。
- 完整包 baseline 原先只记录宿主 Android/iOS 源码和依赖版本;相邻 `file:` SDK 在版本号未变时修改 Java/Kotlin 会被漏掉。`computeNativeBaseline()` 现在额外哈希每个 `file:` 运行时依赖的对应平台原生源码与资源,registry 依赖仍由不可变安装版本标识,不扫描整个 `node_modules`
- Gradle `prepareXuqmEmbeddedBundles` 同步把所有 `file:` 依赖的 Android/iOS 原生源码、资源、podspec 与 package manifest 声明为输入,并排除 build/.cxx/.gradle/generated。baseline 计算和 Gradle 增量使用同一边界,不再出现脚本认为变化而 Gradle 跳过、或 Gradle 重建但 baseline 不变的分裂状态。
- 新增 Node fixture 证明本地依赖版本不变、只修改原生源码也会改变 Android baseline;RNSDK 全量格式、六 workspace 类型检查与全部测试通过。update CLI/Metro 测试增至 14 项,domain 仍为 9 项。
- App4 刷新本地依赖后,旧 baseline `android-sha256:4567…7501` 正确触发六模块重建,新 baseline 为 `android-sha256:eae4…99df`;紧接着复跑 998ms,11 tasks 全部 `UP-TO-DATE`。原生 SemVer 单测、update Java 编译与 App4 457 tasks Debug 构建均通过。
- 新 APK 在 `emulator-5556` 不清用户数据覆盖安装后,common/app 状态都原子刷新为 1.0.0、native API 2 和新 baseline;私有目录仍只有 common/app,无 buz,也无 Java/React FATAL。宿主同时运行 DevEco Studio/Harmony 模拟器导致本轮三次冷启动约 2.27–2.47s、fully-drawn 约 3.28–3.55s;该受竞争负载样本只保留为功能回归证据,不覆盖独立性能基准。
### 2026-07-20 / Android 插件包暂存与存储职责拆分
- `XuqmBundleModule` 不再同时承担 RN Bridge、发布事务、ZIP 解压、manifest 校验、摘要和文件系统实现。下载包大小/SHA-256、ZIP 条目与解压上限、manifest/候选版本一致性、Bundle/资源完整性统一归入 `XuqmPluginPackageStager`;桥接模块只编排 stage/activate/confirm/rollback。
- `XuqmBundleStorage` 成为插件更新原生层文件读取、安全相对路径、SHA-256、原子替换和递归删除的唯一实现;内嵌 Bundle 恢复与远端插件暂存共用同一摘要和写盘语义,没有保留旧私有方法或补丁兼容层。
- `XuqmBundleModule` 从 1081 行降至 853 行。新增原生单测覆盖标准 SHA-256、目录穿越拒绝、Android 资源路径白名单,并与既有 SemVer 测试一起在 App4 宿主执行3 个测试类、6 项测试、0 failure/error。
- App4 刷新相邻 `file:` 依赖后,原生源码变化按设计触发六模块重建,native baseline 更新为 `android-sha256:1a95…5cf0`;紧接着复跑 `:app:prepareXuqmEmbeddedBundles` 为 11 tasks 全部 `UP-TO-DATE`。完整 `:app:assembleDebug``:xuqm_rn-update:testDebugUnitTest` 同轮构建成功。
### 2026-07-20 / Gradle 9.3.1 宿主兼容复核
- update Android 仓库声明已改为 Gradle 9 持续支持的 `url = uri(...)` 赋值形式,不再由 Xuqm 自有脚本产生旧 Groovy property-space assignment warning;不在 SDK 内覆盖宿主 AGP、Kotlin、Gradle 或 React Native 版本。
- App4 按 RN 0.86 官方模板切换到 Gradle 9.3.1 后,真实执行 `:xuqm_rn-update:testDebugUnitTest` 成功,并完成 451 tasks 六模块 Debug APK 构建;原生存储、暂存、SemVer 与 Bridge 拆分均进入宿主编译,不只是独立源码测试。
- 本轮 SDK 定向复核结果common 21 tests、update CLI/Metro/native-baseline 14 tests、release-set/plugin-registry 9 tests、bugcollect 5 tests、xwebview 4 tests;根工作区及各 package TypeScript、Prettier 和 update 发布包 35 文件校验均通过。App4 完整门禁同步达到 20 suites / 87 tests。
- Gradle 9 构建仍报告的弃用项来自 React Native/三方插件,不通过 SDK 关闭 warning。后续升级必须继续以宿主完整构建和原生单测为门禁,不能只验证 TypeScript 或 npm 打包。

查看文件

@ -3,9 +3,9 @@
## 公共生命周期
```ts
import {XuqmSDK} from '@xuqm/rn-common';
import { XuqmSDK } from '@xuqm/rn-common'
await XuqmSDK.awaitInitialization();
await XuqmSDK.awaitInitialization()
await XuqmSDK.login({
userId: 'user-id',
@ -13,18 +13,30 @@ await XuqmSDK.login({
userSig: 'optional-im-credential',
name: '姓名',
phone: '手机号',
});
})
await XuqmSDK.logout();
await XuqmSDK.logout()
```
- `initialize(options)`:仅在不能使用配置文件自动初始化时调用。
- `awaitInitialization()`:等待唯一初始化完成;未配置且未手动初始化会抛出明确错误。
- `awaitInitialization()`:等待唯一自动初始化完成;缺少平台签发配置或 Metro 集成时
抛出明确错误。
- `login(options)`:唯一登录入口;同一会话重复调用幂等,不同会话串行切换。
- `setPrivacyConsent(consented)`:唯一隐私同意同步入口;扩展 SDK 不自行弹窗。
- `getInitializationState()`:返回 `idle / initializing / ready / degraded / failed`
可选结构化错误。
- `logout()`:唯一登出入口。
- `getUserId()` / `getUserInfo()`:只读获取当前会话。
不提供 `setUserInfo`、`setUserId` 或公开的配置文件初始化入口。
不提供 `initialize`、`setUserInfo`、`setUserId` 或公开的配置文件初始化入口。
### 地址职责
- `platformUrl`Xuqm 租户平台地址。远程配置、整包更新、插件 release set 检查等平台接口只使用该地址;自动配置未提供时使用 SDK 内置的公有平台地址。
- `apiUrl`:租户平台返回的业务扩展服务地址,只供明确声明使用该业务服务的扩展能力读取,不能替代 `platformUrl`
- common 的 `apiRequest()` 默认基础地址在 SDK 初始化后固定为 `platformUrl`;访问其它业务服务时必须传绝对 URL 或在 common-only 模式显式 `configureHttp()`,禁止用远程 `apiUrl` 静默改写平台请求。
- 配置中的 `signingKey` 是可被客户端提取的请求完整性材料,不代表用户身份或接口
授权;敏感接口必须继续校验登录 `accessToken`,构建制品上传使用 Jenkins 发布
Bearer。
## common 通用能力
@ -33,16 +45,55 @@ await XuqmSDK.logout();
- `apiRequest`、`configureHttp`、`useRequest`、`useApi`、`usePageApi`
- `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`
- `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`
- `decryptXuqmFile`、`hmacSha256Base64Url`
- `hmacSha256Base64Url`、`sha256Hex`
- `getDeviceId`、`getDeviceInfo`、`detectPushVendor`
- `showToast`、`showAlert`、`showConfirm`、`ScaledImage`
## 扩展包
- `@xuqm/rn-update``UpdateSDK`、`XuqmRuntime`、`definePlugin` 和 `xuqm-rn` CLI。
- `@xuqm/rn-xwebview``XWebViewView`、`XWebViewScreen`、JSBridge 与统一下载。
原生最终恢复页使用 `UpdateSDK.resetToEmbedded()`;它只清理 Update SDK 私有
Bundle/发布状态,不影响宿主账号、业务数据、BugCollect 或签名 SDK。
- `@xuqm/rn-xwebview`:根部一次挂载 `XWebViewHost`,业务使用
`openWebView(config)` 打开全屏页面;`XWebViewView` 只用于内嵌浏览器。业务
JSBridge 通过 `config.bridge` 注入,不属于 XWebView 自身协议。
- `@xuqm/rn-bugcollect``BugCollect` 错误、事件、漏斗和队列上报。
Debug 单次联调使用 `BugCollect.sendTestErrorAndFlush()`,App 不调用
`startCapture()`
- `@xuqm/rn-im`:在 common 会话含 `userSig` 且服务启用时连接。
- `@xuqm/rn-push`:在 common 会话变化时同步原生推送绑定。
所有扩展包都不提供第二套初始化或登录 API。
## update 插件检查边界
Update 服务由平台远程配置 `features.update` 控制,缺失时默认关闭。显式调用
`checkAppUpdate`、`downloadApk`、`downloadAndInstallApp`、`checkPluginRelease` 或
`installPluginRelease` 时,关闭状态统一抛出 `UpdateDisabledError`,错误码为
`UPDATE_DISABLED`,且不会先访问网络或原生安装模块;自动启动检查会正常跳过。
```ts
const plan = await UpdateSDK.checkPluginRelease('buz1', {
signal,
timeoutMs: 5_000,
})
if (plan) {
await UpdateSDK.installPluginRelease(plan, { signal, onProgress })
}
await UpdateSDK.checkAndInstallPlugin('buz1', {
signal,
checkTimeoutMs: 5_000,
onProgress,
})
```
- `checkPluginRelease()` 只检查版本和兼容依赖闭包,不下载、不激活;`timeoutMs` 只约束检查请求。
- `installPluginRelease()` 只安装此前确认的计划;本地基线已变化时抛出 `StaleReleaseSetError`
- `checkAndInstallPlugin()` 用于宿主自动进入插件的场景;`checkTimeoutMs` 只传给检查阶段,下载仍由 `signal` 和进度回调控制。
- release-set 请求固定包含 `appVersion`、`nativeApiLevel`、`nativeBaselineId`;缺少
native baseline 时不发请求。服务端返回的插件候选必须具有平台 Ed25519
`keyId/signature`,客户端在计划前和安装前验证不可变 manifest。
候选响应的 ZIP `sha256``archiveSha256` 纳入签名,下载后继续以同一值校验归档。
- common HTTP 的统一默认请求上限为 30 秒;宿主需要更短的交互等待时显式传上述参数,不得在页面复制 `Promise.race` 或第二套超时器。

查看文件

@ -1,211 +1,52 @@
# XWebView JSBridge 标准接口
# XWebView Bridge 边界
> 最后更新2026-06-15
> 最后更新2026-07-26
---
XWebView 不是业务 JSBridge 协议。它只定义一个通用适配接口:
## 概述
XWebView 内置 JSBridge,支持 H5 页面与 React Native 宿主之间的双向通信。
**通信机制**
- H5 → RN`window.ReactNativeWebView.postMessage(JSON.stringify(data))`
- RN → H5`webViewRef.injectJavaScript(jsString)`
---
## 一、H5 → RN 消息协议
### 1.1 内置消息__xwv 前缀)
XWebView 自动拦截以下浏览器行为,通过 `postMessage` 发送给 RN
| 消息类型 | 触发场景 | 数据格式 |
|---------|---------|---------|
| `alert` | `window.alert(msg)` | `{__xwv: 'alert', msg: string}` |
| `confirm` | `window.confirm(msg)` | `{__xwv: 'confirm', msg: string}` |
| `prompt` | `window.prompt(msg, def)` | `{__xwv: 'prompt', msg: string, def: string}` |
| `download` | 点击下载链接 | `{__xwv: 'download', url: string, filename: string}` |
| `blobdownload` | blob URL 下载 | `{__xwv: 'blobdownload', url: string, filename: string, data: base64}` |
| `bloberror` | blob 读取失败 | `{__xwv: 'bloberror', msg: string}` |
| `log` | 调试日志 | `{__xwv: 'log', msg: string}` |
### 1.2 自定义消息
H5 发送非 `__xwv` 前缀的消息时,XWebView 将其转发给 `onMessage` 回调:
```javascript
// H5 端
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'customEvent',
payload: { key: 'value' }
}));
```ts
type XWebViewBridge = {
injectedJavaScriptBeforeContentLoaded?: string
onMessage(raw: string, context: XWebViewBridgePageContext): boolean | Promise<boolean>
onPageClosed?(pageId: string): void
}
```
```typescript
// RN 端
<XWebViewView
onMessage={(event) => {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'customEvent') {
// 处理自定义消息
}
}}
/>
```
## 页面上下文
---
业务协议实现可以:
## 二、RN → H5 通信
- 关闭当前页;
- 向当前页执行 JavaScript;
- 判断当前页是否为页面栈顶;
- 取得真实状态栏高度;
- 修改当前页导航栏标题/背景;
- 修改当前页导航栏与状态栏显隐。
### 2.1 注入 JavaScript
协议实现不能:
```typescript
// 通过 controller API
controller.postMessageToWeb('window.dispatchEvent(new CustomEvent("rnMessage", {detail: {type: "update", data: "hello"}}))');
- 访问或修改其他 WebView 页面;
- 从 XWebView 读取用户、token、平台、域名或厂商;
- 直接控制宿主导航器;
- 输出原始 Bridge payload;
- 在 common 中注册全局业务 handler。
// 通过 ref
webViewRef.injectJavaScript('window.handleRNMessage("hello")');
```
## 消息所有权
### 2.2 Controller API
XWebView 内部只消费 `__xwv` 命名空间:
通过 `setXWebViewController` 获取的 controller 对象提供以下方法:
- `download` / `blobdownload` / `bloberror`
- `permission`
| 方法 | 说明 |
|------|------|
| `refresh()` | 刷新当前页面 |
| `close()` | 关闭 WebView |
| `goBack()` | 返回上一页 |
| `goForward()` | 前进 |
| `copyUrl()` | 复制当前 URL 到剪贴板 |
| `postMessageToWeb(jsString)` | 向 H5 注入并执行 JS |
| `getTitle()` | 获取当前页面标题 |
其他消息先交给 `config.bridge`。Bridge 返回 `false` 时才继续交给
`behavior.onMessage`。Bridge 抛错由 `behavior.onBridgeError` 接收,不产生未处理
Promise。
---
## 业务协议接入
## 三、下载处理
业务协议必须在自己的 SDK/插件中维护唯一方法表、请求解析、响应结构、错误码和兼容
测试,然后以 `XWebViewBridge` 适配到容器。XWebView 不提供 `getSession`、`navigate`、
`showToast`、签名、支付或任何假定宿主业务的内置方法。
### 3.1 自动下载拦截
XWebView 自动拦截以下文件扩展名的链接点击:
```
.pdf .zip .rar .tar .gz .apk .ipa .doc .docx .xls .xlsx .ppt .pptx .mp4 .mp3 .mov .exe .dmg
```
### 3.2 下载回调
```typescript
<XWebViewView
autoDownload={true}
onDownloadStart={(request) => {
// request: { url, suggestedFilename, mimeType?, fileSize? }
console.log('Download started:', request.suggestedFilename);
}}
onDownloadProgress={(progress) => {
// progress: { url, filename, received, total, percentage }
console.log(`Download: ${progress.percentage}%`);
}}
onDownloadComplete={(result) => {
// result: { url, filename, filePath, fileSize }
console.log('Download complete:', result.filePath);
}}
onDownloadError={(url, error) => {
console.error('Download failed:', url, error);
}}
onDownloadDecide={(request) => {
// 返回下载决策
return { allowed: true, filename: request.suggestedFilename };
}}
downloadConflict="rename" // 'rename' | 'overwrite'
/>
```
### 3.3 Blob URL 下载
XWebView 自动处理 blob URL 下载:
1. 拦截 `<a href="blob:...">` 点击
2. 通过 `FileReader` 读取 blob 数据为 base64
3. 通过 `saveBase64File` 保存到本地
---
## 四、内置 Dialog 覆盖
XWebView 注入 `DIALOG_OVERRIDE_JS`,覆盖浏览器原生对话框:
| 原生方法 | 覆盖行为 |
|---------|---------|
| `window.alert(msg)` | 发送 `{__xwv: 'alert', msg}` 消息 |
| `window.confirm(msg)` | 发送 `{__xwv: 'confirm', msg}` 消息,始终返回 `true` |
| `window.prompt(msg, def)` | 发送 `{__xwv: 'prompt', msg, def}` 消息,返回默认值 |
| `window.open(url)` | 发送日志消息,然后 `window.location.href = url` |
RN 端收到这些消息后,可以展示原生 Alert/Confirm/Prompt 对话框。
---
## 五、标准 Bridge 接口(宿主实现)
以下接口由宿主应用通过 `bridgeHandlers` 实现,H5 通过 `xuqm.call(method, params)` 调用:
### 5.1 会话管理
| 接口 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `getSession` | — | `{token, userId}` | 获取当前登录会话 |
| `getUserInfo` | — | `{userId, name, phone, avatar}` | 获取用户信息 |
### 5.2 导航
| 接口 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `navigate` | `{screen, params?}` | — | 跳转宿主页面 |
| `close` | — | — | 关闭当前 WebView |
| `goBack` | — | — | 返回上一页 |
### 5.2 UI 交互
| 接口 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `showToast` | `{message}` | — | 显示 Toast |
| `showAlert` | `{title?, message}` | — | 显示 Alert 对话框 |
| `showConfirm` | `{title?, message, confirmText?, cancelText?}` | `{confirmed: boolean}` | 显示确认对话框 |
### 5.3 文件操作
| 接口 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `uploadFile` | `{accept?}` | `{url, name, size}` | 选择并上传文件 |
| `openCamera` | — | `{url, base64}` | 打开相机拍照 |
| `scanQR` | — | `{result}` | 扫描二维码 |
### 5.4 设备信息
| 接口 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `getDeviceInfo` | — | `{platform, version, model}` | 获取设备信息 |
| `getLocation` | — | `{latitude, longitude}` | 获取当前位置 |
---
## 六、H5 端调用示例
```javascript
// 获取会话
xuqm.call('getSession').then(session => {
console.log('Token:', session.token);
});
// 跳转宿主页面
xuqm.call('navigate', { screen: 'Settings' });
// 显示 Toast
xuqm.call('showToast', { message: '操作成功' });
// 上传文件
xuqm.call('uploadFile', { accept: 'image/*' }).then(file => {
console.log('Uploaded:', file.url);
});
```
医网信 H5 场景使用 `@szyx/sdk-h5-app/host`;其 V5 线协议不属于
`@xuqm/rn-xwebview` 的公开契约。

查看文件

@ -9,7 +9,17 @@ pnpm exec xuqm-rn init
pnpm xuqm:doctor
```
`init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件配置文件为根目录 `xuqm.config.json`
`init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件构建清单为根目录 `xuqm.modules.json`。它与租户平台签发、宿主不可编辑的 `config.xuqmconfig` 初始化文件职责完全分离。
构建清单分别使用 `packageName` 保存 Android applicationId、使用 `iosBundleId`
保存 iOS Bundle Identifier。目标平台字段只在该平台 build/package/release 时必填。
ZIP wire 字段统一名为 `packageName`,但其值必须由 CLI 按平台从上述两个配置字段解析;
发布时再与同一平台身份精确比较。
插件 ZIP v1 只有一套协议上限:归档不超过 50 MiB、单个 entry 解压后不超过
100 MiB、全部 entry 解压后合计不超过 100 MiB、entry 总数不超过 4096。CLI
`zipSync` 只生成文件 entry,因此显式目录 entry 不属于 v1 协议,服务端和 Android
stager 都必须拒绝;禁止在任一端另设 5000 等平行上限。
开发启动也使用同一 CLI,不由宿主复制 Node 包装脚本:
@ -30,7 +40,7 @@ pnpm exec xuqm-rn plugin create prescription
- 以小写字母开头
- 只包含小写字母、数字和连字符
- 在 `xuqm.config.json` 中唯一
- 在 `xuqm.modules.json` 中唯一
CLI 会生成:
@ -40,7 +50,7 @@ src/plugins/prescription/
PrescriptionScreen.tsx
```
并向 `xuqm.config.json.modules` 增加唯一模块声明。不会修改 Babel alias、TypeScript paths、Metro offset 或增加模块专属 package script。
并向 `xuqm.modules.json.modules` 增加唯一模块声明。不会修改 Babel alias、TypeScript paths、Metro offset 或增加模块专属 package script。
## 插件入口

查看文件

@ -4,7 +4,7 @@
`@xuqm/rn-common` 是唯一可以独立使用的基础包,负责:
- 为官方扩展提供从 `src/assets/config/config.xuqmconfig` 自动初始化的共享上下文;common 单独使用时不启动初始化;
- 为官方扩展提供从 `src/assets/config/` 内唯一 `.xuqmconfig` 自动初始化的共享上下文;common 单独使用时不启动初始化;
- 维护唯一的 SDK 配置和用户会话;
- 网络、访问令牌、签名、文件、时间、加密、设备信息和通用 UI;
- 将一次 `XuqmSDK.login()` / `logout()` 传播给官方扩展包。

查看文件

@ -8,7 +8,7 @@ Xuqm RN 扩展 SDK 只支持一种自动初始化方案:宿主保存一份平
- 不放 `.xuqmconfig`
- 不使用 `withXuqmConfig()`
- 不调用 `XuqmSDK.initialize()`
- 不存在也不需要调用 `XuqmSDK.initialize()`
- 不调用 `XuqmSDK.login()`
common-only 模式保持零初始化、零登录。
@ -18,35 +18,35 @@ common-only 模式保持零初始化、零登录。
安装 update、bugcollect、xwebview 等扩展后,将平台签发的原始文件放到:
```text
src/assets/config/config.xuqmconfig
src/assets/config/<平台下载的文件名>.xuqmconfig
```
文件名也可以是其他 `*.xuqmconfig`。扫描优先级为:
1. `config.xuqmconfig`
2. `config.xuqm`
3. 目录中的第一个 `*.xuqmconfig`
文件名由租户平台决定,不要求宿主改名。该目录没有 `.xuqmconfig` 时保持
common-only;恰好一个时自动使用;存在多个时构建明确失败,禁止按名称或排序猜测
权威配置。不会扫描其它目录。
Metro 配置只包装一次:
```js
const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config');
const {withXuqmConfig} = require('@xuqm/rn-common/metro');
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config')
const { withXuqmConfig } = require('@xuqm/rn-common/metro')
const baseConfig = mergeConfig(getDefaultConfig(__dirname), {
// 宿主自己的 Metro 配置
});
})
module.exports = withXuqmConfig(baseConfig);
module.exports = withXuqmConfig(baseConfig)
```
## 自动初始化时序
```text
Metro 读取唯一 .xuqmconfig
→ 验证平台 Ed25519 签名
→ 校验 V2 Schema、有效期和 canonical JSON
→ AES-GCM 解密
→ 生成构建期传输模块
→ 注册 @xuqm/rn-common/internal 为 pre-main module
→ 解密配置
→ 使用 appKey 请求远程 SDK 配置
→ 初始化唯一 HTTP/扩展上下文
→ 执行业务入口
@ -56,50 +56,69 @@ pre-main 机制保证初始化不受 Metro `inlineRequires` 和业务模块加
## 原生配置同步
`withXuqmConfig()` 会把同一份源文件幂等同步到:
Android Release 构建任务会在 V2 验签和 Bundle 构建成功后,把同一份源文件复制到:
```text
android/app/src/main/assets/config/config.xuqmconfig
ios/<App>/config/config.xuqmconfig
android/app/build/generated/xuqm/assets/config/config.xuqmconfig
```
这些位置是构建目标,不是第二配置源。宿主只维护 `src/assets/config` 下的原文件。
该位置是被 Git 排除的固定构建目标,不是第二配置源。宿主只维护
`src/assets/config/` 中平台下载的唯一 `.xuqmconfig`;不得把副本写进 Android/iOS
源码目录。
## 加密文件
文件格式:
```text
XUQM-CONFIG-V1.{salt}.{iv}.{ciphertextAndTag}
XUQM-CONFIG-V2.{keyId}.{salt}.{iv}.{ciphertextAndTag}.{signature}
```
- PBKDF2-HMAC-SHA256
- AES-256-GCM
- Ed25519 验签覆盖前五段的精确 ASCII,必须先验签再解密
- `keyId` 只能命中 SDK 内置的 X.509 Ed25519 公钥集合
- PBKDF2-HMAC-SHA256 + AES-256-GCM 仅用于混淆正文
- 12 字节 IV
- 16 字节认证标签
解密后的必要字段为 `appKey`,可选字段包括 `serverUrl`、`baseUrl` 和 `signingKey`。明文结构由平台负责签发,业务仓库不得自行构造。
明文必须是键按字典序排列、无空白、忽略 null 的 canonical JSON。必要字段为
`schemaVersion=2`、`configId`、正整数 `revision`、`issuedAt`、`appKey`、`appName`、
`serverUrl``signingKey`;`expiresAt` 省略表示长期有效。平台重新生成配置后,
`configId/revision` 只会阻止后续 Release 构建,不会追溯影响已经安装的 App。
远程配置响应中的 `apiUrl` 是业务扩展服务地址,与配置文件中的平台地址职责不同:
```text
配置文件 serverUrl/baseUrl
→ platformUrl
→ /api/sdk/config、整包更新、插件 release set 等 Xuqm 平台接口
平台返回 apiUrl
→ 业务扩展服务
→ 只有明确依赖该服务的扩展能力使用
```
禁止把远程 `apiUrl` 写回公共 HTTP 基础地址,否则会把版本检查错误地发送到 App 自身业务服务。
## 登录
初始化与业务登录是两个动作。App 登录完成后只同步一次公共会话:
```ts
import {XuqmSDK} from '@xuqm/rn-common';
import { XuqmSDK } from '@xuqm/rn-common'
await XuqmSDK.awaitInitialization();
await XuqmSDK.awaitInitialization()
await XuqmSDK.login({
userId: 'user-id',
accessToken: 'access-token',
name: '姓名',
phone: '手机号',
});
})
```
退出时只调用:
```ts
await XuqmSDK.logout();
await XuqmSDK.logout()
```
扩展包不得各自初始化或登录,业务代码不得导入 `@xuqm/rn-common/internal`
@ -107,7 +126,9 @@ await XuqmSDK.logout();
## 失败规则
- 扩展项目缺少配置:`awaitInitialization()` 明确报错。
- 解密失败或远程配置失败:初始化 Promise 拒绝并保留原始错误。
- 签名、V2 Schema、canonical JSON 或有效期校验失败:构建立即失败。
- 远程配置失败且存在七天内的最后成功配置:进入 `degraded` 并继续启动。
- 首次启动没有缓存:扩展能力返回 `XUQM_NOT_READY`,宿主核心业务继续运行。
- 不自动切换到硬编码配置,不静默创建默认租户,不提供多级兼容回退。
- common-only 项目没有配置时不会执行初始化,也不会报错。
@ -117,3 +138,12 @@ await XuqmSDK.logout();
- 不提交明文 appKey、signingKey 或租户密钥。
- 不在多个目录手工维护配置副本。
- `.xuqmconfig` 变更必须通过平台重新签发。
- 最后成功配置不会明文写入 AsyncStorage。SDK 使用签发配置中的 `signingKey`
appKey、包名和平台地址派生独立 AES-GCM 缓存密钥,密钥材料不落缓存;篡改、
跨包或跨环境读取都会失败。该边界属于应用沙箱内加密,不宣称具备 Android
Keystore/iOS Keychain 的硬件密钥保密性。
- `signingKey` 是随客户端配置交付、可被终端提取的请求签名材料,只用于请求完整性
标记和本地缓存派生,不是 License、授权凭据或服务端秘密,服务端不得仅凭该签名
授予敏感权限。
- 用户数据、灰度发布、上传构建制品等敏感 API 仍必须使用登录得到的用户
`accessToken` 或 Jenkins 发布流水线的独立 Bearer,并由服务端执行真实授权。

查看文件

@ -18,12 +18,13 @@
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
},
"scripts": {
"format": "prettier --write AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json packages/common packages/bugcollect packages/update packages/xwebview",
"format:check": "prettier --check AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json packages/common packages/bugcollect packages/update packages/xwebview",
"test": "pnpm --recursive --if-present test",
"format": "prettier --write AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json scripts packages/common packages/bugcollect packages/update packages/xwebview",
"format:check": "prettier --check AGENTS.md docs/IMPLEMENTATION_HANDOFF.md package.json scripts packages/common packages/bugcollect packages/update packages/xwebview",
"signature-vector:check": "node scripts/sync-server-signature-vector.mjs --check",
"test": "corepack pnpm --recursive --if-present test",
"typecheck": "tsc --noEmit",
"typecheck:all": "pnpm --recursive --if-present typecheck",
"validate": "pnpm format:check && pnpm typecheck:all && pnpm test"
"typecheck:all": "corepack pnpm --recursive --if-present typecheck",
"validate": "corepack pnpm signature-vector:check && corepack pnpm format:check && corepack pnpm typecheck:all && corepack pnpm test"
},
"peerDependencies": {
"@react-native-async-storage/async-storage": ">=1.21.0",

查看文件

@ -24,8 +24,18 @@ try {
}
```
初始化只由 common 自动完成,登录只由 `XuqmSDK.login()` 完成。配置中启用 bugcollect 后,SDK 在共享初始化完成时自动执行一次 `startCapture()`;手工调用仍然幂等,但正常集成不需要调用。`BugCollect.flush()` 可在 App 转入后台前主动刷新队列。
初始化只由 common 自动完成,登录只由 `XuqmSDK.login()` 完成。宿主通过
`XuqmSDK.setPrivacyConsent(true)` 同步已取得的隐私同意;只有平台启用、用户同意且
非 Debug 三个条件同时满足时,SDK 才自动注册采集。不存在宿主手工启动入口。
`BugCollect.flush()` 可在 App 转入后台前主动刷新队列;Debug 开发页使用
`BugCollect.sendTestErrorAndFlush()` 做一次性真实上报验证。
错误指纹使用 common 的统一 SHA-256 实现,事件里的 SDK 版本直接读取当前发布包版本,不再维护第二份硬编码版本号。
SourceMap 上传使用独立打包后处理流程;不要再叠加基于 Metro `customSerializer` 的旧插件方案,以免破坏新版 Metro 的 exports 与序列化流程。
fatal/error 持久队列复用 common 的 AES-GCM 沙箱加密能力;没有签发密钥时不降级写入
包含堆栈和设备信息的明文。Issue 只有在错误堆栈能唯一匹配当前原生 Bundle
state/manifest 时才携带 `buildId/moduleId/moduleVersion/bundleHash` 并标记
`symbolicationStatus=exact`;无法归属时明确为 `unavailable`,服务端不得猜测 latest
Source Map。

查看文件

@ -1,220 +0,0 @@
'use strict'
const fs = require('fs')
const path = require('path')
const https = require('https')
const http = require('http')
/**
* 读取配置文件获取 bugCollectApiUrl
*/
function readConfig(projectRoot) {
// 1. 尝试读取 xuqm.config.js
const configJsPath = path.join(projectRoot, 'xuqm.config.js')
if (fs.existsSync(configJsPath)) {
try {
const config = require(configJsPath)
if (config.bugCollectApiUrl) return config.bugCollectApiUrl
if (config.platformUrl) return config.platformUrl.replace(/\/$/, '') + '/api'
} catch (e) {
console.warn('[BugCollect] Failed to read xuqm.config.js:', e.message)
}
}
// 2. 尝试读取 .xuqmconfig (加密配置文件)
const configDirs = ['config', 'xuqm']
for (const dir of configDirs) {
const configDir = path.join(projectRoot, 'src', 'assets', dir)
if (!fs.existsSync(configDir)) continue
const files = fs.readdirSync(configDir)
const configFile = files.find(f => f.endsWith('.xuqmconfig') || f.endsWith('.xuqm'))
if (configFile) {
// 加密配置文件需要解密,这里先跳过
// 实际使用时应该通过 SDK 解密
console.warn('[BugCollect] Encrypted config found but cannot be read directly')
}
}
// 3. 从环境变量读取
if (process.env.BUG_COLLECT_API_URL) return process.env.BUG_COLLECT_API_URL
return null
}
/**
* 上传 SourceMap 文件
*/
function uploadSourceMap(apiUrl, appKey, platform, appVersion, sourceMapPath) {
return new Promise((resolve, reject) => {
const url = new URL(`${apiUrl}/bugcollect/v1/sourcemaps/upload`)
const isHttps = url.protocol === 'https:'
const client = isHttps ? https : http
// 读取 sourcemap 文件
const fileContent = fs.readFileSync(sourceMapPath)
const fileName = path.basename(sourceMapPath)
// 构建 multipart/form-data
const boundary = '----BugCollectBoundary' + Date.now()
const parts = []
// appKey
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="appKey"\r\n\r\n${appKey}`)
// platform
parts.push(
`--${boundary}\r\nContent-Disposition: form-data; name="platform"\r\n\r\n${platform}`,
)
// appVersion
parts.push(
`--${boundary}\r\nContent-Disposition: form-data; name="appVersion"\r\n\r\n${appVersion}`,
)
// bundleName
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="bundleName"\r\n\r\nindex`)
// file
parts.push(
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/json\r\n\r\n`,
)
const header = Buffer.from(parts.join('\r\n') + '\r\n')
const footer = Buffer.from(`\r\n--${boundary}--\r\n`)
const body = Buffer.concat([header, fileContent, footer])
const options = {
hostname: url.hostname,
port: url.port || (isHttps ? 443 : 80),
path: url.pathname,
method: 'POST',
headers: {
'Content-Type': `multipart/form-data; boundary=${boundary}`,
'Content-Length': body.length,
},
}
const req = client.request(options, res => {
let data = ''
res.on('data', chunk => (data += chunk))
res.on('end', () => {
if (res.statusCode >= 200 && res.statusCode < 300) {
console.log(`[BugCollect] SourceMap uploaded successfully: ${fileName}`)
resolve(data)
} else {
console.warn(`[BugCollect] SourceMap upload failed: ${res.statusCode} ${data}`)
reject(new Error(`Upload failed: ${res.statusCode}`))
}
})
})
req.on('error', err => {
console.warn('[BugCollect] SourceMap upload error:', err.message)
reject(err)
})
req.write(body)
req.end()
})
}
/**
* 获取应用版本号
*/
function getAppVersion(projectRoot) {
try {
const packageJson = require(path.join(projectRoot, 'package.json'))
return packageJson.version || '1.0.0'
} catch {
return '1.0.0'
}
}
/**
* withBugCollect(metroConfig)
* 包裹 Metro 配置 Release 包时自动上传 SourceMap
*/
function withBugCollect(metroConfig) {
const projectRoot = metroConfig.projectRoot || process.cwd()
return {
...metroConfig,
serializer: {
...metroConfig.serializer,
customSerializer: async (entryPoint, preModules, graph, options) => {
// 仅在宿主已有 customSerializer 时委托;否则保持 result 未定义并交回 Metro。
// 注意:本插件应通过显式包裹在已有 serializer 之上使用(见 README
// 不建议自动链式启用Metro 默认 serializer 不可经 exports 获取)。
const baseSerializer = metroConfig.serializer?.customSerializer
const result = baseSerializer
? await baseSerializer(entryPoint, preModules, graph, options)
: undefined
// 仅 Release 包上传 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'

查看文件

@ -10,6 +10,32 @@ type OnError = (error: unknown, meta?: Record<string, unknown>) => void
let _originalFetch: typeof fetch | null = null
function getRequestMetadata(input: RequestInfo | URL, init?: RequestInit) {
const rawUrl =
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
let path: string
try {
path = new URL(rawUrl).pathname
} catch {
path = rawUrl.split(/[?#]/, 1)[0] ?? ''
}
return {
method: init?.method?.toUpperCase() ?? 'GET',
path,
type: 'api_error',
}
}
function isBugCollectRequest(input: RequestInfo | URL): boolean {
const rawUrl =
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
try {
return new URL(rawUrl).pathname.includes('/bugcollect/')
} catch {
return rawUrl.includes('/bugcollect/')
}
}
export const HttpInterceptor = {
start(onError: OnError): void {
if (_originalFetch) return // already installed
@ -22,23 +48,20 @@ export const HttpInterceptor = {
init?: RequestInit,
): Promise<Response> => {
const original = _originalFetch!
if (isBugCollectRequest(input)) return original(input, init)
try {
const res = await original(input, init)
if (!res.ok) {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
onError(new Error(`HTTP ${res.status} ${res.statusText}`), {
type: 'api_error',
url,
onError(new Error(`HTTP ${res.status}`), {
...getRequestMetadata(input, init),
status: res.status,
})
}
return res
} catch (e) {
const url =
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
onError(e, { type: 'api_error', url })
throw e
} catch (error) {
// fetch 抛出的对象可能携带原始 URL 或原生请求细节;采集端只接收固定错误和摘要。
onError(new Error('HTTP request failed'), getRequestMetadata(input, init))
throw error
}
}
},

查看文件

@ -0,0 +1,34 @@
const SENSITIVE_KEY =
/authorization|cookie|token|secret|password|passwd|pin|idcard|identity|phone|mobile/i
const PHONE = /(?<!\d)1[3-9]\d{9}(?!\d)/g
const ID_CARD = /(?<![0-9A-Z])\d{17}[0-9X](?![0-9A-Z])/gi
const BEARER = /Bearer\s+[A-Za-z0-9._~+/=-]+/gi
export function assertBugCollectPrivacyConsent(consented: boolean): void {
if (!consented) {
throw new Error('[BugCollect] Privacy consent is required')
}
}
function sanitizeString(value: string): string {
return value
.replace(BEARER, 'Bearer [REDACTED]')
.replace(PHONE, '[PHONE]')
.replace(ID_CARD, '[ID_CARD]')
}
/** BugCollect 入队前的唯一深度脱敏实现,避免网络与持久化分支出现不同策略。 */
export function sanitizeDiagnostic<T>(value: T, seen = new WeakSet<object>()): T {
if (typeof value === 'string') return sanitizeString(value) as T
if (value === null || typeof value !== 'object') return value
if (seen.has(value)) return '[CIRCULAR]' as T
seen.add(value)
if (Array.isArray(value)) {
return value.map(item => sanitizeDiagnostic(item, seen)) as T
}
const result: Record<string, unknown> = {}
for (const [key, item] of Object.entries(value)) {
result[key] = SENSITIVE_KEY.test(key) ? '[REDACTED]' : sanitizeDiagnostic(item, seen)
}
return result as T
}

查看文件

@ -2,25 +2,50 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import type { BugCollectEvent } from '../types'
import { BUGCOLLECT_SDK_NAME, BUGCOLLECT_SDK_VERSION } from '../sdkInfo'
import { shouldPersist } from './queuePolicy'
const STORED_QUEUE_KEY = '@xuqm_bugcollect:queue'
import { retryWithBackoff } from '@xuqm/rn-common'
import {
clearStoredQueue,
isBugCollectDisabledPayload,
openQueue,
sealQueue,
storedQueueKey,
} from './queueStorage'
const BATCH_SIZE = 30
const FLUSH_INTERVAL_MS = 10_000 // 10 seconds
const MAX_QUEUE_SIZE = 500
const MAX_RETRY = 3
const MAX_STORED_AGE_MS = 7 * 24 * 60 * 60 * 1_000
const DISABLED_ERROR_CODE = 'BUGCOLLECT_DISABLED'
export class BugCollectDisabledError extends Error {
readonly name = 'BugCollectDisabledError'
readonly code = DISABLED_ERROR_CODE
}
export class LogQueue {
private _memory: BugCollectEvent[] = []
private flushTimer: ReturnType<typeof setInterval> | null = null
private retryCount = 0
private disabled = false
private flushPromise: Promise<void> | null = null
constructor(private cfg: { bugCollectApiUrl: string; appKey: string }) {
constructor(
private cfg: {
bugCollectApiUrl: string
appKey: string
encryptionSecret?: string
onDisabled?: () => void | Promise<void>
},
) {
this.flushTimer = setInterval(() => {
void this.flush()
}, FLUSH_INTERVAL_MS)
}
static async clearStored(appKey: string): Promise<void> {
await clearStoredQueue(AsyncStorage, appKey).catch(() => undefined)
}
async push(event: BugCollectEvent): Promise<void> {
if (this.disabled) return
if (shouldPersist(event)) {
const stored = await this._readStored()
if (stored.length >= MAX_QUEUE_SIZE) stored.shift()
@ -32,7 +57,16 @@ export class LogQueue {
}
}
async flush(): Promise<void> {
async flush(throwOnFailure = false): Promise<void> {
if (this.flushPromise) return this.flushPromise
this.flushPromise = this._flushOnce(throwOnFailure).finally(() => {
this.flushPromise = null
})
return this.flushPromise
}
private async _flushOnce(throwOnFailure: boolean): Promise<void> {
if (this.disabled) return
const stored = await this._readStored()
const combined = [...stored, ...this._memory]
if (combined.length === 0) return
@ -41,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

查看文件

@ -0,0 +1,52 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { HttpInterceptor } from '../src/interceptor/HttpInterceptor'
test('HTTP interception removes host, query and request contents before reporting', async () => {
const originalFetch = globalThis.fetch
const reports: Array<{ error: unknown; metadata?: Record<string, unknown> }> = []
globalThis.fetch = async () => new Response(null, { status: 500 })
try {
HttpInterceptor.start((error, metadata) => reports.push({ error, metadata }))
await globalThis.fetch('https://api.example.com/am/user?phone=13800000000&token=secret', {
body: JSON.stringify({ userId: 'secret-user' }),
headers: { authorization: 'secret-authorization' },
method: 'POST',
})
} finally {
HttpInterceptor.stop()
globalThis.fetch = originalFetch
}
assert.equal(reports.length, 1)
assert.deepEqual(reports[0]?.metadata, {
method: 'POST',
path: '/am/user',
status: 500,
type: 'api_error',
})
const serialized = JSON.stringify(reports)
assert.doesNotMatch(serialized, /example\.com|13800000000|token|secret|authorization|userId/)
})
test('BugCollect transport failures are excluded to prevent recursive reporting', async () => {
const originalFetch = globalThis.fetch
const reports: unknown[] = []
globalThis.fetch = async () => new Response(null, { status: 503 })
try {
HttpInterceptor.start(error => reports.push(error))
const response = await globalThis.fetch(
'https://bugcollect.example.com/bugcollect/v1/issues/batch',
{ method: 'POST' },
)
assert.equal(response.status, 503)
} finally {
HttpInterceptor.stop()
globalThis.fetch = originalFetch
}
assert.deepEqual(reports, [])
})

查看文件

@ -44,6 +44,7 @@ test('fatal and error issues are the only events persisted for restart recovery'
exception: { type: 'Error', value: 'value' },
sessionId: 'session',
sdk: { name: 'bugcollect.rn', version: '1.0.0' },
symbolicationStatus: 'unavailable',
} as const
assert.equal(shouldPersist({ ...base, level: 'fatal' } as BugCollectEvent), true)
assert.equal(shouldPersist({ ...base, level: 'error' } as BugCollectEvent), true)

查看文件

@ -0,0 +1,22 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { assertBugCollectPrivacyConsent, sanitizeDiagnostic } from '../src/privacy'
test('diagnostic sanitization removes secrets and common identity values recursively', () => {
const sanitized = sanitizeDiagnostic({
authorization: 'Bearer secret-token',
nested: {
message: 'phone=13800138000 id=110101199001011234',
token: 'secret',
},
})
const serialized = JSON.stringify(sanitized)
assert.doesNotMatch(serialized, /secret-token|13800138000|110101199001011234|"secret"/)
assert.match(serialized, /REDACTED|PHONE|ID_CARD/)
})
test('manual BugCollect test upload still requires privacy consent', () => {
assert.throws(() => assertBugCollectPrivacyConsent(false), /Privacy consent is required/)
assert.doesNotThrow(() => assertBugCollectPrivacyConsent(true))
})

查看文件

@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
clearStoredQueue,
isBugCollectDisabledPayload,
openQueue,
sealQueue,
} from '../src/queue/queueStorage'
import type { IssueEvent } from '../src/types'
const issue: IssueEvent = {
type: 'issue',
level: 'error',
platform: 'android',
fingerprint: 'fingerprint',
timestamp: 1_000,
appKey: 'app',
release: '8.0.0',
exception: { type: 'Error', value: 'secret', stacktrace: 'private stack' },
symbolicationStatus: 'unavailable',
}
test('persistent queue is encrypted, context-bound and tamper-evident', () => {
const encrypted = sealQueue([issue], 'signing-key', 'bugcollect-queue|app')
assert.equal(encrypted.includes('private stack'), false)
assert.deepEqual(openQueue(encrypted, 'signing-key', 'bugcollect-queue|app', 0), [issue])
assert.throws(() => openQueue(encrypted, 'signing-key', 'bugcollect-queue|other', 0))
const parts = encrypted.split('.')
parts[2] = `${parts[2][0] === 'A' ? 'B' : 'A'}${parts[2].slice(1)}`
assert.throws(() => openQueue(parts.join('.'), 'signing-key', 'bugcollect-queue|app', 0))
})
test('platform disabled response recognizes the deployed message contract', () => {
assert.equal(
isBugCollectDisabledPayload({
code: 403,
status: '1',
message: 'BUGCOLLECT_DISABLED',
}),
true,
)
assert.equal(isBugCollectDisabledPayload({ code: 'BUGCOLLECT_DISABLED' }), true)
assert.equal(isBugCollectDisabledPayload({ code: 403, message: 'forbidden' }), false)
})
test('cold-start disable removes a persisted queue before LogQueue is created', async () => {
const removed: string[] = []
await clearStoredQueue(
{
async removeItem(key) {
removed.push(key)
},
},
'app',
)
assert.deepEqual(removed, ['@xuqm_bugcollect:queue:app'])
})

查看文件

@ -25,7 +25,8 @@ const state = expirationStatus('2027-01-01T00:00:00Z')
## 扩展包自动初始化
把平台生成的加密配置放到 `src/assets/config/config.xuqmconfig`,并只包装一次 Metro 配置:
把平台生成的加密配置原名放到 `src/assets/config/`(目录内只能有一个
`.xuqmconfig`),并只包装一次 Metro 配置:
```js
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config')
@ -38,19 +39,15 @@ module.exports = withXuqmConfig(
)
```
使用 update、bugcollect、xwebview 等扩展包时,`withXuqmConfig()` 会把 common 的初始化入口注册为 Metro pre-main module,确保初始化早于业务入口且不受 `inlineRequires` 影响。Metro 插件还会把同一份配置同步到 Android assets;宿主不需要在多个位置维护副本。
使用 update、bugcollect、xwebview 等扩展包时,`withXuqmConfig()` 会在 Node/Metro 构建进程中解密配置,生成只读的虚拟配置模块,并把 common 的初始化入口注册为 Metro pre-main module,确保初始化早于业务入口且不受 `inlineRequires` 影响。Metro 插件还会把同一份加密配置同步到 Android assets;宿主不需要在多个位置维护副本。PBKDF2 与 AES-GCM 解密不在 Hermes 启动线程执行,应用启动时只消费构建已经解析的配置并发起共享初始化。
common-only 项目不要放置 `.xuqmconfig`,也不要使用 `withXuqmConfig()`,即可保持零初始化。是否初始化只由这一条规则决定,不提供额外 alias 或多套配置约定。
common-only 项目不要放置 `.xuqmconfig`,也不要使用 `withXuqmConfig()`,即可保持零初始化。是否初始化只由这一条规则决定,不提供手动初始化、额外 alias 或多套配置约定。
无法使用配置文件时,才调用一次手动初始化:
Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败,common 会终止该 Promise,避免形成未处理异常。随后第一个真正需要配置的扩展调用共享 `awaitInitialization()` 时,会使用同一份已解析配置重新尝试;配置、appKey 和平台地址不需要由页面或子 SDK 再次传入。持续失败由宿主入口统一记录一次,SDK 不重复打印同一错误。构建期配置损坏、密钥不匹配或 Schema 非法会直接阻断构建,禁止打出运行时才失败的安装包。
```ts
await XuqmSDK.initialize({ appKey: 'app-key' })
```
同一配置的并发初始化会复用同一个 Promise;重复使用不同配置会直接报错。
Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败,common 会终止该 Promise,避免形成未处理异常。随后第一个真正需要配置的扩展调用共享 `awaitInitialization()` 时,会使用同一份加密配置重新尝试;配置、appKey 和平台地址不需要由页面或子 SDK 再次传入。持续失败由宿主入口统一记录一次,SDK 不重复打印同一错误。
平台远程配置中的 `features.update` 是 Update 服务开关;兼容字段
`updateEnabled` 只在前者缺失时读取。两者都缺失时安全默认关闭,最后成功配置缓存会
原样保存开关值。关闭 Update 不影响 common、宿主业务或其它扩展 SDK。
## 扩展包的唯一登录入口
@ -80,7 +77,11 @@ await XuqmSDK.logout()
- `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`:统一时间解析与过期判断。
- `formatNumericDateTime`:为 API 参数和固定数字布局提供不受 locale 标点/顺序影响的本地时间格式,调用方通过选项控制日期、时间、秒和分隔符。
- `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`、`registerAndroidDownloadedFile`:统一文件与 Android 下载登记操作。
- `decryptXuqmFile`、`hmacSha256Base64Url`:基于 `@noble/*` 的跨平台加密实现。
- `hmacSha256Base64Url`、`sha256Hex`:签名与文件摘要使用的统一实现。平台签发的
`.xuqmconfig` 只允许由 Metro 构建插件验签和解析,不向运行时暴露通用解密 API。
- `getDeviceInfo`、`getDeviceId`、`useApi`、`usePageApi`、`showToast` 等项目通用能力。
业务代码不得导入 `@xuqm/rn-common/internal`。该子路径仅供官方 `@xuqm` 扩展包接入公共生命周期。
`internal-security` 同样只供官方扩展使用:配置 V2 和插件不可变 manifest 复用一份
canonical JSON键排序、无空白、null/undefined 字段省略)与可信 Ed25519 keyId
集合,不是宿主可替换的通用验签入口。

查看文件

@ -6,42 +6,144 @@
* 宿主只需将平台下载的 .xuqmconfig 文件放入 src/assets/config/
* SDK 自动读取并完成初始化无需重命名或创建 .ts 包装文件
*
* 对齐 Android SDK ConfigFileReader 逻辑
* 优先 config.xuqmconfig否则取第一个 *.xuqmconfig
* 目录内没有配置时保持 common-only唯一一个 *.xuqmconfig 自动使用多个文件
* 无法判断权威配置必须在构建期明确失败
*
* @param {import('@react-native/metro-config').MetroConfig} metroConfig
* @returns {import('@react-native/metro-config').MetroConfig}
*/
const fs = require('fs')
const path = require('path')
const crypto = require('node:crypto')
const VIRTUAL_MODULE_ID = '@xuqm/autoinit-config'
const VIRTUAL_MODULE_ID = '@xuqm/rn-common/auto-init-config'
const AUTO_INIT_MODULE = path.resolve(__dirname, '../src/internal.ts')
const CONFIG_DIR_CANDIDATES = [
'src/assets/config',
'src/assets/xuqm',
'assets/config',
'assets/xuqm',
]
const CONFIG_DIRECTORY_RELATIVE_PATH = 'src/assets/config'
const CONFIG_MAGIC = 'XUQM-CONFIG-V2'
const CONFIG_PASSPHRASE = 'xuqm-config-file-v2.2026.internal'
const PBKDF2_ITERATIONS = 120_000
const TRUSTED_CONFIG_KEYS = require('../security/trusted-signing-keys.json')
const { canonicalJson } = require('../security/canonical-json.js')
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex')
function findConfigFile(projectRoot) {
for (const dir of CONFIG_DIR_CANDIDATES) {
const absDir = path.resolve(projectRoot, dir)
if (!fs.existsSync(absDir)) continue
function decodeBase64Url(value) {
return Buffer.from(value, 'base64url')
}
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)
/**
* 配置文件只在 Node/Metro 构建期解密移动端运行时直接消费解析后的配置
* 避免 Hermes 在应用启动阶段执行 12 万次 PBKDF2 并阻塞 JS 线程
*/
function assertConfigPayload(config, plaintext) {
const allowedFields = new Set([
'schemaVersion',
'configId',
'revision',
'issuedAt',
'expiresAt',
'appKey',
'appName',
'packageName',
'iosBundleId',
'harmonyBundleName',
'serverUrl',
'signingKey',
])
const unknownField = Object.keys(config).find(field => !allowedFields.has(field))
if (unknownField) throw new Error(`配置包含不受支持字段:${unknownField}`)
if (config.schemaVersion !== 2) throw new Error('配置 schemaVersion 必须为 2')
if (
typeof config.configId !== 'string' ||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
config.configId,
)
) {
throw new Error('配置 configId 不是有效 UUID')
}
if (!Number.isInteger(config.revision) || config.revision <= 0) {
throw new Error('配置 revision 必须为正整数')
}
for (const field of ['issuedAt', 'appKey', 'appName', 'serverUrl', 'signingKey']) {
if (typeof config[field] !== 'string' || !config[field].trim()) {
throw new Error(`配置 ${field} 不能为空`)
}
}
return null
if (!config.issuedAt.endsWith('Z') || Number.isNaN(Date.parse(config.issuedAt))) {
throw new Error('配置 issuedAt 必须为 RFC3339 UTC 时间')
}
if (config.expiresAt !== undefined) {
const expiresAt = Date.parse(config.expiresAt)
if (!config.expiresAt.endsWith('Z') || Number.isNaN(expiresAt)) {
throw new Error('配置 expiresAt 必须为 RFC3339 UTC 时间')
}
if (expiresAt <= Date.now()) throw new Error('配置文件已过期,请从租户平台重新生成')
}
if (canonicalJson(config) !== plaintext) {
throw new Error('配置正文不是规范 canonical JSON')
}
}
function decryptConfigAtBuildTime(content, trustedKeys = TRUSTED_CONFIG_KEYS) {
const parts = content.trim().split('.')
if (parts.length !== 6 || parts[0] !== CONFIG_MAGIC) {
throw new Error('仅支持 XUQM-CONFIG-V2;请从租户平台重新下载配置文件')
}
const keyId = parts[1]
const publicKeyBase64 = trustedKeys[keyId]
if (!publicKeyBase64) throw new Error(`配置签名 keyId 不受信任:${keyId}`)
const unsignedToken = parts.slice(0, 5).join('.')
const signatureValid = crypto.verify(
null,
Buffer.from(unsignedToken, 'ascii'),
{
key: Buffer.concat([ED25519_SPKI_PREFIX, Buffer.from(publicKeyBase64, 'base64')]),
format: 'der',
type: 'spki',
},
decodeBase64Url(parts[5]),
)
if (!signatureValid) throw new Error('配置文件签名验证失败')
const salt = decodeBase64Url(parts[2])
const iv = decodeBase64Url(parts[3])
const encrypted = decodeBase64Url(parts[4])
if (salt.length === 0 || iv.length !== 12 || encrypted.length <= 16) {
throw new Error('配置文件加密参数无效')
}
const body = encrypted.subarray(0, -16)
const authTag = encrypted.subarray(-16)
const key = crypto.pbkdf2Sync(CONFIG_PASSPHRASE, salt, PBKDF2_ITERATIONS, 32, 'sha256')
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv)
decipher.setAuthTag(authTag)
try {
const plaintext = Buffer.concat([decipher.update(body), decipher.final()])
const text = plaintext.toString('utf8')
const config = JSON.parse(text)
assertConfigPayload(config, text)
return config
} catch {
throw new Error('配置文件解密或内容校验失败')
}
}
function findConfigFile(projectRoot) {
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 candidates[0]
}
function applyBugCollect(metroConfig) {
@ -51,56 +153,6 @@ function applyBugCollect(metroConfig) {
return metroConfig
}
/** 写入目标文件(仅在缺失或内容不一致时),返回是否发生写入。 */
function syncFile(content, destPath, label) {
try {
if (fs.existsSync(destPath) && fs.readFileSync(destPath, 'utf-8').trim() === content) {
return false
}
fs.mkdirSync(path.dirname(destPath), { recursive: true })
fs.writeFileSync(destPath, content, 'utf-8')
console.log(`[XuqmConfig] 已同步 .xuqmconfig → ${label}`)
return true
} catch (e) {
console.warn(`[XuqmConfig] 同步 .xuqmconfig 到 ${label} 失败:${e.message}`)
return false
}
}
/**
* 单点放置宿主只在 RN 侧放置一份 .xuqmconfig构建/运行时自动同步到
* 原生位置Android assets/config XuqmMergedProvider 自动初始化iOS 资源目录
* 缺失或内容不一致才复制幂等
*/
function syncNativeConfig(projectRoot, content) {
const fileName = 'config.xuqmconfig'
// Android原生 ContentProvider 扫描 assets/config 下的 .xuqmconfig
if (fs.existsSync(path.resolve(projectRoot, 'android'))) {
syncFile(
content,
path.resolve(projectRoot, 'android/app/src/main/assets/config', fileName),
'android assets/config',
)
}
// iOS复制到 app 资源目录(需在 Xcode「Copy Bundle Resources」中引用一次
const iosDir = path.resolve(projectRoot, 'ios')
if (fs.existsSync(iosDir)) {
try {
const appDir = fs
.readdirSync(iosDir, { withFileTypes: true })
.filter(d => d.isDirectory() && fs.existsSync(path.join(iosDir, d.name, 'Info.plist')))
.map(d => d.name)[0]
if (appDir) {
syncFile(content, path.join(iosDir, appDir, 'config', fileName), `ios ${appDir}/config`)
}
} catch {
// 忽略 iOS 同步异常,不阻断打包
}
}
}
function withXuqmConfig(metroConfig) {
const projectRoot = metroConfig.projectRoot ?? process.cwd()
const configFile = findConfigFile(projectRoot)
@ -113,10 +165,14 @@ function withXuqmConfig(metroConfig) {
// 读取加密配置内容
const encryptedContent = fs.readFileSync(configFile, 'utf-8').trim()
// 单点放置:自动同步到原生 Android/iOS缺失或不一致才复制
syncNativeConfig(projectRoot, encryptedContent)
let resolvedConfig
try {
resolvedConfig = decryptConfigAtBuildTime(encryptedContent)
} catch (error) {
throw new Error(`[XuqmConfig] ${configFile}: ${error.message}`)
}
// 生成临时 TS 文件作为虚拟模块
// 生成临时 TS 文件作为虚拟模块。这里只传输已解析对象,移动端不再解密。
// Metro 0.84+ only hashes files under projectRoot/watchFolders. Keep the
// generated transport module in the ignored package-manager cache instead
// of the system temp directory; the .xuqmconfig file remains the sole source.
@ -125,11 +181,11 @@ function withXuqmConfig(metroConfig) {
const virtualFile = path.join(generatedDir, 'autoinit-config.ts')
fs.writeFileSync(
virtualFile,
`// Auto-generated by @xuqm/rn-common/metro — do not edit\nexport const ENCRYPTED_CONFIG = ${JSON.stringify(encryptedContent)};\n`,
`// Auto-generated by @xuqm/rn-common/metro — do not edit\nexport const RESOLVED_CONFIG = ${JSON.stringify(resolvedConfig)} as const;\nexport const BUILD_OPTIONS = ${JSON.stringify({ bugCollectMode: process.env.XUQM_BUGCOLLECT_MODE ?? 'platform' })} as const;\n`,
'utf-8',
)
// 拦截模块解析,将 @xuqm/autoinit-config 指向虚拟文件
// 拦截默认空实现,将 @xuqm/rn-common/auto-init-config 指向虚拟文件
const existingResolveRequest = metroConfig.resolver?.resolveRequest
const existingGetModulesRunBeforeMainModule =
metroConfig.serializer?.getModulesRunBeforeMainModule
@ -165,4 +221,4 @@ function withXuqmConfig(metroConfig) {
})
}
module.exports = { withXuqmConfig }
module.exports = { decryptConfigAtBuildTime, findConfigFile, withXuqmConfig }

查看文件

@ -8,18 +8,22 @@
"types": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./auto-init-config": "./src/autoInitConfig.ts",
"./crypto": "./src/crypto.ts",
"./download": "./src/download.ts",
"./file-name": "./src/fileName.ts",
"./internal": "./src/internal.ts",
"./internal-security": "./src/internalSecurity.ts",
"./metro": "./metro/index.js",
"./package.json": "./package.json",
"./secure-cache": "./src/secureCache.ts",
"./version": "./src/version.ts"
},
"metro": "metro/index.js",
"files": [
"src",
"metro"
"metro",
"security"
],
"private": false,
"publishConfig": {
@ -31,6 +35,7 @@
},
"dependencies": {
"@noble/ciphers": "2.2.0",
"@noble/curves": "2.2.0",
"@noble/hashes": "2.2.0",
"@types/semver": "7.7.1",
"react-native-blob-util": "0.24.10",
@ -38,18 +43,18 @@
"zod": "4.4.3"
},
"peerDependencies": {
"react": ">=18.0.0",
"react-native": ">=0.76.0",
"@react-native-async-storage/async-storage": ">=1.21.0",
"axios": ">=1.0.0"
"axios": ">=1.0.0",
"react": ">=18.0.0",
"react-native": ">=0.76.0"
},
"devDependencies": {
"typescript": "catalog:",
"@types/react": "^19.2.14",
"@react-native-async-storage/async-storage": "^3.1.1",
"@types/react": "^19.2.14",
"axios": "^1.18.0",
"react": "19.2.7",
"react-native": "0.86.0",
"tsx": "^4.23.1"
"tsx": "^4.23.1",
"typescript": "catalog:"
}
}

5
packages/common/security/base64.d.ts vendored 普通文件
查看文件

@ -0,0 +1,5 @@
export function decodeBase64(value: string): Uint8Array
export function encodeBase64(
value: Uint8Array,
options?: { urlSafe?: boolean; padding?: boolean },
): string

查看文件

@ -0,0 +1,85 @@
'use strict'
const STANDARD_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
const URL_SAFE_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_'
/**
* JS Base64 编码默认输出带填充的标准 Base64
* 协议调用方必须显式声明 URL-safe 与是否保留填充避免现场字符串替换
*/
function encodeBase64(value, options = {}) {
if (!(value instanceof Uint8Array)) throw new TypeError('Base64 value must be Uint8Array')
const alphabet = options.urlSafe ? URL_SAFE_ALPHABET : STANDARD_ALPHABET
const padding = options.padding !== false
let output = ''
for (let offset = 0; offset < value.length; offset += 3) {
const first = value[offset]
const hasSecond = offset + 1 < value.length
const hasThird = offset + 2 < value.length
const second = hasSecond ? value[offset + 1] : 0
const third = hasThird ? value[offset + 2] : 0
output += alphabet[first >>> 2]
output += alphabet[((first & 0x03) << 4) | (second >>> 4)]
if (hasSecond) {
output += alphabet[((second & 0x0f) << 2) | (third >>> 6)]
} else if (padding) {
output += '='
}
if (hasThird) {
output += alphabet[third & 0x3f]
} else if (padding) {
output += '='
}
}
return output
}
/**
* 严格解码标准 Base64 Base64URL不依赖浏览器/Hermes atob
* 接受有填充或无填充形式但拒绝混合字母表非法长度非法填充和非零尾随位
*/
function decodeBase64(value) {
if (typeof value !== 'string') throw new TypeError('Base64 value must be a string')
if (value.length === 0) return new Uint8Array(0)
if (/[^A-Za-z0-9+/_=-]/.test(value)) throw new Error('Base64 contains invalid characters')
if (/[+\/]/.test(value) && /[-_]/.test(value)) {
throw new Error('Base64 cannot mix standard and URL-safe alphabets')
}
const firstPadding = value.indexOf('=')
const data = firstPadding === -1 ? value : value.slice(0, firstPadding)
const padding = firstPadding === -1 ? '' : value.slice(firstPadding)
if (padding !== '' && padding !== '=' && padding !== '==') {
throw new Error('Base64 padding is invalid')
}
if (data.includes('=')) throw new Error('Base64 padding is invalid')
const remainder = data.length % 4
if (remainder === 1) throw new Error('Base64 length is invalid')
const expectedPadding = remainder === 0 ? 0 : 4 - remainder
if (padding.length !== 0 && padding.length !== expectedPadding) {
throw new Error('Base64 padding length is invalid')
}
const normalized = data.replace(/-/g, '+').replace(/_/g, '/')
const outputLength = Math.floor((normalized.length * 6) / 8)
const output = new Uint8Array(outputLength)
let accumulator = 0
let bitCount = 0
let outputIndex = 0
for (const character of normalized) {
const sextet = STANDARD_ALPHABET.indexOf(character)
if (sextet < 0) throw new Error('Base64 contains invalid characters')
accumulator = (accumulator << 6) | sextet
bitCount += 6
if (bitCount >= 8) {
bitCount -= 8
output[outputIndex] = (accumulator >>> bitCount) & 0xff
outputIndex += 1
accumulator &= (1 << bitCount) - 1
}
}
if (accumulator !== 0) throw new Error('Base64 has non-zero trailing bits')
return output
}
module.exports = { decodeBase64, encodeBase64 }

查看文件

@ -0,0 +1 @@
export function canonicalJson(value: unknown): string

查看文件

@ -0,0 +1,46 @@
'use strict'
/**
* 安全协议唯一使用的 canonical JSON对象键按字典序排列null/undefined 字段省略
* JSON 协议值数组空位和不确定的对象形态直接拒绝
*/
function canonicalJson(value) {
if (value === null) return 'null'
if (typeof value === 'string' || typeof value === 'boolean') return JSON.stringify(value)
if (typeof value === 'number') {
if (!Number.isFinite(value)) throw new TypeError('Canonical JSON requires finite numbers')
return JSON.stringify(value)
}
if (Array.isArray(value)) {
for (let index = 0; index < value.length; index += 1) {
if (!Object.prototype.hasOwnProperty.call(value, index) || value[index] === undefined) {
throw new TypeError('Canonical JSON arrays cannot contain holes or undefined')
}
}
return `[${value.map(canonicalJson).join(',')}]`
}
if (value && typeof value === 'object') {
const prototype = Object.getPrototypeOf(value)
if (prototype !== Object.prototype && prototype !== null) {
throw new TypeError('Canonical JSON only supports plain objects')
}
const keys = Reflect.ownKeys(value)
if (keys.some(key => typeof key === 'symbol')) {
throw new TypeError('Canonical JSON does not support symbol keys')
}
for (const key of keys) {
const descriptor = Object.getOwnPropertyDescriptor(value, key)
if (!descriptor?.enumerable || !('value' in descriptor)) {
throw new TypeError('Canonical JSON only supports enumerable data properties')
}
}
return `{${keys
.filter(key => value[key] !== null && value[key] !== undefined)
.sort()
.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
.join(',')}}`
}
throw new TypeError(`Canonical JSON does not support ${typeof value}`)
}
module.exports = { canonicalJson }

查看文件

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

查看文件

@ -1,5 +1,7 @@
import type { AxiosError, AxiosResponse } from 'axios'
import { isAxiosError } from 'axios'
import type { z } from 'zod'
import type { RequestError } from './errors'
type SafeIssue = {
code: string
@ -16,15 +18,29 @@ export type SafeApiDiagnostic = {
type: 'AxiosError' | 'Response' | 'ValidationError'
}
export type SafeApiErrorReport = {
diagnostic?: SafeApiDiagnostic
requestType: RequestError['type']
}
/**
* / 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),
}
}
@ -53,6 +69,42 @@ export function createAxiosDiagnostic(error: AxiosError): SafeApiDiagnostic {
code: error.code,
httpStatus: error.response?.status,
method: error.config?.method?.toUpperCase(),
path: error.config?.url,
path: safeRequestPath(error.config?.url),
}
}
/**
* axios
* causestackconfig
*/
export function createUnexpectedDiagnostic(error: unknown): string {
if (error instanceof Error) {
return `${error.name}: ${error.message.slice(0, 200)}`
}
return typeof error
}
type ValidationCause = {
issues: z.ZodIssue[]
response: AxiosResponse<unknown>
}
function isValidationCause(value: unknown): value is ValidationCause {
if (!value || typeof value !== 'object') return false
const candidate = value as Partial<ValidationCause>
return Array.isArray(candidate.issues) && Boolean(candidate.response?.config)
}
/**
* RequestError.cause
* cause Axios configheaders
*/
export function createSafeApiErrorReport(error: RequestError): SafeApiErrorReport {
const cause = error.cause
const diagnostic = isValidationCause(cause)
? createValidationDiagnostic(cause.response, cause.issues)
: isAxiosError(cause)
? createAxiosDiagnostic(cause)
: undefined
return { diagnostic, requestType: error.type }
}

查看文件

@ -1,7 +1,8 @@
export class RequestError<TData = unknown> extends Error {
constructor(
message: string,
public readonly type: 'Cancel' | 'ValidationError' | 'AxiosError' | 'OtherError',
public readonly type:
'Cancel' | 'ValidationError' | 'BusinessError' | 'AxiosError' | 'OtherError',
public readonly cause?: unknown,
public readonly response?: { data?: TData; status?: number },
) {

查看文件

@ -1,6 +1,7 @@
import { RequestError } from './errors'
import { createSafeApiErrorReport, type SafeApiErrorReport } from './diagnostics'
type ApiErrorHandler = (error: RequestError) => void
type ApiErrorHandler = (report: SafeApiErrorReport) => void
let _handler: ApiErrorHandler | null = null
@ -11,5 +12,5 @@ export function setGlobalApiErrorHandler(handler: ApiErrorHandler): void {
/** 内部调用:在 useRequest 的 catch 分支中触发。*/
export function _notifyApiError(error: RequestError): void {
_handler?.(error)
_handler?.(createSafeApiErrorReport(error))
}

查看文件

@ -3,6 +3,7 @@ export { useApi } from './useApi'
export { usePageApi } from './usePageApi'
export { RequestError } from './errors'
export { setGlobalApiErrorHandler } from './globalErrorHandler'
export type { SafeApiDiagnostic, SafeApiErrorReport } from './diagnostics'
export type { RequestOptions } from './useRequest'
export type { ApiMethod } from './useApi'
export type { PageOptions } from './usePageApi'

查看文件

@ -10,12 +10,13 @@ import { z } from 'zod'
import {
createAxiosDiagnostic,
createResponseDiagnostic,
createUnexpectedDiagnostic,
createValidationDiagnostic,
} from './diagnostics'
import { RequestError } from './errors'
import { type RequestOptions, useRequest, ValidationError } from './useRequest'
const SUCCESS_STATUS = /^0$/
const SUCCESS_STATUS = '0'
type ApiResponse<T> = {
status: string
@ -42,12 +43,14 @@ export const useApi = <S extends z.ZodTypeAny, T extends z.infer<S> = z.infer<S>
requestInterceptors: AxiosInterceptorManager<AxiosRequestConfig<unknown>>
responseInterceptors: AxiosInterceptorManager<AxiosResponse<ApiResponse<T>, unknown>>
} => {
// schema 只校验响应结构,不强制 status==='0';
// 业务状态码非 '0' 属于正常业务响应,由 transformedFetchAsync 统一处理。
const responseSchema = useMemo(
() =>
z.object({
status: z.string().regex(SUCCESS_STATUS),
status: z.string(),
message: z.string(),
data: validationSchema ?? z.unknown(),
data: (validationSchema ?? z.unknown()).optional(),
}),
[validationSchema],
)
@ -106,12 +109,6 @@ export const useApi = <S extends z.ZodTypeAny, T extends z.infer<S> = z.infer<S>
}
const firstIssue = e.issues[0]
if (firstIssue.path.length > 0 && firstIssue.path[0] === 'status') {
return Promise.reject(
new RequestError(e.response.data?.message ?? '接口返回失败', 'ValidationError', e),
)
}
return Promise.reject(new RequestError(firstIssue.message, 'ValidationError', e))
}
@ -123,7 +120,13 @@ export const useApi = <S extends z.ZodTypeAny, T extends z.infer<S> = z.infer<S>
return Promise.reject(new RequestError('网络请求失败', 'AxiosError', thrownError))
}
console.error('[XuqmAPI] unexpected request failure')
console.error(
'[XuqmAPI] unexpected request failure',
createUnexpectedDiagnostic(thrownError),
)
if (thrownError instanceof Error && thrownError.stack) {
console.error('[XuqmAPI] unexpected request failure stack', thrownError.stack)
}
return Promise.reject(new RequestError('网络请求失败', 'OtherError', thrownError))
},
)
@ -136,7 +139,13 @@ export const useApi = <S extends z.ZodTypeAny, T extends z.infer<S> = z.infer<S>
async (nextConfig?: AxiosRequestConfig<D>) => {
try {
const requestResponse = await fetchAsync(nextConfig)
return (requestResponse as unknown as AxiosResponse<ApiResponse<T>>).data.data
const body = (requestResponse as unknown as AxiosResponse<ApiResponse<T>>).data
// 业务状态码非 '0' 时,抛出 RequestError 而非 ValidationError,
// 避免业务错误被误报为"校验失败"并影响调用方。
if (body.status !== SUCCESS_STATUS) {
throw new RequestError(body.message || '接口返回失败', 'BusinessError', body)
}
return body.data
} catch (requestError) {
return Promise.reject(requestError)
}

查看文件

@ -15,19 +15,27 @@ export interface RequestOptions {
loadingDelay?: number
/** 请求标签,用于日志或错误追踪。*/
tag?: string
/** true = 在响应拦截器中打印完整响应。*/
/** true = 在响应拦截器中打印不含请求体、响应体和 headers 的安全摘要。*/
log?: boolean
}
// ─── 内部 ValidationError对齐 @szyx-mobile/use-axios 的 ValidationError────
export class ValidationError<T = unknown, D = unknown> extends z.ZodError {
// 不能 extends z.ZodErrorzod v4 的 ZodError 是 $constructor 工厂函数,
// 其自定义 Symbol.hasInstance 要求实例已初始化 _zod.traits。Babel 转译的子类
// 在 super() 之前先执行 _classCallCheck(this, ValidationError),此时 _zod 尚不
// 存在,instanceof 返回 false,直接抛 “Cannot call a class as a function”,
// 把真实的校验失败掩盖成非预期错误。
export class ValidationError<T = unknown, D = unknown> extends Error {
public readonly issues: z.ZodIssue[]
constructor(
issues: z.ZodIssue[],
public readonly response: AxiosResponse<T, D>,
) {
super(issues)
super(issues[0]?.message ?? 'ValidationError')
this.name = 'ValidationError'
this.issues = issues
}
}

查看文件

@ -8,20 +8,31 @@
* 2. metro.config.js 使 withXuqmConfig(metroConfig)
* 3. Metro
*
* require
* 使 withXuqmConfig Metro Bundle
*/
import { isInitialized } from './config'
import { _initializeFromConfigFile } from './sdk'
import type { DecryptedConfig } from './crypto'
import { _initializeFromResolvedConfig } from './sdk'
let _autoInitAttempted = false
function tryRequireConfig(): string | null {
interface AutoInitPayload {
config: DecryptedConfig
buildOptions?: { bugCollectMode?: 'platform' | 'disabled' }
}
function tryRequireConfig(): AutoInitPayload | null {
try {
// withXuqmConfig 将此模块映射到构建期生成的配置传输模块
// withXuqmConfig 将此模块映射到构建期已解密、已校验的配置传输模块。
// eslint-disable-next-line @typescript-eslint/no-require-imports
const mod = require('@xuqm/autoinit-config') as { ENCRYPTED_CONFIG?: string; default?: string }
return mod?.ENCRYPTED_CONFIG ?? mod?.default ?? null
const mod = require('@xuqm/rn-common/auto-init-config') as {
RESOLVED_CONFIG?: DecryptedConfig
default?: DecryptedConfig
BUILD_OPTIONS?: { bugCollectMode?: 'platform' | 'disabled'; buildId?: string }
}
const config = mod?.RESOLVED_CONFIG ?? mod?.default ?? null
return config ? { config, buildOptions: mod.BUILD_OPTIONS } : null
} catch {
return null
}
@ -38,10 +49,13 @@ export function tryAutoInit(): void {
_autoInitAttempted = true
if (isInitialized()) return
const encrypted = tryRequireConfig()
if (!encrypted) return
const payload = tryRequireConfig()
if (!payload) return
void _initializeFromConfigFile(encrypted, { debug: __DEV__ }).catch(() => undefined)
void _initializeFromResolvedConfig(payload.config, {
debug: __DEV__,
bugCollectMode: payload.buildOptions?.bugCollectMode,
}).catch(() => undefined)
}
tryAutoInit()

查看文件

@ -0,0 +1,6 @@
/**
* common-only 宿withXuqmConfig Metro
* 使 Metro
*/
export const RESOLVED_CONFIG = undefined
export const BUILD_OPTIONS = undefined

查看文件

@ -0,0 +1,28 @@
export interface XuqmBundleIdentity {
buildId: string
moduleId: string
moduleVersion: string
bundleHash: string
}
export type XuqmBundleIdentityProvider = (stacktrace: string) => Promise<XuqmBundleIdentity | null>
let provider: { owner: string; resolve: XuqmBundleIdentityProvider } | null = null
/** @internal update 是 Bundle 状态唯一事实源,只允许注册一个解析器。 */
export function _registerBundleIdentityProvider(
owner: string,
next: XuqmBundleIdentityProvider,
): void {
if (provider && provider.owner !== owner) {
throw new Error('[XuqmSDK] A bundle identity provider is already registered.')
}
provider = { owner, resolve: next }
}
/** @internal 无法精确归属时返回 null,调用方不得猜测 latest 或 app Bundle。 */
export async function _resolveBundleIdentity(
stacktrace: string,
): Promise<XuqmBundleIdentity | null> {
return provider ? provider.resolve(stacktrace) : null
}

查看文件

@ -1,11 +1,23 @@
export interface XuqmInitOptions {
/** @internal 仅由 Metro 已验签配置驱动,不属于宿主公开契约。 */
export interface InternalInitOptions {
appKey: string
platformUrl?: string // 不传则使用内置默认公有平台地址
debug?: boolean
/** 构建工具只允许将 BugCollect 降级为 disabled,不能反向覆盖平台关闭状态。 */
bugCollectMode?: 'platform' | 'disabled'
/** 自动配置内部使用。 */
packageName?: string
/** @internal 远程配置来源,用于扩展判断缓存是否有权覆盖服务端停用锁。 */
configSource?: 'remote' | 'cache'
}
export interface XuqmConfig {
appKey: string
/** 已验签配置中的宿主包名,用于绑定插件签名,禁止由业务运行时改写。 */
packageName: string
/** 租户平台地址:配置、更新、插件发布等 Xuqm 平台接口统一使用该地址。 */
platformUrl: string
/** 业务扩展服务地址,由租户平台远程配置返回,不能代替 platformUrl。 */
apiUrl: string
imWsUrl: string
fileServiceUrl: string
@ -16,10 +28,26 @@ export interface XuqmConfig {
// 崩溃采集服务rn-bugcollect 使用)
bugCollectApiUrl: string
bugCollectEnabled: boolean
/** Update 服务必须由平台明确开启;缺失时安全关闭。 */
updateEnabled: boolean
/** Update 灰度检查是否要求共享登录态;由租户平台动态配置。 */
updateRequiresLogin: boolean
configurationSource: 'remote' | 'cache'
// 请求签名密钥(从配置文件读取)
signingKey?: string
}
export type XuqmInitializationState = 'idle' | 'initializing' | 'ready' | 'degraded' | 'failed'
export interface XuqmInitializationSnapshot {
state: XuqmInitializationState
source?: 'remote' | 'cache'
error?: {
code: string
message: string
}
}
export interface XuqmUserInfo {
userId: string
userSig?: string // IM 登录凭证;未开通 IM 时可不传
@ -36,13 +64,17 @@ export interface XuqmLoginOptions extends XuqmUserInfo {
export type XuqmUserInfoHandler = (info: XuqmUserInfo | null) => void | Promise<void>
export type XuqmInitializationHandler = (config: Readonly<XuqmConfig>) => void | Promise<void>
export type XuqmPrivacyConsentHandler = (consented: boolean) => void | Promise<void>
// ─── Internal state ────────────────────────────────────────────────────────────
let _config: XuqmConfig | null = null
let _userInfo: XuqmUserInfo | null = null
let _privacyConsented = false
let _initializationSnapshot: XuqmInitializationSnapshot = { state: 'idle' }
const _userInfoHandlers = new Map<string, XuqmUserInfoHandler>()
const _initializationHandlers = new Map<string, XuqmInitializationHandler>()
const _privacyConsentHandlers = new Map<string, XuqmPrivacyConsentHandler>()
// ─── Config ────────────────────────────────────────────────────────────────────
@ -55,16 +87,21 @@ export interface XuqmRemoteConfig {
pushEnabled?: boolean
bugCollectApiUrl?: string
bugCollectEnabled?: boolean
updateEnabled?: boolean
updateRequiresLogin?: boolean
}
export function initConfigFromRemote(
options: XuqmInitOptions,
options: InternalInitOptions,
remote: XuqmRemoteConfig,
signingKey?: string,
): void {
const platformUrl = (options.platformUrl ?? '').replace(/\/$/, '')
const apiUrl = remote.apiUrl ?? remote.imApiUrl ?? ''
_config = {
appKey: options.appKey,
packageName: options.packageName?.trim() ?? '',
platformUrl,
apiUrl,
imWsUrl: remote.imWsUrl ?? '',
fileServiceUrl: remote.fileServiceUrl ?? apiUrl,
@ -72,13 +109,19 @@ export function initConfigFromRemote(
imEnabled: remote.imEnabled ?? !!remote.imWsUrl,
pushEnabled: remote.pushEnabled ?? true,
bugCollectApiUrl: remote.bugCollectApiUrl ?? '',
bugCollectEnabled: remote.bugCollectEnabled ?? false,
bugCollectEnabled: options.bugCollectMode !== 'disabled' && (remote.bugCollectEnabled ?? false),
updateEnabled: remote.updateEnabled ?? false,
// 服务端未下发匿名策略时按安全默认禁止匿名更新检查。
updateRequiresLogin: remote.updateRequiresLogin ?? true,
configurationSource: options.configSource ?? 'remote',
signingKey,
}
}
export function getConfig(): XuqmConfig {
if (!_config) throw new Error('[XuqmSDK] Not initialized — call XuqmSDK.initialize() first.')
if (!_config) {
throw new Error('[XuqmSDK] Not initialized — verify the signed config and Metro integration.')
}
return _config
}
@ -94,6 +137,42 @@ export function isInitialized(): boolean {
return _config !== null
}
export function getInitializationSnapshot(): Readonly<XuqmInitializationSnapshot> {
return _initializationSnapshot
}
export function _setInitializationSnapshot(snapshot: XuqmInitializationSnapshot): void {
_initializationSnapshot = snapshot
}
export function hasPrivacyConsent(): boolean {
return _privacyConsented
}
export async function _setPrivacyConsent(consented: boolean): Promise<void> {
if (_privacyConsented === consented) return
_privacyConsented = consented
const results = await Promise.allSettled(
Array.from(_privacyConsentHandlers.values(), handler => Promise.resolve(handler(consented))),
)
for (const result of results) {
if (result.status === 'rejected') {
console.warn('[XuqmSDK] Privacy consent handler failed:', result.reason)
}
}
}
export function _registerPrivacyConsentHandler(
name: string,
handler: XuqmPrivacyConsentHandler,
): () => void {
if (_privacyConsentHandlers.has(name)) {
throw new Error(`[XuqmSDK] Privacy consent handler "${name}" is already registered.`)
}
_privacyConsentHandlers.set(name, handler)
return () => _privacyConsentHandlers.delete(name)
}
export function _registerInitializationHandler(
name: string,
handler: XuqmInitializationHandler,
@ -157,11 +236,11 @@ export async function _setUserInfo(info: XuqmUserInfo | null): Promise<void> {
}
}),
)
const failures = results
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
.map(result => String(result.reason))
if (failures.length > 0) {
throw new Error(`[XuqmSDK] Session propagation failed: ${failures.join('; ')}`)
for (const result of results) {
if (result.status === 'rejected') {
// 扩展登录失败不能反向破坏宿主已经完成的业务登录。
console.warn('[XuqmSDK] Session propagation failed:', result.reason)
}
}
}

查看文件

@ -1,77 +1,30 @@
import { gcm } from '@noble/ciphers/aes.js'
import { hmac } from '@noble/hashes/hmac.js'
import { pbkdf2Async } from '@noble/hashes/pbkdf2.js'
import { sha256 } from '@noble/hashes/sha2.js'
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'
const MAGIC_CONFIG = 'XUQM-CONFIG-V1'
const PASSPHRASES = {
[MAGIC_CONFIG]: 'xuqm-config-file-v1.2026.internal',
} as const
const PBKDF2_ITERATIONS = 120_000
const AES_KEY_BYTES = 32
export type XuqmEncryptedFileMagic = keyof typeof PASSPHRASES
import { encodeBase64 } from '../security/base64.js'
export interface DecryptedConfig {
schemaVersion: 2
configId: string
revision: number
appKey: string
appName?: string
companyName?: string
baseUrl?: string
serverUrl?: string
signingKey?: string
issuedAt?: string
appName: string
packageName?: string
iosBundleId?: string
harmonyBundleName?: string
serverUrl: string
signingKey: string
issuedAt: string
expiresAt?: string
}
function base64UrlDecode(value: string): Uint8Array {
const padded =
value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4)
const binary = atob(padded)
return Uint8Array.from({ length: binary.length }, (_, index) => binary.charCodeAt(index))
/** Encode arbitrary binary data without exceeding Hermes argument-count limits. */
export function bytesToBase64(value: Uint8Array): string {
return encodeBase64(value)
}
function base64UrlEncode(value: Uint8Array): string {
let binary = ''
value.forEach(byte => {
binary += String.fromCharCode(byte)
})
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* Decode UTF-8 without relying on the optional TextDecoder implementation in
* the React Native runtime. Hermes versions and third-party polyfills have
* returned invalid text for Uint8Array views in real applications.
*/
function utf8BytesToString(value: Uint8Array): string {
let encoded = ''
for (const byte of value) {
encoded += `%${byte.toString(16).padStart(2, '0')}`
}
return decodeURIComponent(encoded)
}
function parseEncryptedFile(content: string): {
magic: XuqmEncryptedFileMagic
salt: Uint8Array
iv: Uint8Array
ciphertext: Uint8Array
} {
const parts = content.trim().split('.')
const magic = parts[0] as XuqmEncryptedFileMagic
if (parts.length !== 4 || !(magic in PASSPHRASES)) {
throw new Error('[XuqmSDK] Invalid config file format')
}
const salt = base64UrlDecode(parts[1])
const iv = base64UrlDecode(parts[2])
const ciphertext = base64UrlDecode(parts[3])
if (salt.length === 0 || iv.length !== 12 || ciphertext.length <= 16) {
throw new Error('[XuqmSDK] Invalid config cryptographic parameters')
}
return { magic, salt, iv, ciphertext }
return encodeBase64(value, { urlSafe: true, padding: false })
}
export function hmacSha256Base64Url(secret: string, data: string): string {
@ -82,30 +35,3 @@ export function hmacSha256Base64Url(secret: string, data: string): string {
export function sha256Hex(value: string | Uint8Array): string {
return bytesToHex(sha256(typeof value === 'string' ? utf8ToBytes(value) : value))
}
export async function decryptXuqmFile<T extends object = DecryptedConfig>(
content: string,
): Promise<T> {
const { magic, salt, iv, ciphertext } = parseEncryptedFile(content)
const key = await pbkdf2Async(sha256, PASSPHRASES[magic], salt, {
c: PBKDF2_ITERATIONS,
dkLen: AES_KEY_BYTES,
})
let plaintext: Uint8Array
try {
plaintext = gcm(key, iv).decrypt(ciphertext)
} catch {
throw new Error('[XuqmSDK] Config decryption failed')
}
try {
return JSON.parse(utf8BytesToString(plaintext)) as T
} catch {
throw new Error('[XuqmSDK] Config content is not valid JSON')
}
}
export function decryptConfigFile(content: string): Promise<DecryptedConfig> {
return decryptXuqmFile<DecryptedConfig>(content)
}

查看文件

@ -0,0 +1,20 @@
export type XuqmErrorCode =
| 'XUQM_NOT_READY'
| 'XUQM_NOT_LOGGED_IN'
| 'XUQM_CONFIG_INVALID'
| 'XUQM_CONFIG_EXPIRED'
| 'XUQM_CONFIG_REVOKED'
| 'XUQM_SERVICE_DISABLED'
/** SDK 公共结构化错误。宿主可以按 code 决定提示方式,无需解析错误文案。 */
export class XuqmError extends Error {
readonly name = 'XuqmError'
constructor(
public readonly code: XuqmErrorCode,
message: string,
public readonly cause?: unknown,
) {
super(message)
}
}

查看文件

@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
import { getOptionalConfig, getUserId } from './config'
import { hmacSha256Base64Url } from './crypto'
import { safeRequestPath } from './api/diagnostics'
const TOKEN_KEY = '@xuqm:token'
let _baseUrl = DEFAULT_TENANT_PLATFORM_URL
@ -44,6 +45,10 @@ export async function apiRequest<T>(
body?: unknown
params?: Record<string, string>
skipAuth?: boolean
/** 调用方取消信号;版本检查等可在页面退出时立即停止。 */
signal?: AbortSignal
/** 默认 30 秒,必须为正数;防止网络异常永久阻塞扩展入口。 */
timeoutMs?: number
} = {},
): Promise<T> {
let url = path.startsWith('http://') || path.startsWith('https://') ? path : `${_baseUrl}${path}`
@ -79,25 +84,44 @@ export async function apiRequest<T>(
if (_debugHttp) {
console.debug('[XuqmSDK][HTTP] request', {
method: options.method ?? 'GET',
url,
path: safeRequestPath(url),
hasBody: Boolean(options.body),
})
}
const res = await fetch(url, {
method: options.method ?? 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
})
const timeoutMs = options.timeoutMs ?? 30_000
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
throw new Error('[XuqmSDK][HTTP] timeoutMs must be a positive number')
}
const controller = new AbortController()
const abortFromCaller = () => controller.abort(options.signal?.reason)
if (options.signal?.aborted) abortFromCaller()
else options.signal?.addEventListener('abort', abortFromCaller, { once: true })
const timeout = setTimeout(() => {
controller.abort(new Error(`[XuqmSDK][HTTP] Request timed out after ${timeoutMs}ms`))
}, timeoutMs)
let res: Response
try {
res = await fetch(url, {
method: options.method ?? 'GET',
headers,
body: options.body ? JSON.stringify(options.body) : undefined,
signal: controller.signal,
})
} finally {
clearTimeout(timeout)
options.signal?.removeEventListener('abort', abortFromCaller)
}
if (!res.ok) {
const err = await res.json().catch(() => ({ message: res.statusText }))
if (_debugHttp) {
console.debug('[XuqmSDK][HTTP] response', {
url,
path: safeRequestPath(url),
status: res.status,
ok: false,
message: (err as { message?: string }).message ?? res.statusText,
})
}
throw new Error((err as { message?: string }).message ?? `HTTP ${res.status}`)
@ -106,7 +130,7 @@ export async function apiRequest<T>(
const json = await res.json()
if (_debugHttp) {
console.debug('[XuqmSDK][HTTP] response', {
url,
path: safeRequestPath(url),
status: res.status,
ok: true,
})

查看文件

@ -1,10 +1,27 @@
export { XuqmSDK } from './sdk'
export type { XuqmInitOptions, XuqmConfig, XuqmLoginOptions, XuqmUserInfo } from './config'
export { getConfig, isInitialized, getUserId, getUserInfo } from './config'
export type {
XuqmConfig,
XuqmInitializationSnapshot,
XuqmInitializationState,
XuqmLoginOptions,
XuqmUserInfo,
} from './config'
export {
getConfig,
getInitializationSnapshot,
hasPrivacyConsent,
isInitialized,
getUserId,
getUserInfo,
} from './config'
export { XuqmError } from './errors'
export type { XuqmErrorCode } from './errors'
export { retryWithBackoff } from './retry'
export type { RetryOptions } from './retry'
export { awaitInitialization } from './sdk'
export { apiRequest, configureHttp } from './http'
export { decryptConfigFile, decryptXuqmFile, hmacSha256Base64Url, sha256Hex } from './crypto'
export type { DecryptedConfig, XuqmEncryptedFileMagic } from './crypto'
export { bytesToBase64, hmacSha256Base64Url, sha256Hex } from './crypto'
export type { DecryptedConfig } from './crypto'
export { DEFAULT_TENANT_PLATFORM_URL, DEFAULT_IM_WS_URL } from './constants'
export { getDeviceId, getDeviceInfo, detectPushVendor } from './device'
export type { DeviceInfo, PushVendor } from './device'
@ -46,30 +63,6 @@ export {
satisfiesVersion,
} from './version'
export { ScaledImage } from './components/ScaledImage'
export {
XWebViewControl,
getXWebViewConfig,
openXWebView,
setXWebViewController,
setXWebViewNavigationRef,
setXWebViewScanQRCodeHandler,
processJSBridgeMessage,
} from './xwebview/XWebViewBridge'
export type { XWebViewNavigationRef, ScanQRCodeHandler } from './xwebview/XWebViewBridge'
export type {
XWebViewClickMenu,
XWebViewConfig,
XWebViewControllerAPI,
XWebViewNavigationState,
XWebViewDownloadDecision,
XWebViewDownloadDestination,
XWebViewDownloadProgress,
XWebViewDownloadRequest,
XWebViewDownloadResult,
XWebViewMessageEvent,
XWebViewPermissionRequest,
} from './xwebview/types'
// api/ 子模块
export {
RequestError,
@ -80,6 +73,7 @@ export {
ValidationError,
} from './api'
export type { RequestOptions as UseRequestOptions, ApiMethod } from './api'
export type { SafeApiDiagnostic, SafeApiErrorReport } from './api'
export type { PageOptions } from './api'
export type { AxiosRequestConfig } from 'axios'

查看文件

@ -6,5 +6,11 @@
*/
import './autoInit'
export { _registerInitializationHandler, _registerUserInfoHandler } from './config'
export { _registerBundleIdentityProvider, _resolveBundleIdentity } from './bundleIdentity'
export {
_registerInitializationHandler,
_registerPrivacyConsentHandler,
_registerUserInfoHandler,
} from './config'
export type { XuqmBundleIdentity, XuqmBundleIdentityProvider } from './bundleIdentity'
export type { XuqmInitializationHandler } from './config'

查看文件

@ -0,0 +1,68 @@
import { ed25519 } from '@noble/curves/ed25519.js'
import { utf8ToBytes } from '@noble/hashes/utils.js'
import { decodeBase64 } from '../security/base64.js'
import { canonicalJson } from '../security/canonical-json.js'
const TRUSTED_SIGNING_KEYS = require('../security/trusted-signing-keys.json') as Readonly<
Record<string, string>
>
export interface SignedPluginManifest {
appKey: string
packageName: string
platform: 'ANDROID' | 'IOS'
moduleId: string
type: 'common' | 'app' | 'buz'
version: string
appVersionRange: string
builtAgainstNativeBaselineId: string
buildId: string
bundleFormat: 'hermes-bytecode' | 'javascript'
commonVersionRange?: string
minNativeApiLevel: number
bundleSha256: string
archiveSha256: string
}
export class PluginManifestSignatureError extends Error {
readonly name = 'PluginManifestSignatureError'
constructor(
message: string,
public readonly code: 'UNKNOWN_SIGNING_KEY' | 'INVALID_SIGNATURE',
) {
super(message)
}
}
/** @internal 插件候选必须在生成计划和下载前通过同一可信密钥集合验签。 */
export function verifySignedPluginManifest(
manifest: SignedPluginManifest,
keyId: string,
signature: string,
trustedKeys: Readonly<Record<string, string>> = TRUSTED_SIGNING_KEYS,
): void {
const publicKey = trustedKeys[keyId]
if (!publicKey) {
throw new PluginManifestSignatureError(
`[XuqmSecurity] Untrusted plugin signing key: ${keyId}`,
'UNKNOWN_SIGNING_KEY',
)
}
let valid = false
try {
valid = ed25519.verify(
decodeBase64(signature),
utf8ToBytes(canonicalJson(manifest)),
decodeBase64(publicKey),
)
} catch {
valid = false
}
if (!valid) {
throw new PluginManifestSignatureError(
`[XuqmSecurity] Invalid plugin manifest signature: ${manifest.moduleId}@${manifest.version}`,
'INVALID_SIGNATURE',
)
}
}

查看文件

@ -0,0 +1,55 @@
import { z } from 'zod'
import type { XuqmRemoteConfig } from './config'
const flatRemoteConfigSchema = z.object({
apiUrl: z.string().optional(),
imApiUrl: z.string().optional(),
imWsUrl: z.string().optional(),
fileServiceUrl: z.string().optional(),
imEnabled: z.boolean().optional(),
pushEnabled: z.boolean().optional(),
bugCollectApiUrl: z.string().optional(),
bugCollectEnabled: z.boolean().optional(),
updateEnabled: z.boolean().optional(),
updateRequiresLogin: z.boolean().optional(),
})
const remoteConfigResponseSchema = flatRemoteConfigSchema
.extend({
allowAnonymousUpdateCheck: z.boolean().optional(),
features: z
.object({
im: z.boolean().optional(),
push: z.boolean().optional(),
bugCollect: z.boolean().optional(),
update: z.boolean().optional(),
})
.optional(),
})
.loose()
/** 平台远程配置的唯一归一化入口;features 字段优先于历史 flat 字段。 */
export function normalizeRemoteConfig(value: unknown): XuqmRemoteConfig {
const raw = remoteConfigResponseSchema.parse(value)
const features = raw.features ?? {}
return {
apiUrl: raw.apiUrl,
imApiUrl: raw.imApiUrl,
imWsUrl: raw.imWsUrl,
fileServiceUrl: raw.fileServiceUrl,
imEnabled: features.im ?? raw.imEnabled,
pushEnabled: features.push ?? raw.pushEnabled,
bugCollectApiUrl: raw.bugCollectApiUrl,
bugCollectEnabled: features.bugCollect ?? raw.bugCollectEnabled,
updateEnabled: features.update ?? raw.updateEnabled,
updateRequiresLogin:
raw.updateRequiresLogin ??
(raw.allowAnonymousUpdateCheck === undefined ? undefined : !raw.allowAnonymousUpdateCheck),
}
}
/** 旧缓存允许缺少 updateEnabled,最终由 XuqmConfig 按安全默认 false 收敛。 */
export function parseCachedRemoteConfig(value: unknown): XuqmRemoteConfig | null {
const parsed = flatRemoteConfigSchema.safeParse(value)
return parsed.success ? parsed.data : null
}

查看文件

@ -0,0 +1,57 @@
export interface RetryOptions {
maxAttempts?: number
baseDelayMs?: number
maxDelayMs?: number
jitterRatio?: number
signal?: AbortSignal
shouldRetry?: (error: unknown) => boolean
}
function abortError(): Error {
const error = new Error('Operation aborted')
error.name = 'AbortError'
return error
}
function wait(delayMs: number, signal?: AbortSignal): Promise<void> {
if (signal?.aborted) return Promise.reject(abortError())
return new Promise((resolve, reject) => {
const timer = setTimeout(resolve, delayMs)
signal?.addEventListener(
'abort',
() => {
clearTimeout(timer)
reject(abortError())
},
{ once: true },
)
})
}
/** 后台网络任务统一使用三次指数退避并加入抖动,禁止各扩展自行无限重试。 */
export async function retryWithBackoff<T>(
action: (attempt: number) => Promise<T>,
options: RetryOptions = {},
): Promise<T> {
const {
maxAttempts = 3,
baseDelayMs = 500,
maxDelayMs = 8_000,
jitterRatio = 0.2,
signal,
shouldRetry = () => true,
} = options
let lastError: unknown
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
return await action(attempt)
} catch (error) {
lastError = error
if (attempt >= maxAttempts || !shouldRetry(error)) throw error
const base = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1))
const jitter = base * jitterRatio * (Math.random() * 2 - 1)
await wait(Math.max(0, Math.round(base + jitter)), signal)
}
}
throw lastError
}

查看文件

@ -1,33 +1,110 @@
import AsyncStorage from '@react-native-async-storage/async-storage'
import {
_notifyInitialized,
_setInitializationSnapshot,
_setPrivacyConsent,
_setUserInfo,
getInitializationSnapshot,
getUserId as getCommonUserId,
getUserInfo as getCommonUserInfo,
initConfigFromRemote,
isInitialized,
type XuqmInitOptions,
type InternalInitOptions,
type XuqmLoginOptions,
type XuqmRemoteConfig,
type XuqmUserInfo,
} from './config'
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
import { sha256Hex } from './crypto'
import { XuqmError } from './errors'
import { _clearAccessToken, _saveAccessToken, configureHttp } from './http'
import { decryptConfigFile } from './crypto'
import { retryWithBackoff } from './retry'
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from './secureCache'
import type { DecryptedConfig } from './crypto'
import { normalizeRemoteConfig, parseCachedRemoteConfig } from './remoteConfig'
let _initPromise: Promise<void> | null = null
let _configFileInitPromise: Promise<void> | null = null
let _configFileRequest: {
encryptedContent: string
options?: { debug?: boolean }
let _resolvedConfigInitPromise: Promise<void> | null = null
let _resolvedConfigRequest: {
config: DecryptedConfig
options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' }
} | null = null
let _initializationKey: string | null = null
let _sessionTransition: Promise<void> = Promise.resolve()
let _activeSessionKey: string | null = null
function initializationKey(options: XuqmInitOptions): string {
return `${options.appKey.trim()}\n${(options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')}`
const REMOTE_CONFIG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000
const REMOTE_CONFIG_CACHE_PREFIX = '@xuqm_common:remote_config:v1:'
interface CachedRemoteConfig {
cachedAt: number
remote: XuqmRemoteConfig
}
function startInitialization(options: XuqmInitOptions, signingKey?: string): Promise<void> {
function runtimePlatform(): 'ANDROID' | 'IOS' {
try {
// 延迟加载避免 common 的纯 Node 工具与测试被 React Native 的 Flow 源码绑定。
// Metro 仍能静态发现该依赖,移动端运行时以 React Native Platform 为唯一事实。
const reactNative = require('react-native') as {
Platform?: { OS?: string }
}
return reactNative.Platform?.OS === 'ios' ? 'IOS' : 'ANDROID'
} catch {
return 'ANDROID'
}
}
function initializationKey(options: InternalInitOptions): string {
return `${options.appKey.trim()}\n${(options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')}\n${options.packageName ?? ''}`
}
function storageKey(options: InternalInitOptions): string {
return `${REMOTE_CONFIG_CACHE_PREFIX}${sha256Hex(initializationKey(options))}`
}
async function readCachedRemoteConfig(
options: InternalInitOptions,
signingKey?: string,
): Promise<CachedRemoteConfig | null> {
if (!signingKey) return null
try {
const encrypted = await AsyncStorage.getItem(storageKey(options))
if (!encrypted) return null
const raw = openLocalCache(encrypted, signingKey, initializationKey(options))
const cached = JSON.parse(raw) as CachedRemoteConfig
const parsedRemote = parseCachedRemoteConfig(cached?.remote)
if (
!cached ||
!isCacheRecordFresh(cached.cachedAt, Date.now(), REMOTE_CONFIG_CACHE_TTL_MS) ||
!parsedRemote
) {
await AsyncStorage.removeItem(storageKey(options))
return null
}
return { cachedAt: cached.cachedAt, remote: parsedRemote }
} catch {
await AsyncStorage.removeItem(storageKey(options)).catch(() => undefined)
return null
}
}
async function writeCachedRemoteConfig(
options: InternalInitOptions,
remote: CachedRemoteConfig['remote'],
signingKey?: string,
): Promise<void> {
if (!signingKey) return
try {
const encrypted = sealLocalCache(
JSON.stringify({ cachedAt: Date.now(), remote } satisfies CachedRemoteConfig),
signingKey,
initializationKey(options),
)
await AsyncStorage.setItem(storageKey(options), encrypted)
} catch {
// 安全随机数或沙箱存储不可用时跳过缓存,不能降级为明文,也不能阻断初始化。
}
}
function startInitialization(options: InternalInitOptions, signingKey?: string): Promise<void> {
const nextKey = initializationKey(options)
if (isInitialized()) {
if (_initializationKey !== nextKey) {
@ -46,36 +123,73 @@ function startInitialization(options: XuqmInitOptions, signingKey?: string): Pro
_initializationKey = nextKey
_initPromise = (async () => {
const baseUrl = options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL
const configUrl = `${baseUrl.replace(/\/$/, '')}/api/sdk/config?appKey=${encodeURIComponent(options.appKey)}`
const res = await fetch(configUrl)
if (!res.ok) {
throw new Error(`[XuqmSDK] Platform config request failed: ${res.status}`)
_setInitializationSnapshot({ state: 'initializing' })
const platformUrl = (options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')
const configParams = new URLSearchParams({
appKey: options.appKey,
platform: runtimePlatform(),
})
if (options.packageName) configParams.set('packageName', options.packageName)
const configUrl = `${platformUrl}/api/sdk/config?${configParams.toString()}`
let remote: CachedRemoteConfig['remote']
let source: 'remote' | 'cache' = 'remote'
try {
const res = await retryWithBackoff(
async () => {
const response = await fetch(configUrl)
if (response.ok) return response
if (response.status >= 500) {
throw new Error(`[XuqmSDK] Platform config request failed: ${response.status}`)
}
throw new XuqmError(
'XUQM_NOT_READY',
`[XuqmSDK] Platform config request failed: ${response.status}`,
)
},
{
maxAttempts: 3,
shouldRetry: error => !(error instanceof XuqmError),
},
)
const json = (await res.json()) as Record<string, unknown>
remote = normalizeRemoteConfig(json.data ?? json)
await writeCachedRemoteConfig(options, remote, signingKey)
} catch (error) {
const cached = await readCachedRemoteConfig(options, signingKey)
if (!cached) {
throw new XuqmError(
'XUQM_NOT_READY',
'[XuqmSDK] Tenant configuration is unavailable and no valid cache exists.',
error,
)
}
remote = cached.remote
source = 'cache'
}
const json = (await res.json()) as Record<string, unknown>
const remote = (json.data ?? json) as Record<string, unknown>
initConfigFromRemote(
options,
{
apiUrl: remote.apiUrl as string | undefined,
imWsUrl: remote.imWsUrl as string | undefined,
fileServiceUrl: remote.fileServiceUrl as string | undefined,
imEnabled: remote.imEnabled as boolean | undefined,
pushEnabled: remote.pushEnabled as boolean | undefined,
bugCollectApiUrl: remote.bugCollectApiUrl as string | undefined,
bugCollectEnabled: remote.bugCollectEnabled as boolean | undefined,
},
signingKey,
)
initConfigFromRemote({ ...options, platformUrl, configSource: source }, remote, signingKey)
// Xuqm 平台接口始终回到租户平台。remote.apiUrl 是业务扩展服务地址,
// 二者职责不同;混用会把版本检查错误地发送到 App 自身的业务服务。
configureHttp({
baseUrl: (remote.apiUrl as string | undefined) ?? baseUrl,
baseUrl: platformUrl,
debug: options.debug,
})
await _notifyInitialized()
_setInitializationSnapshot({
state: source === 'remote' ? 'ready' : 'degraded',
source,
})
})().catch(error => {
_initPromise = null
_initializationKey = null
throw error
const structured =
error instanceof XuqmError
? error
: new XuqmError('XUQM_NOT_READY', '[XuqmSDK] Initialization failed.', error)
_setInitializationSnapshot({
state: 'failed',
error: { code: structured.code, message: structured.message },
})
throw structured
})
return _initPromise
}
@ -87,23 +201,6 @@ function enqueueSessionTransition(action: () => Promise<void>): Promise<void> {
}
export const XuqmSDK = {
/**
* B
*
* appKey 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
*/
@ -111,6 +208,17 @@ export const XuqmSDK = {
await awaitInitialization()
},
getInitializationState() {
return getInitializationSnapshot()
},
/**
* 宿SDK
*/
async setPrivacyConsent(consented: boolean): Promise<void> {
await _setPrivacyConsent(consented)
},
getUserId(): string | null {
return getCommonUserId()
},
@ -152,38 +260,47 @@ export const XuqmSDK = {
export async function awaitInitialization(): Promise<void> {
if (isInitialized()) return
const pendingInitialization =
_configFileInitPromise ??
_resolvedConfigInitPromise ??
_initPromise ??
(_configFileRequest
? _initializeFromConfigFile(_configFileRequest.encryptedContent, _configFileRequest.options)
(_resolvedConfigRequest
? _initializeFromResolvedConfig(_resolvedConfigRequest.config, _resolvedConfigRequest.options)
: null)
if (!pendingInitialization) {
throw new Error(
'[XuqmSDK] Automatic initialization did not start. Place config.xuqmconfig in src/assets/config and wrap Metro with withXuqmConfig(), or call XuqmSDK.initialize().',
throw new XuqmError(
'XUQM_NOT_READY',
'[XuqmSDK] Automatic initialization did not start. Place exactly one .xuqmconfig file in src/assets/config and wrap Metro with withXuqmConfig().',
)
}
await pendingInitialization
}
/** Internal entry used only by the Metro auto-init module. */
export function _initializeFromConfigFile(
encryptedContent: string,
options?: { debug?: boolean },
/** Metro 自动初始化专用入口;配置已在可信构建进程中完成解密和格式校验。 */
export function _initializeFromResolvedConfig(
config: DecryptedConfig,
options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' },
): Promise<void> {
_configFileRequest = { encryptedContent, options }
if (_configFileInitPromise) return _configFileInitPromise
_resolvedConfigRequest = { config, options }
if (_resolvedConfigInitPromise) return _resolvedConfigInitPromise
_configFileInitPromise = (async () => {
const file = await decryptConfigFile(encryptedContent)
const platformUrl = file.serverUrl ?? file.baseUrl ?? undefined
_resolvedConfigInitPromise = (async () => {
const platformUrl = config.serverUrl
await startInitialization(
{ appKey: file.appKey, platformUrl, debug: options?.debug },
file.signingKey,
{
appKey: config.appKey,
platformUrl,
debug: options?.debug,
bugCollectMode: options?.bugCollectMode,
packageName:
runtimePlatform() === 'IOS'
? (config.iosBundleId ?? config.packageName)
: config.packageName,
},
config.signingKey,
)
})().catch(error => {
_configFileInitPromise = null
_resolvedConfigInitPromise = null
throw error
})
return _configFileInitPromise
return _resolvedConfigInitPromise
}

查看文件

@ -0,0 +1,60 @@
import { gcm } from '@noble/ciphers/aes.js'
import { sha256 } from '@noble/hashes/sha2.js'
import { utf8ToBytes } from '@noble/hashes/utils.js'
import { decodeBase64, encodeBase64 } from '../security/base64.js'
const CACHE_FORMAT = 'XUQM-CACHE-V1'
const NONCE_BYTES = 12
function bytesToBase64Url(value: Uint8Array): string {
return encodeBase64(value, { urlSafe: true, padding: false })
}
function cacheKey(secret: string, context: string): Uint8Array {
return sha256(utf8ToBytes(`xuqm-lkg-cache\0${context}\0${secret}`))
}
function utf8BytesToString(value: Uint8Array): string {
let encoded = ''
for (const byte of value) encoded += `%${byte.toString(16).padStart(2, '0')}`
return decodeURIComponent(encoded)
}
function randomNonce(): Uint8Array {
const nonce = new Uint8Array(NONCE_BYTES)
const cryptoApi = globalThis.crypto
if (!cryptoApi?.getRandomValues) {
throw new Error('[XuqmSDK] Secure random source is unavailable; LKG cache was not written.')
}
cryptoApi.getRandomValues(nonce)
return nonce
}
/**
* signingKey
* 线 Keystore
*/
export function sealLocalCache(plaintext: string, secret: string, context: string): string {
const nonce = randomNonce()
const ciphertext = gcm(cacheKey(secret, context), nonce, utf8ToBytes(context)).encrypt(
utf8ToBytes(plaintext),
)
return `${CACHE_FORMAT}.${bytesToBase64Url(nonce)}.${bytesToBase64Url(ciphertext)}`
}
export function openLocalCache(payload: string, secret: string, context: string): string {
const parts = payload.split('.')
if (parts.length !== 3 || parts[0] !== CACHE_FORMAT) {
throw new Error('[XuqmSDK] LKG cache format is invalid.')
}
const nonce = decodeBase64(parts[1])
if (nonce.length !== NONCE_BYTES) throw new Error('[XuqmSDK] LKG cache nonce is invalid.')
const plaintext = gcm(cacheKey(secret, context), nonce, utf8ToBytes(context)).decrypt(
decodeBase64(parts[2]),
)
return utf8BytesToString(plaintext)
}
export function isCacheRecordFresh(cachedAt: number, now: number, ttlMs: number): boolean {
return Number.isFinite(cachedAt) && cachedAt <= now && ttlMs > 0 && now - cachedAt <= ttlMs
}

查看文件

@ -1,222 +0,0 @@
import { Alert, Platform, ToastAndroid } from 'react-native'
import { getDeviceInfo } from '../device'
import { getUserInfo } from '../config'
import { _getAccessToken } from '../http'
import type { XWebViewConfig, XWebViewControllerAPI } from './types'
export type { XWebViewControllerAPI } from './types'
// ─── Navigation ref (宿主注入) ─────────────────────────────────────────────────
export type XWebViewNavigationRef = {
navigate: (route: string, params?: Record<string, unknown>) => void
goBack: () => void
}
let _navigationRef: XWebViewNavigationRef | null = null
/**
* 宿 App
* JSBridge `xuqm.openNativePage` / `xuqm.closeWebView` 使
*/
export function setXWebViewNavigationRef(ref: XWebViewNavigationRef | null): void {
_navigationRef = ref
}
// ─── ScanQRCode handler (宿主注入) ────────────────────────────────────────────
export type ScanQRCodeHandler = () => Promise<string>
let _scanQRCodeHandler: ScanQRCodeHandler | null = null
/**
* 宿 App
* JSBridge `xuqm.scanQRCode` 使
* handler
*/
export function setXWebViewScanQRCodeHandler(handler: ScanQRCodeHandler | null): void {
_scanQRCodeHandler = handler
}
// ─── JSBridge response helpers ─────────────────────────────────────────────────
type BridgeResponse = { code: 0; data: unknown } | { code: -1; message: string }
function ok(data: unknown): BridgeResponse {
return { code: 0, data }
}
function fail(message: string): BridgeResponse {
return { code: -1, message }
}
function showToast(message: string): void {
if (Platform.OS === 'android') {
ToastAndroid.show(message, ToastAndroid.SHORT)
} else {
Alert.alert('', message)
}
}
// ─── JSBridge message processor ───────────────────────────────────────────────
/**
* WebView JSBridge action `xuqm.`
*
* WebView `onMessage` bridge
* `true` userOnMessage `false`
*
* @param rawData WebViewMessageEvent.nativeEvent.data
* @param postMessageToWeb WebView injectJavaScript
*/
export async function processJSBridgeMessage(
rawData: string,
postMessageToWeb: (js: string) => void,
): Promise<boolean> {
let parsed: {
__xuqm?: string
id?: string | number
params?: Record<string, unknown>
}
try {
parsed = JSON.parse(rawData)
} catch {
return false
}
const action = parsed.__xuqm
if (typeof action !== 'string' || !action.startsWith('xuqm.')) {
return false
}
const id = parsed.id
const params = parsed.params ?? {}
function respond(result: BridgeResponse): void {
const js = `
(function(){
var e = new CustomEvent('xuqm_bridge_response_${String(id ?? '')}', { detail: ${JSON.stringify(result)} });
window.dispatchEvent(e);
if (window.__xuqmBridgeCallback) {
window.__xuqmBridgeCallback(${JSON.stringify(String(id ?? ''))}, ${JSON.stringify(result)});
}
})();
true;`.trim()
postMessageToWeb(js)
}
try {
switch (action) {
case 'xuqm.getDeviceInfo': {
const info = await getDeviceInfo()
respond(ok(info))
break
}
case 'xuqm.getToken': {
const token = await _getAccessToken()
respond(ok(token))
break
}
case 'xuqm.getUserInfo': {
const info = getUserInfo()
respond(ok(info))
break
}
case 'xuqm.openNativePage': {
const route = params.route
if (typeof route !== 'string') {
respond(fail('params.route is required'))
break
}
const navParams = params.params as Record<string, unknown> | undefined
if (!_navigationRef) {
respond(fail('navigation not available — call setXWebViewNavigationRef() in host app'))
break
}
_navigationRef.navigate(route, navParams)
respond(ok(null))
break
}
case 'xuqm.closeWebView': {
if (!_navigationRef) {
respond(fail('navigation not available — call setXWebViewNavigationRef() in host app'))
break
}
_navigationRef.goBack()
respond(ok(null))
break
}
case 'xuqm.showToast': {
const message = params.message
if (typeof message !== 'string') {
respond(fail('params.message is required'))
break
}
showToast(message)
respond(ok(null))
break
}
case 'xuqm.scanQRCode': {
if (!_scanQRCodeHandler) {
respond(
fail('scanQRCode not available — call setXWebViewScanQRCodeHandler() in host app'),
)
break
}
const result = await _scanQRCodeHandler()
respond(ok(result))
break
}
default:
// Unknown xuqm.* action — not handled by built-in bridge
return false
}
} catch (err) {
respond(fail(err instanceof Error ? err.message : String(err)))
}
return true
}
// ─── Config + Controller ───────────────────────────────────────────────────────
let _config: XWebViewConfig = {}
let _controller: XWebViewControllerAPI | null = null
export function openXWebView(navigate: (pluginId: string) => void, config: XWebViewConfig) {
_config = { ...config }
navigate('xwebview')
}
export function getXWebViewConfig(): XWebViewConfig {
return _config
}
export function setXWebViewController(controller: XWebViewControllerAPI | null) {
_controller = controller
}
export const XWebViewControl: XWebViewControllerAPI = {
refresh: () => _controller?.refresh(),
close: () => _controller?.close(),
goBack: () => _controller?.goBack(),
goForward: () => _controller?.goForward(),
copyUrl: () => _controller?.copyUrl(),
postMessageToWeb: js => _controller?.postMessageToWeb(js),
getTitle: () => _controller?.getTitle() ?? '',
getNavigationState: () =>
_controller?.getNavigationState() ?? {
canGoBack: false,
canGoForward: false,
title: '',
url: '',
},
}

查看文件

@ -1,99 +0,0 @@
import type React from 'react'
export type XWebViewClickMenu = {
view?: React.ReactNode
onClick: () => void
}
export type XWebViewMessageEvent = {
nativeEvent: { data: string }
}
export type XWebViewPermissionRequest = {
origin: string
resources: string[]
grant: (resources?: string[]) => void
deny: () => void
}
export type XWebViewDownloadRequest = {
url: string
suggestedFilename: string
mimeType?: string
fileSize?: number
}
export type XWebViewDownloadDecision = {
allowed: boolean
filename?: string
savePath?: string
}
export type XWebViewDownloadProgress = {
url: string
filename: string
received: number
total: number
percentage: number
}
export type XWebViewDownloadResult = {
url: string
filename: string
filePath: string
fileSize: number
}
export type XWebViewNavigationState = {
canGoBack: boolean
canGoForward: boolean
title: string
url: string
}
export type XWebViewControllerAPI = {
refresh: () => void
close: () => void
goBack: () => void
goForward: () => void
copyUrl: () => void
postMessageToWeb: (jsString: string) => void
getTitle: () => string
getNavigationState: () => XWebViewNavigationState
}
export type XWebViewDownloadDestination = 'sandbox' | 'publicDownloads'
export type XWebViewConfig = {
showTopBar?: boolean
showStatusBar?: boolean
doubleBackExit?: boolean
title?: string
showTitle?: boolean
autoTitle?: boolean
showMenu?: boolean
openForBrowser?: boolean
clickMenu?: XWebViewClickMenu
url?: string
/** 首次加载 URL 时携带的请求头,适用于需要会话鉴权的业务 H5。 */
headers?: Record<string, string>
content?: string
onMessage?: (event: XWebViewMessageEvent) => void
onNavigationStateChange?: (state: XWebViewNavigationState) => void
injectedJavaScript?: string
onPermissionRequest?: (request: XWebViewPermissionRequest) => void
autoDownload?: boolean
onDownloadStart?: (request: XWebViewDownloadRequest) => void
onDownloadProgress?: (progress: XWebViewDownloadProgress) => void
onDownloadComplete?: (result: XWebViewDownloadResult) => void
onDownloadError?: (url: string, error: string) => void
onDownloadDecide?: (
request: XWebViewDownloadRequest,
) => XWebViewDownloadDecision | Promise<XWebViewDownloadDecision>
downloadConflict?: 'rename' | 'overwrite'
/** 下载保存位置;与原生 Android SDK FileDownloadDestination 对齐。 */
downloadDestination?: XWebViewDownloadDestination
/** 非空时显示状态栏下载进度通知。 */
downloadNotificationTitle?: string
onClose?: () => void
}

查看文件

@ -2,7 +2,13 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import type { AxiosResponse } from 'axios'
import { createResponseDiagnostic, createValidationDiagnostic } from '../src/api/diagnostics'
import {
createResponseDiagnostic,
createSafeApiErrorReport,
createValidationDiagnostic,
safeRequestPath,
} from '../src/api/diagnostics'
import { RequestError } from '../src/api/errors'
function sensitiveResponse(): AxiosResponse<unknown> {
return {
@ -36,6 +42,14 @@ test('API diagnostics only retain non-sensitive request shape', () => {
assert.doesNotMatch(serialized, /secret|phone|token|authorization|example\.com/)
})
test('request paths remove hosts, query parameters and fragments', () => {
assert.equal(
safeRequestPath('https://api.example.com/am/example?phone=13800000000#secret'),
'/am/example',
)
assert.equal(safeRequestPath('/am/example?token=secret'), '/am/example')
})
test('validation diagnostics retain issue paths without response data', () => {
const diagnostic = createValidationDiagnostic(sensitiveResponse(), [
{
@ -50,3 +64,29 @@ test('validation diagnostics retain issue paths without response data', () => {
const serialized = JSON.stringify(diagnostic)
assert.doesNotMatch(serialized, /secret|13800000000|authorization|example\.com/)
})
test('global API error reports never expose RequestError causes', () => {
const response = sensitiveResponse()
const cause = {
issues: [
{
code: 'invalid_type',
expected: 'string',
message: 'Invalid input: expected string, received number',
path: ['data', 'userId'],
},
],
response,
}
const report = createSafeApiErrorReport(
new RequestError('server message may contain private data', 'ValidationError', cause),
)
assert.equal(report.requestType, 'ValidationError')
assert.equal(report.diagnostic?.type, 'ValidationError')
const serialized = JSON.stringify(report)
assert.doesNotMatch(
serialized,
/private|secret|13800000000|authorization|example\.com|server message/,
)
})

查看文件

@ -1,50 +1,15 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { decryptXuqmFile, hmacSha256Base64Url } from '../src/crypto'
const CONFIG_VECTOR =
'XUQM-CONFIG-V1.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.vhdKh3AhEdeftXCdXp9KusVySkrBNAzmdaA747uf1tVWGFnAsJeCLe2mHKk0dxxez8SRTknIbk1UuRibnXY0Gbot1fmkR-jSN2ssMJO0_zQ8dUu8'
import { hmacSha256Base64Url, sha256Hex } from '../src/crypto'
test('hmacSha256Base64Url matches the standard HMAC-SHA256 vector', () => {
test('sha256Hex uses the canonical lowercase digest', () => {
assert.equal(
hmacSha256Base64Url('key', 'The quick brown fox jumps over the lazy dog'),
'97yD9DBThCSxMpjmqm-xQ-9NWaFJRhdZl0edvC0aPNg',
sha256Hex('xuqm'),
'8d7c5b94129df39d6bec13e9fed29bb15fd1715f2e75a506436d9464bd38250c',
)
})
test('decryptXuqmFile remains compatible with existing PBKDF2 + AES-256-GCM files', async () => {
await assert.doesNotReject(async () => {
const result = await decryptXuqmFile(CONFIG_VECTOR)
assert.deepEqual(result, {
appKey: 'test-app',
appName: '兼容向量',
signingKey: 'secret',
})
})
})
test('decryptXuqmFile does not depend on a React Native TextDecoder polyfill', async () => {
const original = globalThis.TextDecoder
Object.defineProperty(globalThis, 'TextDecoder', {
configurable: true,
value: class BrokenTextDecoder {
decode(): string {
return '[object Uint8Array]'
}
},
})
try {
const result = await decryptXuqmFile(CONFIG_VECTOR)
assert.equal(result.appName, '兼容向量')
} finally {
Object.defineProperty(globalThis, 'TextDecoder', {
configurable: true,
value: original,
})
}
})
test('decryptXuqmFile rejects invalid authentication tags', async () => {
await assert.rejects(() => decryptXuqmFile(`${CONFIG_VECTOR.slice(0, -1)}A`), /decryption failed/)
test('hmacSha256Base64Url returns an unpadded URL-safe value', () => {
assert.match(hmacSha256Base64Url('secret', 'payload'), /^[A-Za-z0-9_-]+$/)
})

13
packages/common/tests/fixtures/README.md vendored 普通文件
查看文件

@ -0,0 +1,13 @@
# 测试夹具
`rn-release-signature-vector.generated.json` 由 Server 仓库的
`update-service/src/test/resources/rn-release-signature-vector.json` 生成。
**GENERATED / DO NOT EDIT**禁止手工修改。Server 是唯一规范源,更新时执行:
```bash
node scripts/sync-server-signature-vector.mjs --write
node scripts/sync-server-signature-vector.mjs --check
```
该文件只位于测试目录,不进入 `@xuqm/rn-common` 发布包。

查看文件

@ -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,6 @@
{
"keyId": "xuqm-rn-vector-2026-07",
"publicKeyRawBase64": "jkw/1R5aVg2NAwB7+xw2fR9i8phXi+2jt9l8QzuG8pU=",
"canonicalJson": "{\"appKey\":\"ak_vector\",\"appVersionRange\":\">=8.0.0 <9.0.0\",\"archiveSha256\":\"1111111111111111111111111111111111111111111111111111111111111111\",\"buildId\":\"build-42\",\"builtAgainstNativeBaselineId\":\"android-sha256:abc\",\"bundleFormat\":\"hermes-bytecode\",\"bundleSha256\":\"0000000000000000000000000000000000000000000000000000000000000000\",\"minNativeApiLevel\":2,\"moduleId\":\"common\",\"packageName\":\"com.xuqm.vector\",\"platform\":\"ANDROID\",\"type\":\"common\",\"version\":\"1.2.3\"}",
"signatureBase64Url": "p5GHekcL-wqM9yC7xNGRLMEwNVy7ECwVGwIdWkMJ9G85Pgr4XXca3kBXm3G-hLB0XZa76_tVmBNJhHkUuse6AQ"
}

查看文件

@ -0,0 +1,7 @@
{
"extends": "../../../../tsconfig.json",
"compilerOptions": {
"noEmit": true
},
"include": ["./public-api.ts"]
}

查看文件

@ -0,0 +1,26 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { apiRequest, configureHttp } from '../src/http'
test('apiRequest aborts a stalled request at the shared timeout boundary', async () => {
const originalFetch = globalThis.fetch
configureHttp({ baseUrl: 'https://platform.example.test' })
globalThis.fetch = async (_input, init) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener(
'abort',
() => reject(init.signal?.reason ?? new Error('aborted')),
{ once: true },
)
})
try {
await assert.rejects(
() => apiRequest('/api/v1/rn/release-set/check', { skipAuth: true, timeoutMs: 5 }),
/Request timed out after 5ms/,
)
} finally {
globalThis.fetch = originalFetch
}
})

查看文件

@ -15,7 +15,10 @@ test('all extensions observe the same initialized config exactly once', async ()
})
initConfigFromRemote(
{ appKey: 'shared-app' },
{
appKey: 'shared-app',
platformUrl: 'https://platform.example.test',
},
{
apiUrl: 'https://api.example.test',
imApiUrl: 'https://im.example.test',
@ -24,6 +27,8 @@ test('all extensions observe the same initialized config exactly once', async ()
await _notifyInitialized()
assert.equal(getConfig().apiUrl, 'https://api.example.test')
assert.equal(getConfig().platformUrl, 'https://platform.example.test')
assert.equal(getConfig().updateEnabled, false)
assert.deepEqual(observed, ['shared-app:https://api.example.test'])
unregister()
})

查看文件

@ -0,0 +1,44 @@
const assert = require('node:assert/strict')
const { execFileSync } = require('node:child_process')
const { createRequire } = require('node:module')
const fs = require('node:fs')
const os = require('node:os')
const path = require('node:path')
const test = require('node:test')
test('common-only host bundles the default auto-init module with real Metro', async () => {
const workspaceRoot = path.resolve(__dirname, '../../..')
const outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-common-only-'))
const output = path.join(outputDirectory, 'common-only.bundle.js')
try {
const reactNativeRequire = createRequire(
require.resolve('react-native/package.json', { paths: [workspaceRoot] }),
)
const metro = reactNativeRequire('metro')
const metroConfig = reactNativeRequire('metro-config')
const config = await metroConfig.getDefaultConfig(workspaceRoot)
config.watchFolders = [workspaceRoot]
config.resolver.extraNodeModules = {
'metro-runtime': path.dirname(reactNativeRequire.resolve('metro-runtime/package.json')),
}
await metro.runBuild(config, {
dev: false,
entry: 'packages/common/tests/fixtures/common-only.js',
minify: false,
out: output,
platform: 'android',
})
assert.equal(fs.statSync(output).size > 0, true)
} finally {
fs.rmSync(outputDirectory, { recursive: true, force: true })
}
})
test('public types expose no manual initialization entry', () => {
const workspaceRoot = path.resolve(__dirname, '../../..')
execFileSync(
path.join(workspaceRoot, 'node_modules/.bin/tsc'),
['-p', path.join(__dirname, 'fixtures/tsconfig.json')],
{ cwd: workspaceRoot, stdio: 'pipe' },
)
})

查看文件

@ -6,6 +6,9 @@ const test = require('node:test')
const { withXuqmConfig } = require('../metro')
const CONFIG_VECTOR =
'XUQM-CONFIG-V2.xuqm-config-dev-2026-07.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.MYRqE2BtQG0PvSnG3TLrr4PXd10C1cHhNA2iJLfy60XmQ5ixQBTCpn4e2g2YN0QQbZY0LVnou1rGONIzMrT7VidOMi_lHSfUf2BXnqlCIRm4yKcYCvALvrIF-Llv2xQWla0BTpKNq2Yuejpsw0oGTZGjNkvmlbBu8JJHAckHi-wP1sAgZuKVjjtJnjc7venWzaa75KwsqOYiNOkhIjWqTLNMG1siCMbX96d2A15KJYRUpwd-rBSpgjmOFLdH6DDaj2ZqCqQg6NRJprjsEorrCAnDjRjwoMKqHiIwHErfihw3v0AO19MJUzCeYzCzU2WfFw.Z-NMbyec47P4K_lfx3l7s3jZJVpd7ru8ySj2eBvXfteU4tVBoJ_3N7IRPMeiv1lUxMchsvNg8okxYpcBs4_2CQ'
test('withXuqmConfig schedules automatic initialization before the app entry', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
const configDir = path.join(projectRoot, 'src/assets/config')
@ -13,7 +16,7 @@ test('withXuqmConfig schedules automatic initialization before the app entry', (
try {
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, 'app.xuqmconfig'), 'XUQM-CONFIG-V1.test', 'utf8')
fs.writeFileSync(path.join(configDir, 'app.xuqmconfig'), CONFIG_VECTOR, 'utf8')
const config = withXuqmConfig({
projectRoot,
@ -29,11 +32,52 @@ test('withXuqmConfig schedules automatic initialization before the app entry', (
const virtualModule = config.resolver.resolveRequest(
{ resolveRequest: () => assert.fail('fallback resolver should not run') },
'@xuqm/autoinit-config',
'@xuqm/rn-common/auto-init-config',
'android',
)
assert.equal(virtualModule.type, 'sourceFile')
assert.match(fs.readFileSync(virtualModule.filePath, 'utf8'), /XUQM-CONFIG-V1\.test/)
const generatedSource = fs.readFileSync(virtualModule.filePath, 'utf8')
assert.match(generatedSource, /RESOLVED_CONFIG/)
assert.match(generatedSource, /"appKey":"test-app"/)
assert.equal(generatedSource.includes('XUQM-CONFIG-V2'), false)
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true })
}
})
test('withXuqmConfig rejects legacy V1 files with a migration instruction', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
const configDir = path.join(projectRoot, 'src/assets/config')
try {
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(
path.join(configDir, 'config.xuqmconfig'),
'XUQM-CONFIG-V1.invalid.invalid.invalid',
'utf8',
)
assert.throws(
() => withXuqmConfig({ projectRoot }),
/仅支持 XUQM-CONFIG-V2;请从租户平台重新下载配置文件/,
)
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true })
}
})
test('withXuqmConfig rejects a modified V2 signature before decrypting content', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
const configDir = path.join(projectRoot, 'src/assets/config')
try {
fs.mkdirSync(configDir, { recursive: true })
const parts = CONFIG_VECTOR.split('.')
parts[5] = `${parts[5][0] === 'A' ? 'B' : 'A'}${parts[5].slice(1)}`
const tampered = parts.join('.')
fs.writeFileSync(path.join(configDir, 'config.xuqmconfig'), tampered, 'utf8')
assert.throws(() => withXuqmConfig({ projectRoot }), /配置文件签名验证失败/)
assert.equal(
fs.existsSync(path.join(projectRoot, 'android/app/src/main/assets/config/config.xuqmconfig')),
false,
)
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true })
}
@ -54,3 +98,19 @@ test('withXuqmConfig leaves common-only hosts unchanged without a config file',
fs.rmSync(projectRoot, { recursive: true, force: true })
}
})
test('withXuqmConfig rejects ambiguous config directories instead of choosing a file', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
const configDir = path.join(projectRoot, 'src/assets/config')
try {
fs.mkdirSync(configDir, { recursive: true })
fs.writeFileSync(path.join(configDir, 'app.xuqmconfig'), CONFIG_VECTOR, 'utf8')
fs.writeFileSync(path.join(configDir, 'tenant.xuqmconfig'), CONFIG_VECTOR, 'utf8')
assert.throws(
() => withXuqmConfig({ projectRoot }),
/只能存在一个 \.xuqmconfig 文件,当前发现 2 个/,
)
} finally {
fs.rmSync(projectRoot, { recursive: true, force: true })
}
})

查看文件

@ -0,0 +1,74 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
import { decodeBase64 } from '../security/base64.js'
import {
PluginManifestSignatureError,
verifySignedPluginManifest,
type SignedPluginManifest,
} from '../src/internalSecurity'
import { canonicalJson } from '../security/canonical-json.js'
interface ServerSignatureVector {
keyId: string
publicKeyRawBase64: string
canonicalJson: string
signatureBase64Url: string
}
const vectorPath = new URL('./fixtures/rn-release-signature-vector.generated.json', import.meta.url)
const vector = JSON.parse(readFileSync(vectorPath, 'utf8')) as ServerSignatureVector
const manifest = JSON.parse(vector.canonicalJson) as SignedPluginManifest
const trustedKeys = { [vector.keyId]: vector.publicKeyRawBase64 }
test('accepts the Server-owned Ed25519 verification vector byte-for-byte', () => {
assert.deepEqual(
Buffer.from(canonicalJson(manifest), 'utf8'),
Buffer.from(vector.canonicalJson, 'utf8'),
)
assert.equal(decodeBase64(vector.publicKeyRawBase64).length, 32)
assert.equal(decodeBase64(vector.signatureBase64Url).length, 64)
assert.doesNotThrow(() =>
verifySignedPluginManifest(manifest, vector.keyId, vector.signatureBase64Url, trustedKeys),
)
})
test('canonical manifest omits null fields used only by non-common modules', () => {
assert.equal(
canonicalJson({ moduleId: 'common', commonVersionRange: null }),
'{"moduleId":"common"}',
)
})
test('rejects an unknown plugin signing key', () => {
assert.throws(
() => verifySignedPluginManifest(manifest, 'unknown', vector.signatureBase64Url, trustedKeys),
(error: unknown) =>
error instanceof PluginManifestSignatureError && error.code === 'UNKNOWN_SIGNING_KEY',
)
})
test('rejects any immutable manifest field tampering', () => {
assert.throws(
() =>
verifySignedPluginManifest(
{ ...manifest, bundleSha256: 'b'.repeat(64) },
vector.keyId,
vector.signatureBase64Url,
trustedKeys,
),
(error: unknown) =>
error instanceof PluginManifestSignatureError && error.code === 'INVALID_SIGNATURE',
)
assert.throws(
() =>
verifySignedPluginManifest(
{ ...manifest, archiveSha256: 'd'.repeat(64) },
vector.keyId,
vector.signatureBase64Url,
trustedKeys,
),
(error: unknown) =>
error instanceof PluginManifestSignatureError && error.code === 'INVALID_SIGNATURE',
)
})

查看文件

@ -0,0 +1,24 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { normalizeRemoteConfig, parseCachedRemoteConfig } from '../src/remoteConfig'
test('platform update feature is authoritative over the flat compatibility field', () => {
assert.equal(
normalizeRemoteConfig({ features: { update: false }, updateEnabled: true }).updateEnabled,
false,
)
assert.equal(normalizeRemoteConfig({ updateEnabled: true }).updateEnabled, true)
})
test('missing update feature stays absent so XuqmConfig can safely default it off', () => {
assert.equal(normalizeRemoteConfig({}).updateEnabled, undefined)
assert.deepEqual(parseCachedRemoteConfig({ apiUrl: 'https://api.example.test' }), {
apiUrl: 'https://api.example.test',
})
})
test('cached update availability is preserved exactly', () => {
assert.equal(parseCachedRemoteConfig({ updateEnabled: true })?.updateEnabled, true)
assert.equal(parseCachedRemoteConfig({ updateEnabled: false })?.updateEnabled, false)
})

查看文件

@ -0,0 +1,34 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { retryWithBackoff } from '../src/retry'
test('retryWithBackoff succeeds within the bounded three-attempt policy', async () => {
let attempts = 0
const value = await retryWithBackoff(
async () => {
attempts += 1
if (attempts < 3) throw new Error('temporary')
return 'ok'
},
{ baseDelayMs: 0, jitterRatio: 0 },
)
assert.equal(value, 'ok')
assert.equal(attempts, 3)
})
test('retryWithBackoff stops immediately for non-retryable errors', async () => {
let attempts = 0
await assert.rejects(
() =>
retryWithBackoff(
async () => {
attempts += 1
throw new Error('disabled')
},
{ baseDelayMs: 0, shouldRetry: () => false },
),
/disabled/,
)
assert.equal(attempts, 1)
})

查看文件

@ -1,19 +1,30 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { _initializeFromConfigFile, awaitInitialization } from '../src/sdk'
import { apiRequest } from '../src/http'
import { getConfig, getInitializationSnapshot } from '../src/config'
import { _initializeFromResolvedConfig, awaitInitialization, XuqmSDK } from '../src/sdk'
const CONFIG_VECTOR =
'XUQM-CONFIG-V1.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.vhdKh3AhEdeftXCdXp9KusVySkrBNAzmdaA747uf1tVWGFnAsJeCLe2mHKk0dxxez8SRTknIbk1UuRibnXY0Gbot1fmkR-jSN2ssMJO0_zQ8dUu8'
test('public SDK has no second manual initialization entry', () => {
assert.equal('initialize' in XuqmSDK, false)
})
test('awaitInitialization observes in-flight work and retries a transient config request failure', async () => {
test('awaitInitialization retries a transient config request without leaking failure to the host', async () => {
const originalFetch = globalThis.fetch
let requestCount = 0
globalThis.fetch = async () => {
const requestedUrls: string[] = []
globalThis.fetch = async input => {
requestCount += 1
requestedUrls.push(String(input))
if (requestCount === 1) {
throw new TypeError('Network request failed')
}
if (requestCount === 3) {
return new Response(JSON.stringify({ data: { available: true } }), {
headers: { 'content-type': 'application/json' },
status: 200,
})
}
return new Response(
JSON.stringify({
data: {
@ -28,15 +39,32 @@ test('awaitInitialization observes in-flight work and retries a transient config
}
try {
const autoInitialization = _initializeFromConfigFile(CONFIG_VECTOR, {
debug: true,
})
await assert.rejects(() => awaitInitialization(), /Network request failed/)
await assert.rejects(() => autoInitialization, /Network request failed/)
// 配置内容由 common 保存,扩展包再次等待时可以自行重试,无需宿主传递配置。
const autoInitialization = _initializeFromResolvedConfig(
{
appKey: 'test-app',
appName: '测试应用',
configId: '123e4567-e89b-42d3-a456-426614174000',
issuedAt: '2026-07-26T00:00:00Z',
revision: 1,
schemaVersion: 2,
serverUrl: 'https://dev.xuqinmin.com',
signingKey: 'secret',
},
{ debug: true },
)
await assert.doesNotReject(() => autoInitialization)
await assert.doesNotReject(() => awaitInitialization())
assert.equal(requestCount, 2)
assert.deepEqual(getInitializationSnapshot(), { state: 'ready', source: 'remote' })
assert.equal(getConfig().updateEnabled, false)
assert.equal(getConfig().updateRequiresLogin, true)
const result = await apiRequest<{ available: boolean }>('/api/v1/rn/release-set/check', {
skipAuth: true,
})
assert.deepEqual(result, { available: true })
assert.equal(requestedUrls[2], 'https://dev.xuqinmin.com/api/v1/rn/release-set/check')
assert.equal(requestedUrls[2]?.includes('api.example.test'), false)
} finally {
globalThis.fetch = originalFetch
}

查看文件

@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from '../src/secureCache'
test('LKG cache round-trips with context-bound authenticated encryption', () => {
const encrypted = sealLocalCache('{"enabled":true}', 'signed-secret', 'app|package|environment')
assert.equal(
openLocalCache(encrypted, 'signed-secret', 'app|package|environment'),
'{"enabled":true}',
)
assert.equal(encrypted.includes('enabled'), false)
})
test('LKG cache rejects tampering and a different package/environment context', () => {
const encrypted = sealLocalCache('payload', 'signed-secret', 'app|package|environment')
const parts = encrypted.split('.')
parts[2] = `${parts[2][0] === 'A' ? 'B' : 'A'}${parts[2].slice(1)}`
assert.throws(() => openLocalCache(parts.join('.'), 'signed-secret', 'app|package|environment'))
assert.throws(() => openLocalCache(encrypted, 'signed-secret', 'app|other|environment'))
})
test('LKG freshness rejects future timestamps and expired records', () => {
const now = 1_000_000
assert.equal(isCacheRecordFresh(now - 1_000, now, 2_000), true)
assert.equal(isCacheRecordFresh(now - 2_001, now, 2_000), false)
assert.equal(isCacheRecordFresh(now + 1, now, 2_000), false)
})

查看文件

@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { decodeBase64, encodeBase64 } from '../security/base64.js'
import { canonicalJson } from '../security/canonical-json.js'
test('strict Base64 decoder supports padded, unpadded and URL-safe inputs without atob', () => {
assert.deepEqual([...decodeBase64('TQ==')], [77])
assert.deepEqual([...decodeBase64('TWE=')], [77, 97])
assert.deepEqual([...decodeBase64('TWFu')], [77, 97, 110])
assert.deepEqual([...decodeBase64('AQIDBA==')], [1, 2, 3, 4])
assert.deepEqual([...decodeBase64('AQIDBA')], [1, 2, 3, 4])
assert.deepEqual([...decodeBase64('-_8=')], [251, 255])
})
test('strict Base64 decoder rejects invalid alphabets, lengths, padding and trailing bits', () => {
for (const value of [
'A',
'A+_-',
'AA=A',
'AA===',
'AAA==',
'TQ=',
'TWE==',
'TQ===',
'TQ==AA',
'TR==',
'TWF=',
'AA!A',
]) {
assert.throws(() => decodeBase64(value), /Base64/)
}
})
test('pure JS Base64 encoder emits canonical standard and URL-safe forms', () => {
assert.equal(encodeBase64(new Uint8Array()), '')
assert.equal(encodeBase64(Uint8Array.of(77)), 'TQ==')
assert.equal(encodeBase64(Uint8Array.of(77, 97)), 'TWE=')
assert.equal(encodeBase64(Uint8Array.of(77, 97, 110)), 'TWFu')
assert.equal(encodeBase64(Uint8Array.of(251, 255), { urlSafe: true }), '-_8=')
assert.equal(encodeBase64(Uint8Array.of(251, 255), { urlSafe: true, padding: false }), '-_8')
assert.throws(() => encodeBase64([1, 2, 3] as unknown as Uint8Array), /Uint8Array/)
})
test('Base64 codec round-trips chunk boundaries and matches the Node reference', () => {
for (const length of [0, 1, 2, 3, 4, 31, 32, 33, 255, 256, 257]) {
const input = Uint8Array.from({ length }, (_, index) => (index * 197 + 31) & 0xff)
const standard = encodeBase64(input)
const urlSafe = encodeBase64(input, { urlSafe: true, padding: false })
assert.equal(standard, Buffer.from(input).toString('base64'))
assert.equal(urlSafe, Buffer.from(input).toString('base64url'))
assert.deepEqual(decodeBase64(standard), input)
assert.deepEqual(decodeBase64(urlSafe), input)
}
})
test('canonical JSON rejects values that have no cross-runtime protocol representation', () => {
assert.throws(() => canonicalJson(Number.NaN), /finite numbers/)
assert.throws(() => canonicalJson(Number.POSITIVE_INFINITY), /finite numbers/)
assert.throws(() => canonicalJson(() => undefined), /does not support function/)
assert.throws(() => canonicalJson(Symbol('value')), /does not support symbol/)
assert.throws(() => canonicalJson([undefined]), /cannot contain holes or undefined/)
assert.throws(() => canonicalJson(new Array(1)), /cannot contain holes or undefined/)
assert.throws(() => canonicalJson(new Date(0)), /only supports plain objects/)
})

查看文件

@ -13,13 +13,39 @@ pnpm exec xuqm-rn init
pnpm run xuqm:doctor
```
`xuqm-rn init` 生成 schema v3 `xuqm.config.json` 并补充最少脚本。版本只有两条明确来源:
`xuqm-rn init` 生成 schema v3 `xuqm.modules.json` 并补充最少脚本。版本只有两条明确来源:
- 完整 App 版本来自宿主 `package.json.version`,医网信新包从 `8.0.0` 开始。
- 插件默认版本来自 `xuqm.config.json.pluginVersion`,从 `1.0.0` 开始;仅独立发布的模块覆盖 `moduleVersion`
- 插件默认版本来自 `xuqm.modules.json.pluginVersion`,从 `1.0.0` 开始;仅独立发布的模块覆盖 `moduleVersion`
`xuqm.modules.json` 分别使用 Android `packageName` 和 iOS `iosBundleId` 保存平台宿主
身份,只在目标平台 build/package/release 时要求相应字段。ZIP wire 字段仍统一为
`packageName`;CLI 通过唯一平台解析器写入对应身份,发布前从 ZIP 内重新读取并与同一
解析结果精确比较,缺失或不一致时在联网前拒绝发布。
Debug 启动只注册并校验插件,不访问远程更新服务。开发页如需验证平台能力,显式调用
`UpdateSDK.developer.checkStartupOnce()`
`UpdateSDK.developer.checkAndInstallPluginOnce(moduleId)`;该状态不会跨重启保留。
`xuqm-rn package android` 会为每次 Release 自动生成递增 `versionCode` 与不可变
`buildId`,并同时注入 APK、插件 manifest 和 Source Map 身份。宿主不得手工维护
versionCode;Jenkins 可通过 `XUQM_VERSION_CODE`、`XUQM_BUILD_ID` 固定本次流水线身份。
同一 `versionName` 的重复测试包仍会获得新的 versionCode/buildId,从而正常覆盖安装并
加载本次 Bundle。
app/buz 使用 `commonVersionRange` 声明 SemVer 兼容范围,并使用 `minNativeApiLevel` 声明最低原生能力。Bundle 只接受 SHA-256,不使用 MD5。
宿主的唯一 Metro 配置使用 update 组合器;它已经包含 common 的自动初始化配置:
```js
const { getDefaultConfig } = require('@react-native/metro-config')
const { withXuqmModuleConfig } = require('@xuqm/rn-update/metro')
module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname))
```
日常 `start/android` 没有模块构建上下文,保持标准 Metro 行为。构建 app/buz 时 CLI 自动先生成 startup/common 共享模块表,并为每个插件分配互不重叠的稳定区间;宿主不得复制模块 ID 缓存或维护多份 Metro 配置。
## 开发与打包
- `pnpm android`:安装 Debug 包、连接 Metro、支持热刷新。
@ -27,6 +53,11 @@ app/buz 使用 `commonVersionRange` 声明 SemVer 兼容范围,并使用 `minN
- `pnpm release:android -- --apk`:生成包含同一批插件的 Release APK。
- `pnpm publish:android`上传插件产物;SDK npm 制品发布只能通过 Jenkins。
发布使用的 `XUQM_API_TOKEN` 只能由 Jenkins Credentials 注入环境变量,不属于
`xuqm.modules.json``config.xuqmconfig`。CLI 不读取项目文件中的 Token,避免可用凭据
进入源码、插件包或构建日志。
配置校验会直接拒绝 `release.apiToken`;发布凭据只能来自 `XUQM_API_TOKEN`
Android 宿主只应用一次 SDK 脚本:
```groovy
@ -35,13 +66,47 @@ apply from: file("../../node_modules/@xuqm/rn-update/android/xuqm-bundles.gradle
该脚本关闭 React Native 默认单体 Bundle,避免宿主同时维护两套启动产物。
多 Bundle 启动壳只需要查询原生分包路径时使用轻量子路径,禁止为了两个原生调用导入完整更新、网络与 release-set 实现:
```ts
import { NativeBundle } from '@xuqm/rn-update/native-bundle'
await NativeBundle.preparePendingRelease()
const commonPath = await NativeBundle.getLaunchPath('common')
const appPath = await NativeBundle.getLaunchPath('app')
```
启动壳只能依次准备并装载 common、app,不得读取 manifest 后枚举或释放 buz。普通业务继续从 `@xuqm/rn-update` 根入口使用 `UpdateSDK`/`XuqmRuntime`;该子路径只用于首屏启动壳和底层宿主编排,不是第二套状态或兼容 API。
多 bundle 宿主还必须在 common 入口保留轻量注册表:
```ts
import '@xuqm/rn-update/plugin-registry'
```
`app` 与各 `buz` 拥有隔离的模块编号区间,common 中预注册的 `plugin-registry` 保证插件执行 `definePlugin()` 后,宿主 `XuqmRuntime` 读取到同一份定义。SDK 的构建/脚手架应生成该入口,业务项目不得自行复制注册表。
同一规则适用于跨 Bundle React Context 和具有原生注册副作用的依赖Provider 在 app 挂载、由 buz 消费的 Context,以及 app/buz 都会使用的原生 View/Module,必须由 common 入口静态持有并分配共享编号。禁止在插件中复制 Context、注册表,或让多个 Bundle 分别执行同名原生组件注册。
插件产物的引擎只有一个事实来源Android 宿主的 `android/gradle.properties`。当
`hermesEnabled=true` 时,CLI 自动使用与宿主 React Native 匹配的 `hermes-compiler` 将每个
startup/common/app/buz 编译成 Hermes bytecode,并组合 Metro/Hermes source map;无需也不允许在
`xuqm.config.json` 再声明一份引擎。manifest 和发布请求会记录 `bundleFormat`
`xuqm.modules.json` 再声明一份引擎。manifest 和发布请求会记录 `bundleFormat`
## 运行时规则
Android 启动只把 APK 内嵌版本当作可恢复基线,不会每次重新读取或复制大 Bundle
- 原生模块每个进程只解析一次内嵌 `manifest.json`,随后以本地 `state.json` 为版本事实源。
- `getBundleState()` 是纯状态查询:已有本地状态时直接读取,尚未访问的模块只从 manifest 返回内嵌基线元数据,不创建目录、不复制 Bundle。安装/恢复只允许由目标模块的 launch path 请求触发。
- 本地下载版本 SemVer 大于等于内嵌版本,且 `nativeBaselineId`、`minNativeApiLevel` 和文件长度仍兼容时,直接返回现有 `active.bundle`
- 本地文件由当前 APK 内嵌基线产生时,版本相同仍比较 manifest SHA-256;这样开发期同版本的新 APK 可以替换旧基线,完全相同的 APK 则直接短路。
- 只有首次安装、本地版本落后、原生基线不兼容、摘要/长度异常或 APK 内嵌内容变化时,才读取 APK 中的 Bundle,校验 SHA-256,并连同资源原子恢复。
因此“版本检查”不等于每次网络检查,也不等于解析 Bundle 内容。启动路径只做小状态读取;插件网络更新仍按下述入口策略执行。
上述恢复是严格的单模块按需操作:冷启动只触发 common、app;buz 在用户进入相应功能时才调用 `checkAndInstallPlugin(moduleId)``XuqmRuntime.activate(moduleId)`。健康的本地 buz 直接执行,缺失或不兼容时只处理目标 buz 与其 common 依赖,未访问插件不复制、不解压、不执行。
安装仅包含 buz 的 release set 后,使用
`XuqmRuntime.activate(moduleId, { reloadBundle: true })` 重新求值并激活新 bundle。若 release set
包含 common,宿主必须冷启动,不能在同一 JavaScript VM 中混用新旧 common。
@ -88,7 +153,25 @@ const plan = await UpdateSDK.checkAndInstallPlugin('prescription', {
})
```
SDK 会验证所有已安装模块在目标 common 下仍兼容,然后整组下载、校验、staging、激活、确认或回滚。Bundle 版本与事务状态只保存在原生 state;宿主不得另建 AsyncStorage 版本表,也不得逐层传递版本或路径状态。
SDK 会验证所有已安装模块在目标 common 下仍兼容,并拒绝 `builtAgainstNativeBaselineId` 与当前完整包不一致的候选,然后整组下载、校验、staging、激活、确认或回滚。Bundle 版本与事务状态只保存在原生 state;宿主不得另建 AsyncStorage 版本表,也不得逐层传递版本或路径状态。
所有公开检查、下载和安装 API 都要求平台明确开启 `features.update`。关闭时在任何网络
或原生操作前抛出 `UpdateDisabledError``code: UPDATE_DISABLED`);登录、启动等自动
后台路径会吞掉该状态并正常返回,不影响宿主使用。
release-set 检查请求必须携带当前 `appVersion`、`nativeApiLevel` 和
`nativeBaselineId`;原生基线缺失时客户端在请求前阻断。服务端候选必须提供
`keyId/signature`,签名覆盖以下不可变 canonical manifest
`appKey, packageName, platform, moduleId, type, version, appVersionRange, builtAgainstNativeBaselineId, buildId, bundleFormat, minNativeApiLevel, bundleSha256, archiveSha256`
app/buz 另包含 `commonVersionRange`;common 模块不构造该字段。canonical JSON 与
Config V2 一致UTF-8、键按字典序、无空白、null/undefined 字段省略。
客户端在生成计划前及安装前各验签一次,未知 keyId、签名错误、宿主身份不匹配或任一
字段被篡改都会拒绝。响应 `sha256``archiveSha256` 进入签名清单,下载后使用同值
校验 ZIP;Android 解包层还会把已签名的
`buildId/bundleFormat/bundleSha256` 与包内 manifest 逐项比较。
需要先展示插件更新提示的项目,分别调用纯检查和安装 API

查看文件

@ -15,7 +15,7 @@ android {
}
repositories {
maven { url "https://nexus.xuqinmin.com/repository/android/" }
maven { url = uri("https://nexus.xuqinmin.com/repository/android/") }
mavenCentral()
google()
}
@ -23,4 +23,5 @@ repositories {
dependencies {
implementation("com.facebook.react:react-android")
implementation("com.xuqm:sdk-update:2.0.0-SNAPSHOT")
testImplementation("junit:junit:4.13.2")
}

查看文件

@ -1,8 +1,8 @@
package com.xuqm.update;
import android.content.Context;
import android.system.ErrnoException;
import android.system.Os;
import android.content.SharedPreferences;
import android.util.Base64;
import androidx.annotation.NonNull;
@ -21,22 +21,29 @@ import org.json.JSONObject;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.util.LinkedHashSet;
import java.util.Locale;
import java.util.Set;
import static com.xuqm.update.XuqmBundleStorage.atomicWrite;
import static com.xuqm.update.XuqmBundleStorage.deleteIfExists;
import static com.xuqm.update.XuqmBundleStorage.deleteRecursively;
import static com.xuqm.update.XuqmBundleStorage.readBytes;
import static com.xuqm.update.XuqmBundleStorage.sha256;
import static com.xuqm.update.XuqmBundleStorage.verifySha256;
import static com.xuqm.update.XuqmPluginPackageStager.areAssetsPresent;
import static com.xuqm.update.XuqmPluginPackageStager.assetPaths;
import static com.xuqm.update.XuqmPluginPackageStager.validateAssetPath;
/** Owns embedded recovery and transactional activation for every RN plugin bundle. */
@ReactModule(name = XuqmBundleModule.NAME)
public final class XuqmBundleModule extends ReactContextBaseJavaModule {
public static final String NAME = "XuqmBundleModule";
private static final int NATIVE_API_LEVEL = 1;
private static final int NATIVE_API_LEVEL = 2;
private static final String BUNDLE_DIR = "rn-bundles";
private static final String EMBEDDED_DIR = "rn-bundles";
private static final String ACTIVE_FILE = "active.bundle";
@ -44,6 +51,16 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
private static final String ROLLBACK_FILE = "rollback.bundle";
private static final String STATE_FILE = "state.json";
private static final String RELEASE_STATE_FILE = "release-state.json";
private static final String ACTIVE_ASSETS_DIR = "active-assets";
private static final String STAGED_ASSETS_DIR = "staged-assets";
private static final String ROLLBACK_ASSETS_DIR = "rollback-assets";
private static final String PACKAGE_TEMP_DIR = "package-temp";
private static final String RESET_PREFERENCES = "xuqm-update-reset";
private static final String RESET_GENERATION = "generation";
private static final String CONFIRMED_RESET_GENERATION = "confirmed-generation";
/** APK 内嵌 manifest 很小且在进程生命周期内不可变,只解析一次。 */
private JSONObject embeddedManifestCache;
public XuqmBundleModule(ReactApplicationContext reactContext) {
super(reactContext);
@ -60,6 +77,52 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
promise.resolve(NATIVE_API_LEVEL);
}
/**
* 原生恢复页的唯一清理入口只删除 Update SDK 私有 rn-bundles 目录
* 宿主账号业务缓存和其它 SDK 数据均不在该目录中
*/
@ReactMethod
public void resetToEmbedded(Promise promise) {
try {
deleteRecursively(getBundleDir());
embeddedManifestCache = null;
SharedPreferences preferences = getReactApplicationContext()
.getSharedPreferences(RESET_PREFERENCES, Context.MODE_PRIVATE);
int generation = preferences.getInt(RESET_GENERATION, 0) + 1;
preferences.edit().putInt(RESET_GENERATION, generation).apply();
promise.resolve(true);
} catch (Exception error) {
promise.reject("BUNDLE_RESET_ERROR", error.getMessage(), error);
}
}
@ReactMethod
public void getResetGeneration(Promise promise) {
SharedPreferences preferences = getReactApplicationContext()
.getSharedPreferences(RESET_PREFERENCES, Context.MODE_PRIVATE);
promise.resolve(preferences.getInt(RESET_GENERATION, 0));
}
@ReactMethod
public void getConfirmedResetGeneration(Promise promise) {
SharedPreferences preferences = getReactApplicationContext()
.getSharedPreferences(RESET_PREFERENCES, Context.MODE_PRIVATE);
promise.resolve(preferences.getInt(CONFIRMED_RESET_GENERATION, 0));
}
@ReactMethod
public void confirmResetGeneration(int generation, Promise promise) {
SharedPreferences preferences = getReactApplicationContext()
.getSharedPreferences(RESET_PREFERENCES, Context.MODE_PRIVATE);
int current = preferences.getInt(RESET_GENERATION, 0);
if (generation != current) {
promise.reject("BUNDLE_RESET_STALE", "Reset generation changed before confirmation");
return;
}
preferences.edit().putInt(CONFIRMED_RESET_GENERATION, generation).apply();
promise.resolve(true);
}
@ReactMethod
public void bundleExists(String moduleId, Promise promise) {
try {
@ -105,8 +168,10 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
@ReactMethod
public void getBundleState(String moduleId, Promise promise) {
try {
ensureEmbeddedBundle(moduleId);
promise.resolve(readState(moduleId).toString());
// 版本检查需要知道全部已交付模块的版本但读取状态绝不能顺带安装它们
// 未访问的 buz 直接从小型内嵌 manifest 构造只读状态真正进入插件时
// getLaunchBundlePath(moduleId) 才按需恢复 Bundle 与资源
promise.resolve(readBundleStateWithoutInstalling(moduleId).toString());
} catch (Exception error) {
promise.reject("BUNDLE_STATE_ERROR", error.getMessage(), error);
}
@ -126,6 +191,45 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
}
}
@ReactMethod
public void getAssetIndex(Promise promise) {
try {
JSONObject index = new JSONObject();
File root = new File(getReactApplicationContext().getFilesDir(), BUNDLE_DIR);
// nativeApiLevel=1 曾把每个模块的 drawable-* 直接复制到模块根目录
// 导致 app/szyx/miniapp 各自残留整套重复图片新版资源只允许存在于
// active-assets 事务目录首次刷新索引时顺带清理旧目录不安装任何 buz
cleanupLegacyAssetDirectories(root);
File[] modules = root.listFiles();
if (modules != null) {
for (File module : modules) {
if (!module.isDirectory()) continue;
collectAssetIndex(new File(module, ACTIVE_ASSETS_DIR), "", index);
}
}
promise.resolve(index.toString());
} catch (Exception error) {
promise.reject("ASSET_INDEX_ERROR", error.getMessage(), error);
}
}
private void cleanupLegacyAssetDirectories(File root) throws IOException {
File[] modules = root.listFiles();
if (modules == null) return;
for (File module : modules) {
if (!module.isDirectory()) continue;
File[] children = module.listFiles();
if (children == null) continue;
for (File child : children) {
if (!child.isDirectory()) continue;
String name = child.getName();
if (name.startsWith("drawable-") || "raw".equals(name)) {
deleteRecursively(child);
}
}
}
}
@ReactMethod
public void writeManifest(String manifestJson, Promise promise) {
try {
@ -158,17 +262,28 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
if (module == null) throw new IllegalArgumentException("Invalid release module at " + index);
String moduleId = requiredString(module, "moduleId");
String version = requiredString(module, "version");
String source = requiredString(module, "source");
String archiveBase64 = requiredString(module, "archiveBase64");
String sha256 = requiredString(module, "sha256");
validateModuleId(moduleId);
verifyHash(source.getBytes(StandardCharsets.UTF_8), sha256);
File staged = new File(getModuleDir(moduleId), STAGED_FILE);
atomicWrite(staged, source.getBytes(StandardCharsets.UTF_8));
stagedModules.add(moduleId);
byte[] archiveBytes = Base64.decode(archiveBase64, Base64.DEFAULT);
File moduleDirectory = getModuleDir(moduleId);
JSONObject packageManifest = XuqmPluginPackageStager.stage(
moduleId,
archiveBytes,
sha256,
module,
new File(moduleDirectory, PACKAGE_TEMP_DIR),
new File(moduleDirectory, STAGED_FILE),
new File(moduleDirectory, STAGED_ASSETS_DIR));
JSONObject state = readState(moduleId);
state.put("moduleId", moduleId);
state.put("pendingVersion", version);
state.put("pendingHash", sha256.toLowerCase(Locale.ROOT));
state.put("pendingBuildId", packageManifest.getString("buildId"));
state.put("pendingHash", packageManifest.getString("bundleSha256").toLowerCase(Locale.ROOT));
state.put("pendingPackageHash", sha256.toLowerCase(Locale.ROOT));
state.put("pendingByteLength", packageManifest.getLong("bundleByteLength"));
state.put("pendingAssetFiles", assetPaths(packageManifest));
state.put("pendingType", requiredString(module, "type"));
state.put("pendingAppVersionRange", requiredString(module, "appVersionRange"));
copyOptionalString(module, "builtAgainstNativeBaselineId", state, "pendingBuiltAgainstNativeBaselineId");
@ -177,7 +292,6 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
state.put("releaseId", releaseId);
state.put("status", "staged");
writeState(moduleId, state);
stagedModules.add(moduleId);
moduleIds.put(moduleId);
}
@ -269,8 +383,8 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
JSONObject release = readReleaseStateOrNull();
if (release != null) {
if ("pending_confirmation".equals(release.optString("status"))
&& release.optInt("launchAttempts", 0) == 0) {
release.put("launchAttempts", 1);
&& release.optInt("launchAttempts", 0) < 2) {
release.put("launchAttempts", release.optInt("launchAttempts", 0) + 1);
writeReleaseState(release);
} else {
JSONArray modules = release.getJSONArray("modules");
@ -308,7 +422,9 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
private void activateModule(String moduleId, String releaseId) throws Exception {
File directory = getModuleDir(moduleId);
File staged = new File(directory, STAGED_FILE);
File stagedAssets = new File(directory, STAGED_ASSETS_DIR);
if (!staged.exists()) throw new IOException("No staged bundle for " + moduleId);
if (!stagedAssets.isDirectory()) throw new IOException("No staged assets for " + moduleId);
JSONObject state = readState(moduleId);
if (!releaseId.equals(state.optString("releaseId"))) {
throw new IOException("Staged bundle does not belong to release " + releaseId);
@ -316,15 +432,29 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
File active = new File(directory, ACTIVE_FILE);
File rollback = new File(directory, ROLLBACK_FILE);
File activeAssets = new File(directory, ACTIVE_ASSETS_DIR);
File rollbackAssets = new File(directory, ROLLBACK_ASSETS_DIR);
boolean hadActive = active.exists();
deleteIfExists(rollback);
deleteRecursively(rollbackAssets);
if (hadActive && !active.renameTo(rollback)) {
throw new IOException("Unable to preserve active bundle for " + moduleId);
}
if (activeAssets.exists() && !activeAssets.renameTo(rollbackAssets)) {
if (rollback.exists()) rollback.renameTo(active);
throw new IOException("Unable to preserve active assets for " + moduleId);
}
if (!staged.renameTo(active)) {
if (rollback.exists()) rollback.renameTo(active);
if (rollbackAssets.exists()) rollbackAssets.renameTo(activeAssets);
throw new IOException("Unable to activate staged bundle for " + moduleId);
}
if (!stagedAssets.renameTo(activeAssets)) {
deleteIfExists(active);
if (rollback.exists()) rollback.renameTo(active);
if (rollbackAssets.exists()) rollbackAssets.renameTo(activeAssets);
throw new IOException("Unable to activate staged assets for " + moduleId);
}
if (hadActive) copyActiveToRollbackMetadata(state);
else clearRollbackMetadata(state);
@ -342,43 +472,215 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
state.put("status", "active");
state.put("launchAttempts", 0);
deleteIfExists(new File(getModuleDir(moduleId), ROLLBACK_FILE));
deleteRecursively(new File(getModuleDir(moduleId), ROLLBACK_ASSETS_DIR));
writeState(moduleId, state);
}
private File ensureEmbeddedBundle(String moduleId) throws Exception {
validateModuleId(moduleId);
File active = new File(getModuleDir(moduleId), ACTIVE_FILE);
if (active.exists()) return active;
File moduleDirectory = getModuleDir(moduleId);
File active = new File(moduleDirectory, ACTIVE_FILE);
JSONObject manifest = readEmbeddedManifest();
JSONObject modules = manifest.optJSONObject("modules");
JSONObject entry = modules == null ? null : modules.optJSONObject(moduleId);
if (entry == null) return active;
String bundleFile = entry.optString("bundleFile", moduleId + ".android.bundle");
byte[] bytes = readAsset(EMBEDDED_DIR + "/" + bundleFile);
atomicWrite(active, bytes);
String bundleFile = entry.optString("bundleFile", moduleId + ".android.bundle");
String embeddedHash = entry.optString("sha256", "");
if (active.exists()) {
JSONObject state = readState(moduleId);
if (canLaunchInstalledBundle(moduleId, active, state, manifest, entry, embeddedHash)) {
return active;
}
}
// 只有首次安装内嵌基线升级完整性异常或本地插件不兼容时才读取 APK
// 中的大 Bundle正常冷启动只解析一次小 manifest 和本地 state
byte[] bytes = readAsset(EMBEDDED_DIR + "/" + bundleFile);
if (embeddedHash.isEmpty()) embeddedHash = sha256(bytes);
else verifySha256(bytes, embeddedHash);
atomicWrite(active, bytes);
File activeAssets = new File(moduleDirectory, ACTIVE_ASSETS_DIR);
deleteRecursively(activeAssets);
restoreEmbeddedAssets(activeAssets, entry.optJSONArray("assetFiles"));
writeEmbeddedState(moduleId, manifest, entry, embeddedHash, bytes.length);
return active;
}
/**
* 本地下载版本不低于 APK 内嵌版本且仍兼容当前原生基线时优先启动本地版本
* APK 自己安装的版本则用 manifest 摘要短路允许开发期同版本新构建替换
*/
private boolean canLaunchInstalledBundle(
String moduleId,
File active,
JSONObject state,
JSONObject manifest,
JSONObject entry,
String embeddedHash) throws Exception {
boolean pendingConfirmation = "pending_confirmation".equals(state.optString("status"));
String metadataPrefix = pendingConfirmation ? "pending" : "active";
long recordedLength = state.optLong(metadataPrefix + "ByteLength", -1L);
if (recordedLength >= 0L && active.length() != recordedLength) return false;
JSONArray assetFiles = state.optJSONArray(metadataPrefix + "AssetFiles");
if (!areAssetsPresent(new File(getModuleDir(moduleId), ACTIVE_ASSETS_DIR), assetFiles)) return false;
boolean ownsEmbeddedBundle = "embedded".equals(state.optString("status"));
if (ownsEmbeddedBundle) {
if (!areAssetsPresent(
new File(getModuleDir(moduleId), ACTIVE_ASSETS_DIR),
entry.optJSONArray("assetFiles"))) return false;
if (embeddedHash.isEmpty()
|| !embeddedHash.equalsIgnoreCase(state.optString("activeHash", ""))) {
return false;
}
long embeddedLength = entry.optLong("byteLength", -1L);
if (embeddedLength >= 0L && active.length() != embeddedLength) return false;
if (!isEmbeddedStateCurrent(state, manifest, entry, embeddedHash, active.length())) {
// 新版 state 字段或兼容元数据变化时只刷新小状态文件不读取/复制 Bundle
writeEmbeddedState(moduleId, manifest, entry, embeddedHash, active.length());
}
return true;
}
String installedVersion = state.optString(metadataPrefix + "Version", "");
String embeddedVersion = entry.optString("moduleVersion", "0.0.0");
final int versionComparison;
try {
versionComparison = XuqmSemanticVersion.compare(installedVersion, embeddedVersion);
} catch (IllegalArgumentException invalidVersion) {
return false;
}
if (versionComparison < 0) return false;
if (versionComparison == 0
&& !embeddedHash.equalsIgnoreCase(state.optString(metadataPrefix + "Hash", ""))) {
// 相同展示版本的新版 APK 以摘要识别真实构建覆盖旧本地副本
// 真正更高且兼容当前原生基线的远程插件仍保留
return false;
}
String currentBaseline = manifest.optString("nativeBaselineId", "");
String installedBaseline = state.optString(metadataPrefix + "BuiltAgainstNativeBaselineId", "");
if (!currentBaseline.isEmpty() && !currentBaseline.equals(installedBaseline)) {
return false;
}
if (state.optInt(metadataPrefix + "MinNativeApiLevel", 1) > NATIVE_API_LEVEL) return false;
if (recordedLength < 0L) {
state.put(metadataPrefix + "ByteLength", active.length());
writeState(moduleId, state);
}
return true;
}
private boolean isEmbeddedStateCurrent(
JSONObject state,
JSONObject manifest,
JSONObject entry,
String embeddedHash,
long byteLength) {
return entry.optString("moduleVersion", "0.0.0").equals(state.optString("activeVersion", ""))
&& manifest.optString("buildId", "").equals(state.optString("activeBuildId", ""))
&& embeddedHash.equalsIgnoreCase(state.optString("activeHash", ""))
&& byteLength == state.optLong("activeByteLength", -1L)
&& entry.optString("type", "buz").equals(state.optString("activeType", ""))
&& entry.optString("appVersionRange", "").equals(state.optString("activeAppVersionRange", ""))
&& entry.optString("commonVersionRange", "").equals(state.optString("activeCommonVersionRange", ""))
&& manifest.optString("nativeBaselineId", "")
.equals(state.optString("activeBuiltAgainstNativeBaselineId", ""))
&& entry.optInt("minNativeApiLevel", 1) == state.optInt("activeMinNativeApiLevel", 1)
&& jsonArraysEqual(entry.optJSONArray("assetFiles"), state.optJSONArray("activeAssetFiles"));
}
private void writeEmbeddedState(
String moduleId,
JSONObject manifest,
JSONObject entry,
String embeddedHash,
long byteLength) throws Exception {
writeState(
moduleId,
createEmbeddedState(
moduleId,
manifest.optString("buildId", ""),
entry,
embeddedHash,
byteLength));
}
private JSONObject createEmbeddedState(
String moduleId,
String buildId,
JSONObject entry,
String embeddedHash,
long byteLength) throws Exception {
JSONObject state = new JSONObject();
state.put("moduleId", moduleId);
state.put("status", "embedded");
state.put("activeVersion", entry.optString("moduleVersion", "0.0.0"));
if (!buildId.isEmpty()) state.put("activeBuildId", buildId);
if (!embeddedHash.isEmpty()) {
state.put("activeHash", embeddedHash.toLowerCase(Locale.ROOT));
}
if (byteLength >= 0L) state.put("activeByteLength", byteLength);
state.put("activeType", entry.optString("type", "buz"));
copyJsonValue(entry, "commonVersionRange", state, "activeCommonVersionRange");
copyJsonValue(entry, "appVersionRange", state, "activeAppVersionRange");
copyJsonValue(entry, "builtAgainstNativeBaselineId", state, "activeBuiltAgainstNativeBaselineId");
copyJsonValue(entry, "minNativeApiLevel", state, "activeMinNativeApiLevel");
writeState(moduleId, state);
return active;
state.put("activeAssetFiles", entry.optJSONArray("assetFiles") == null
? new JSONArray()
: entry.optJSONArray("assetFiles"));
return state;
}
private JSONObject readEmbeddedManifest() throws Exception {
try {
return new JSONObject(new String(readAsset(EMBEDDED_DIR + "/manifest.json"), StandardCharsets.UTF_8));
} catch (IOException missing) {
return new JSONObject();
/**
* 动态 loadBundle(file) React Native 会从该 Bundle 所在目录解析
* drawable-* 资源因此恢复 Bundle 与恢复其资源必须属于同一个原生职责
*/
private void restoreEmbeddedAssets(File assetDirectory, JSONArray assetFiles) throws Exception {
if (assetFiles == null) return;
for (int index = 0; index < assetFiles.length(); index += 1) {
String relativePath = assetFiles.getString(index);
validateAssetPath(relativePath);
atomicWrite(
new File(assetDirectory, relativePath),
readAsset(EMBEDDED_DIR + "/" + relativePath));
}
}
private boolean jsonArraysEqual(JSONArray left, JSONArray right) {
JSONArray normalizedLeft = left == null ? new JSONArray() : left;
JSONArray normalizedRight = right == null ? new JSONArray() : right;
return normalizedLeft.toString().equals(normalizedRight.toString());
}
private void collectAssetIndex(File directory, String prefix, JSONObject index) throws Exception {
File[] children = directory.listFiles();
if (children == null) return;
for (File child : children) {
String relative = prefix.isEmpty() ? child.getName() : prefix + "/" + child.getName();
if (child.isDirectory()) {
collectAssetIndex(child, relative, index);
} else {
validateAssetPath(relative);
if (index.has(relative)) throw new IOException("Duplicate active plugin asset: " + relative);
index.put(relative, child.toURI().toString());
}
}
}
private synchronized JSONObject readEmbeddedManifest() throws Exception {
if (embeddedManifestCache != null) return embeddedManifestCache;
try {
embeddedManifestCache = new JSONObject(
new String(readAsset(EMBEDDED_DIR + "/manifest.json"), StandardCharsets.UTF_8));
} catch (IOException missing) {
embeddedManifestCache = new JSONObject();
}
return embeddedManifestCache;
}
private JSONObject requireRelease(String releaseId) throws Exception {
validateReleaseId(releaseId);
JSONObject release = readReleaseStateOrNull();
@ -408,16 +710,24 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
File directory = getModuleDir(moduleId);
File active = new File(directory, ACTIVE_FILE);
File rollback = new File(directory, ROLLBACK_FILE);
File activeAssets = new File(directory, ACTIVE_ASSETS_DIR);
File rollbackAssets = new File(directory, ROLLBACK_ASSETS_DIR);
JSONObject state = readState(moduleId);
if (rollback.exists()) {
deleteIfExists(active);
if (!rollback.renameTo(active)) throw new IOException("Unable to restore " + moduleId);
deleteRecursively(activeAssets);
if (rollbackAssets.exists() && !rollbackAssets.renameTo(activeAssets)) {
throw new IOException("Unable to restore assets for " + moduleId);
}
moveRollbackToActiveMetadata(state);
} else if ("pending_confirmation".equals(state.optString("status"))) {
deleteIfExists(active);
deleteRecursively(activeAssets);
clearActiveMetadata(state);
}
deleteIfExists(new File(directory, STAGED_FILE));
deleteRecursively(new File(directory, STAGED_ASSETS_DIR));
clearPendingMetadata(state);
clearRollbackMetadata(state);
state.remove("releaseId");
@ -429,6 +739,8 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
private void cleanupStaged(String moduleId) {
try {
deleteIfExists(new File(getModuleDir(moduleId), STAGED_FILE));
deleteRecursively(new File(getModuleDir(moduleId), STAGED_ASSETS_DIR));
deleteRecursively(new File(getModuleDir(moduleId), PACKAGE_TEMP_DIR));
JSONObject state = readState(moduleId);
clearPendingMetadata(state);
state.remove("releaseId");
@ -464,6 +776,29 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
: new JSONObject().put("moduleId", moduleId).put("status", "embedded");
}
/** Reads installed state, or describes the APK baseline without creating a module directory. */
private JSONObject readBundleStateWithoutInstalling(String moduleId) throws Exception {
validateModuleId(moduleId);
File moduleDirectory = new File(new File(getReactApplicationContext().getFilesDir(), BUNDLE_DIR), moduleId);
File stateFile = new File(moduleDirectory, STATE_FILE);
if (stateFile.exists()) {
return new JSONObject(new String(readBytes(stateFile), StandardCharsets.UTF_8));
}
JSONObject manifest = readEmbeddedManifest();
JSONObject modules = manifest.optJSONObject("modules");
JSONObject entry = modules == null ? null : modules.optJSONObject(moduleId);
if (entry == null) {
return new JSONObject().put("moduleId", moduleId).put("status", "missing");
}
return createEmbeddedState(
moduleId,
manifest.optString("buildId", ""),
entry,
entry.optString("sha256", ""),
entry.optLong("byteLength", -1L));
}
private void writeState(String moduleId, JSONObject state) throws Exception {
atomicWrite(new File(getModuleDir(moduleId), STATE_FILE), state.toString().getBytes(StandardCharsets.UTF_8));
}
@ -512,32 +847,44 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
private void copyActiveToRollbackMetadata(JSONObject state) throws Exception {
copyJsonValue(state, "activeVersion", state, "rollbackVersion");
copyJsonValue(state, "activeBuildId", state, "rollbackBuildId");
copyJsonValue(state, "activeHash", state, "rollbackHash");
copyJsonValue(state, "activeByteLength", state, "rollbackByteLength");
copyJsonValue(state, "activeType", state, "rollbackType");
copyJsonValue(state, "activeAppVersionRange", state, "rollbackAppVersionRange");
copyJsonValue(state, "activeBuiltAgainstNativeBaselineId", state, "rollbackBuiltAgainstNativeBaselineId");
copyJsonValue(state, "activeCommonVersionRange", state, "rollbackCommonVersionRange");
copyJsonValue(state, "activeMinNativeApiLevel", state, "rollbackMinNativeApiLevel");
copyJsonValue(state, "activePackageHash", state, "rollbackPackageHash");
copyJsonValue(state, "activeAssetFiles", state, "rollbackAssetFiles");
}
private void movePendingToActiveMetadata(JSONObject state) throws Exception {
moveJsonValue(state, "pendingVersion", "activeVersion");
moveJsonValue(state, "pendingBuildId", "activeBuildId");
moveJsonValue(state, "pendingHash", "activeHash");
moveJsonValue(state, "pendingByteLength", "activeByteLength");
moveJsonValue(state, "pendingType", "activeType");
moveJsonValue(state, "pendingAppVersionRange", "activeAppVersionRange");
moveJsonValue(state, "pendingBuiltAgainstNativeBaselineId", "activeBuiltAgainstNativeBaselineId");
moveJsonValue(state, "pendingCommonVersionRange", "activeCommonVersionRange");
moveJsonValue(state, "pendingMinNativeApiLevel", "activeMinNativeApiLevel");
moveJsonValue(state, "pendingPackageHash", "activePackageHash");
moveJsonValue(state, "pendingAssetFiles", "activeAssetFiles");
}
private void moveRollbackToActiveMetadata(JSONObject state) throws Exception {
moveJsonValue(state, "rollbackVersion", "activeVersion");
moveJsonValue(state, "rollbackBuildId", "activeBuildId");
moveJsonValue(state, "rollbackHash", "activeHash");
moveJsonValue(state, "rollbackByteLength", "activeByteLength");
moveJsonValue(state, "rollbackType", "activeType");
moveJsonValue(state, "rollbackAppVersionRange", "activeAppVersionRange");
moveJsonValue(state, "rollbackBuiltAgainstNativeBaselineId", "activeBuiltAgainstNativeBaselineId");
moveJsonValue(state, "rollbackCommonVersionRange", "activeCommonVersionRange");
moveJsonValue(state, "rollbackMinNativeApiLevel", "activeMinNativeApiLevel");
moveJsonValue(state, "rollbackPackageHash", "activePackageHash");
moveJsonValue(state, "rollbackAssetFiles", "activeAssetFiles");
}
private void moveJsonValue(JSONObject state, String source, String target) throws Exception {
@ -546,15 +893,15 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
}
private void clearActiveMetadata(JSONObject state) {
for (String key : new String[]{"activeVersion", "activeHash", "activeType", "activeAppVersionRange", "activeBuiltAgainstNativeBaselineId", "activeCommonVersionRange", "activeMinNativeApiLevel"}) state.remove(key);
for (String key : new String[]{"activeVersion", "activeBuildId", "activeHash", "activeByteLength", "activeType", "activeAppVersionRange", "activeBuiltAgainstNativeBaselineId", "activeCommonVersionRange", "activeMinNativeApiLevel", "activePackageHash", "activeAssetFiles"}) state.remove(key);
}
private void clearPendingMetadata(JSONObject state) {
for (String key : new String[]{"pendingVersion", "pendingHash", "pendingType", "pendingAppVersionRange", "pendingBuiltAgainstNativeBaselineId", "pendingCommonVersionRange", "pendingMinNativeApiLevel"}) state.remove(key);
for (String key : new String[]{"pendingVersion", "pendingBuildId", "pendingHash", "pendingByteLength", "pendingType", "pendingAppVersionRange", "pendingBuiltAgainstNativeBaselineId", "pendingCommonVersionRange", "pendingMinNativeApiLevel", "pendingPackageHash", "pendingAssetFiles"}) state.remove(key);
}
private void clearRollbackMetadata(JSONObject state) {
for (String key : new String[]{"rollbackVersion", "rollbackHash", "rollbackType", "rollbackAppVersionRange", "rollbackBuiltAgainstNativeBaselineId", "rollbackCommonVersionRange", "rollbackMinNativeApiLevel"}) state.remove(key);
for (String key : new String[]{"rollbackVersion", "rollbackBuildId", "rollbackHash", "rollbackByteLength", "rollbackType", "rollbackAppVersionRange", "rollbackBuiltAgainstNativeBaselineId", "rollbackCommonVersionRange", "rollbackMinNativeApiLevel", "rollbackPackageHash", "rollbackAssetFiles"}) state.remove(key);
}
private void validateModuleId(String moduleId) {
@ -569,23 +916,6 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
}
}
private void verifyHash(byte[] bytes, String expected) throws Exception {
if (expected == null || !expected.matches("(?i)^[a-f0-9]{64}$")) {
throw new IOException("A valid SHA-256 is required");
}
String actual = digest("SHA-256", bytes);
if (!actual.equalsIgnoreCase(expected)) {
throw new IOException("Bundle SHA-256 mismatch. Expected " + expected + " but got " + actual);
}
}
private String digest(String algorithm, byte[] bytes) throws Exception {
byte[] result = MessageDigest.getInstance(algorithm).digest(bytes);
StringBuilder output = new StringBuilder(result.length * 2);
for (byte value : result) output.append(String.format(Locale.ROOT, "%02x", value));
return output.toString();
}
private byte[] readAsset(String path) throws IOException {
try (InputStream input = getReactApplicationContext().getAssets().open(path);
ByteArrayOutputStream output = new ByteArrayOutputStream()) {
@ -596,41 +926,4 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
}
}
private byte[] readBytes(File file) throws IOException {
try (FileInputStream input = new FileInputStream(file);
ByteArrayOutputStream output = new ByteArrayOutputStream((int) file.length())) {
byte[] buffer = new byte[8192];
int count;
while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count);
return output.toByteArray();
}
}
private void atomicWrite(File target, byte[] bytes) throws IOException {
File parent = target.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) throw new IOException("Unable to create " + parent);
File temporary = new File(target.getAbsolutePath() + ".tmp");
try (FileOutputStream output = new FileOutputStream(temporary)) {
output.write(bytes);
output.flush();
output.getFD().sync();
}
try {
Os.rename(temporary.getAbsolutePath(), target.getAbsolutePath());
} catch (ErrnoException error) {
deleteIfExists(temporary);
throw new IOException("Unable to atomically replace " + target, error);
}
}
private void deleteIfExists(File file) throws IOException {
if (file.exists() && !file.delete()) throw new IOException("Unable to delete " + file);
}
private void deleteRecursively(File file) throws IOException {
if (!file.exists()) return;
File[] children = file.listFiles();
if (children != null) for (File child : children) deleteRecursively(child);
deleteIfExists(file);
}
}

查看文件

@ -0,0 +1,91 @@
package com.xuqm.update;
import android.system.ErrnoException;
import android.system.Os;
import java.io.ByteArrayOutputStream;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileOutputStream;
import java.io.IOException;
import java.security.MessageDigest;
import java.util.Locale;
/**
* Bundle 文件系统的唯一底层实现
*
* <p>发布事务内嵌恢复和插件包暂存必须共用这里的摘要路径与原子写入语义避免不同流程
* 各自维护一套看似相同但边界不同的文件操作
*/
final class XuqmBundleStorage {
private XuqmBundleStorage() {}
static void verifySha256(byte[] bytes, String expected) throws Exception {
if (expected == null || !expected.matches("(?i)^[a-f0-9]{64}$")) {
throw new IOException("A valid SHA-256 is required");
}
String actual = sha256(bytes);
if (!actual.equalsIgnoreCase(expected)) {
throw new IOException("Bundle SHA-256 mismatch. Expected " + expected + " but got " + actual);
}
}
static String sha256(byte[] bytes) throws Exception {
byte[] result = MessageDigest.getInstance("SHA-256").digest(bytes);
StringBuilder output = new StringBuilder(result.length * 2);
for (byte value : result) output.append(String.format(Locale.ROOT, "%02x", value));
return output.toString();
}
static File safeFile(File root, String relativePath) throws IOException {
File file = new File(root, relativePath).getCanonicalFile();
File canonicalRoot = root.getCanonicalFile();
if (!file.getPath().startsWith(canonicalRoot.getPath() + File.separator)) {
throw new IOException("Plugin package path escapes staging directory: " + relativePath);
}
return file;
}
static byte[] readBytes(File file) throws IOException {
try (FileInputStream input = new FileInputStream(file);
ByteArrayOutputStream output = new ByteArrayOutputStream((int) file.length())) {
byte[] buffer = new byte[8192];
int count;
while ((count = input.read(buffer)) != -1) output.write(buffer, 0, count);
return output.toByteArray();
}
}
static void atomicWrite(File target, byte[] bytes) throws IOException {
File parent = target.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
throw new IOException("Unable to create " + parent);
}
File temporary = new File(target.getAbsolutePath() + ".tmp");
try (FileOutputStream output = new FileOutputStream(temporary)) {
output.write(bytes);
output.flush();
output.getFD().sync();
}
try {
Os.rename(temporary.getAbsolutePath(), target.getAbsolutePath());
} catch (ErrnoException error) {
deleteIfExists(temporary);
throw new IOException("Unable to atomically replace " + target, error);
}
}
static void deleteIfExists(File file) throws IOException {
if (file.exists() && !file.delete()) throw new IOException("Unable to delete " + file);
}
static void deleteRecursively(File file) throws IOException {
if (!file.exists()) return;
File[] children = file.listFiles();
if (children != null) {
for (File child : children) deleteRecursively(child);
}
deleteIfExists(file);
}
}

查看文件

@ -0,0 +1,267 @@
package com.xuqm.update;
import com.facebook.react.bridge.ReadableMap;
import org.json.JSONArray;
import org.json.JSONObject;
import java.io.ByteArrayInputStream;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.zip.ZipEntry;
import java.util.zip.ZipInputStream;
/** 校验并暂存一个完整的 Android RN 插件包,不参与发布集的激活与回滚编排。 */
final class XuqmPluginPackageStager {
private static final int MAX_ARCHIVE_BYTES = 50 * 1024 * 1024;
private static final long MAX_ENTRY_UNCOMPRESSED_BYTES = 100L * 1024L * 1024L;
private static final long MAX_UNCOMPRESSED_BYTES = 100L * 1024L * 1024L;
private static final int MAX_ARCHIVE_ENTRIES = 4096;
private XuqmPluginPackageStager() {}
static JSONObject stage(
String moduleId,
byte[] archiveBytes,
String expectedPackageHash,
ReadableMap candidate,
File packageDirectory,
File stagedBundle,
File stagedAssets) throws Exception {
if (archiveBytes.length == 0 || archiveBytes.length > MAX_ARCHIVE_BYTES) {
throw new IOException("Invalid plugin package size for " + moduleId);
}
XuqmBundleStorage.verifySha256(archiveBytes, expectedPackageHash);
XuqmBundleStorage.deleteRecursively(packageDirectory);
XuqmBundleStorage.deleteRecursively(stagedAssets);
if (!packageDirectory.mkdirs() || !stagedAssets.mkdirs()) {
throw new IOException("Unable to create plugin staging directories for " + moduleId);
}
Set<String> archiveEntries = extractArchive(moduleId, archiveBytes, packageDirectory);
JSONObject manifest = readAndValidateManifest(moduleId, packageDirectory, candidate);
stageBundle(moduleId, packageDirectory, stagedBundle, manifest);
stageAssets(packageDirectory, stagedAssets, archiveEntries, manifest);
XuqmBundleStorage.deleteRecursively(packageDirectory);
return manifest;
}
static JSONArray assetPaths(JSONObject manifest) throws Exception {
JSONArray paths = new JSONArray();
JSONArray assets = manifest.optJSONArray("assets");
if (assets == null) return paths;
for (int index = 0; index < assets.length(); index += 1) {
paths.put(assets.getJSONObject(index).getString("path"));
}
return paths;
}
static boolean areAssetsPresent(File root, JSONArray files) {
if (files == null) return true;
try {
for (int index = 0; index < files.length(); index += 1) {
String relativePath = files.getString(index);
validateAssetPath(relativePath);
if (!XuqmBundleStorage.safeFile(root, relativePath).isFile()) return false;
}
return true;
} catch (Exception error) {
return false;
}
}
static void validateAssetPath(String relativePath) {
if (relativePath == null
|| !relativePath.matches("^(?:drawable-[a-z0-9-]+|raw)/[a-z0-9_]+\\.[a-z0-9]+$")) {
throw new IllegalArgumentException("Invalid embedded asset path: " + relativePath);
}
}
private static Set<String> extractArchive(
String moduleId,
byte[] archiveBytes,
File packageDirectory) throws Exception {
Set<String> archiveEntries = new LinkedHashSet<>();
long uncompressedBytes = 0L;
int entryCount = 0;
try (ZipInputStream input = new ZipInputStream(new ByteArrayInputStream(archiveBytes))) {
ZipEntry entry;
byte[] buffer = new byte[8192];
while ((entry = input.getNextEntry()) != null) {
entryCount += 1;
validateArchiveEntryCount(entryCount);
// CLI zipSync 只生成文件 entry显式目录 entry 不属于 v1 协议
// 与服务端保持一致拒绝避免目录绕过 entry 数量和路径检查
if (entry.isDirectory()) {
throw new IOException("Plugin package directory entries are not allowed");
}
String name = entry.getName();
validateArchiveEntry(moduleId, name);
if (!archiveEntries.add(name)) {
throw new IOException("Duplicate plugin package entry: " + name);
}
File destination = XuqmBundleStorage.safeFile(packageDirectory, name);
File parent = destination.getParentFile();
if (parent != null && !parent.exists() && !parent.mkdirs()) {
throw new IOException("Unable to create package directory " + parent);
}
try (FileOutputStream output = new FileOutputStream(destination)) {
long entryUncompressedBytes = 0L;
int count;
while ((count = input.read(buffer)) != -1) {
entryUncompressedBytes += count;
validateEntryUncompressedSize(entryUncompressedBytes);
uncompressedBytes += count;
if (uncompressedBytes > MAX_UNCOMPRESSED_BYTES) {
throw new IOException("Plugin package exceeds the uncompressed size limit");
}
output.write(buffer, 0, count);
}
}
}
}
return archiveEntries;
}
static void validateArchiveEntryCount(int entryCount) throws IOException {
if (entryCount > MAX_ARCHIVE_ENTRIES) {
throw new IOException("Plugin package has too many entries");
}
}
static void validateEntryUncompressedSize(long entryBytes) throws IOException {
if (entryBytes > MAX_ENTRY_UNCOMPRESSED_BYTES) {
throw new IOException("Plugin package entry exceeds the uncompressed size limit");
}
}
private static JSONObject readAndValidateManifest(
String moduleId,
File packageDirectory,
ReadableMap candidate) throws Exception {
File manifestFile = new File(packageDirectory, "rn-manifest.json");
if (!manifestFile.isFile()) throw new IOException("Plugin package manifest is missing");
JSONObject manifest = new JSONObject(
new String(XuqmBundleStorage.readBytes(manifestFile), StandardCharsets.UTF_8));
if (manifest.optInt("schemaVersion", 0) != 1) {
throw new IOException("Unsupported plugin package schema");
}
if (!moduleId.equals(manifest.optString("moduleId"))
|| !"android".equals(manifest.optString("platform"))) {
throw new IOException("Plugin package identity does not match " + moduleId + "/android");
}
if (manifest.optString("buildId").trim().isEmpty()) {
throw new IOException("Plugin package buildId is missing");
}
validateCandidate(candidate, manifest);
return manifest;
}
private static void stageBundle(
String moduleId,
File packageDirectory,
File stagedBundle,
JSONObject manifest) throws Exception {
String bundleFile = manifest.getString("bundleFile");
if (!bundleFile.equals(moduleId + ".android.bundle")) {
throw new IOException("Unexpected bundle file in plugin package: " + bundleFile);
}
File packagedBundle = XuqmBundleStorage.safeFile(packageDirectory, bundleFile);
if (!packagedBundle.isFile()) throw new IOException("Plugin bundle is missing: " + bundleFile);
byte[] bundleBytes = XuqmBundleStorage.readBytes(packagedBundle);
if (bundleBytes.length != manifest.getLong("bundleByteLength")) {
throw new IOException("Plugin bundle length does not match its manifest");
}
XuqmBundleStorage.verifySha256(bundleBytes, manifest.getString("bundleSha256"));
XuqmBundleStorage.atomicWrite(stagedBundle, bundleBytes);
}
private static void stageAssets(
File packageDirectory,
File stagedAssets,
Set<String> archiveEntries,
JSONObject manifest) throws Exception {
JSONArray assets = manifest.optJSONArray("assets");
if (assets == null) assets = new JSONArray();
Set<String> declaredAssets = new LinkedHashSet<>();
for (int index = 0; index < assets.length(); index += 1) {
JSONObject asset = assets.getJSONObject(index);
String relativePath = asset.getString("path");
validateAssetPath(relativePath);
if (!declaredAssets.add(relativePath)) {
throw new IOException("Duplicate plugin asset: " + relativePath);
}
File packagedAsset = XuqmBundleStorage.safeFile(packageDirectory, "assets/" + relativePath);
if (!packagedAsset.isFile()) throw new IOException("Plugin asset is missing: " + relativePath);
byte[] bytes = XuqmBundleStorage.readBytes(packagedAsset);
if (bytes.length != asset.getLong("byteLength")) {
throw new IOException("Plugin asset length mismatch: " + relativePath);
}
XuqmBundleStorage.verifySha256(bytes, asset.getString("sha256"));
XuqmBundleStorage.atomicWrite(new File(stagedAssets, relativePath), bytes);
}
for (String archiveEntry : archiveEntries) {
if (archiveEntry.startsWith("assets/")
&& !declaredAssets.contains(archiveEntry.substring("assets/".length()))) {
throw new IOException("Plugin package contains an undeclared asset: " + archiveEntry);
}
}
}
private static void validateCandidate(ReadableMap candidate, JSONObject manifest) throws Exception {
requireEqual(candidate, manifest, "moduleId");
requireEqual(candidate, manifest, "type");
requireEqual(candidate, manifest, "version");
requireEqual(candidate, manifest, "buildId");
requireEqual(candidate, manifest, "bundleFormat");
requireEqual(candidate, manifest, "bundleSha256");
requireEqual(candidate, manifest, "appVersionRange");
requireOptionalEqual(candidate, manifest, "builtAgainstNativeBaselineId");
requireOptionalEqual(candidate, manifest, "commonVersionRange");
if (candidate.hasKey("minNativeApiLevel") && !candidate.isNull("minNativeApiLevel")
&& candidate.getInt("minNativeApiLevel") != manifest.optInt("minNativeApiLevel", 1)) {
throw new IOException("Plugin package minNativeApiLevel does not match the release candidate");
}
}
private static void requireEqual(ReadableMap candidate, JSONObject manifest, String key) throws Exception {
String expected = requiredString(candidate, key);
if (!expected.equals(manifest.optString(key))) {
throw new IOException("Plugin package " + key + " does not match the release candidate");
}
}
private static void requireOptionalEqual(ReadableMap candidate, JSONObject manifest, String key)
throws Exception {
String expected = candidate.hasKey(key) && !candidate.isNull(key) ? candidate.getString(key) : null;
String actual = manifest.has(key) && !manifest.isNull(key) ? manifest.getString(key) : null;
if (expected == null ? actual != null : !expected.equals(actual)) {
throw new IOException("Plugin package " + key + " does not match the release candidate");
}
}
private static String requiredString(ReadableMap map, String key) {
if (!map.hasKey(key) || map.isNull(key)) {
throw new IllegalArgumentException("Missing required field: " + key);
}
String value = map.getString(key);
if (value == null || value.trim().isEmpty()) {
throw new IllegalArgumentException("Invalid required field: " + key);
}
return value;
}
private static void validateArchiveEntry(String moduleId, String name) {
if ("rn-manifest.json".equals(name) || (moduleId + ".android.bundle").equals(name)) return;
if (name != null && name.startsWith("assets/")) {
validateAssetPath(name.substring("assets/".length()));
return;
}
throw new IllegalArgumentException("Invalid plugin package entry: " + name);
}
}

查看文件

@ -0,0 +1,85 @@
package com.xuqm.update;
import java.math.BigInteger;
/**
* Android 原生 Bundle 启动门禁使用的唯一 SemVer 2.0 优先级实现
*
* <p>版本判断属于存储协议不应散落在 React Native Bridge 的安装流程中构建元数据不参与
* 优先级非法版本由调用方按不可信本地 Bundle处理并恢复 APK 基线
*/
final class XuqmSemanticVersion {
private XuqmSemanticVersion() {}
static boolean isAtLeast(String installed, String baseline) {
try {
return compare(installed, baseline) >= 0;
} catch (IllegalArgumentException invalidVersion) {
return false;
}
}
/** SemVer 2.0 precedence comparison; build metadata does not affect precedence. */
static int compare(String left, String right) {
String[] leftVersion = parts(left);
String[] rightVersion = parts(right);
String[] leftCore = leftVersion[0].split("\\.", -1);
String[] rightCore = rightVersion[0].split("\\.", -1);
if (leftCore.length != 3 || rightCore.length != 3) {
throw new IllegalArgumentException("Semantic version requires major.minor.patch");
}
for (int index = 0; index < 3; index += 1) {
int compared = numericIdentifier(leftCore[index]).compareTo(numericIdentifier(rightCore[index]));
if (compared != 0) return compared;
}
String leftPreRelease = leftVersion[1];
String rightPreRelease = rightVersion[1];
if (leftPreRelease.isEmpty() && rightPreRelease.isEmpty()) return 0;
if (leftPreRelease.isEmpty()) return 1;
if (rightPreRelease.isEmpty()) return -1;
String[] leftIdentifiers = leftPreRelease.split("\\.", -1);
String[] rightIdentifiers = rightPreRelease.split("\\.", -1);
int sharedLength = Math.min(leftIdentifiers.length, rightIdentifiers.length);
for (int index = 0; index < sharedLength; index += 1) {
String leftIdentifier = leftIdentifiers[index];
String rightIdentifier = rightIdentifiers[index];
if (!leftIdentifier.matches("[0-9A-Za-z-]+")
|| !rightIdentifier.matches("[0-9A-Za-z-]+")) {
throw new IllegalArgumentException("Invalid semantic version pre-release identifier");
}
boolean leftNumeric = leftIdentifier.matches("[0-9]+");
boolean rightNumeric = rightIdentifier.matches("[0-9]+");
int compared;
if (leftNumeric && rightNumeric) {
compared = numericIdentifier(leftIdentifier).compareTo(numericIdentifier(rightIdentifier));
} else if (leftNumeric) {
compared = -1;
} else if (rightNumeric) {
compared = 1;
} else {
compared = leftIdentifier.compareTo(rightIdentifier);
}
if (compared != 0) return compared;
}
return Integer.compare(leftIdentifiers.length, rightIdentifiers.length);
}
private static String[] parts(String version) {
if (version == null || version.trim().isEmpty()) {
throw new IllegalArgumentException("Semantic version is required");
}
String withoutBuild = version.trim().split("\\+", 2)[0];
String[] versionParts = withoutBuild.split("-", 2);
return new String[]{versionParts[0], versionParts.length == 2 ? versionParts[1] : ""};
}
private static BigInteger numericIdentifier(String value) {
if (value == null || !value.matches("0|[1-9][0-9]*")) {
throw new IllegalArgumentException("Invalid numeric semantic version identifier");
}
return new BigInteger(value);
}
}

查看文件

@ -0,0 +1,49 @@
package com.xuqm.update;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.assertThrows;
import org.junit.Test;
import java.io.File;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
public final class XuqmBundleStorageTest {
@Test
public void sha256UsesTheCanonicalLowercaseEncoding() throws Exception {
assertEquals(
"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad",
XuqmBundleStorage.sha256("abc".getBytes(StandardCharsets.UTF_8)));
}
@Test
public void safeFileRejectsTraversalOutsideTheStagingRoot() throws Exception {
File root = new File(System.getProperty("java.io.tmpdir"), "xuqm-storage-test");
assertThrows(IOException.class, () -> XuqmBundleStorage.safeFile(root, "../outside.bundle"));
}
@Test
public void recursiveDeleteRemovesOnlyTheSelectedSdkDirectory() throws Exception {
File root = new File(System.getProperty("java.io.tmpdir"), "xuqm-reset-test-" + System.nanoTime());
File updateDirectory = new File(root, "rn-bundles/common");
File hostDirectory = new File(root, "host-business");
assertTrue(updateDirectory.mkdirs());
assertTrue(hostDirectory.mkdirs());
XuqmBundleStorage.atomicWrite(
new File(updateDirectory, "active.bundle"),
"bundle".getBytes(StandardCharsets.UTF_8));
XuqmBundleStorage.atomicWrite(
new File(hostDirectory, "account.json"),
"account".getBytes(StandardCharsets.UTF_8));
XuqmBundleStorage.deleteRecursively(new File(root, "rn-bundles"));
assertFalse(new File(root, "rn-bundles").exists());
assertTrue(new File(hostDirectory, "account.json").exists());
XuqmBundleStorage.deleteRecursively(root);
}
}

查看文件

@ -0,0 +1,35 @@
package com.xuqm.update;
import static org.junit.Assert.assertThrows;
import org.junit.Test;
public final class XuqmPluginPackageStagerTest {
@Test
public void acceptsOnlyPackagedAndroidResourcePaths() {
XuqmPluginPackageStager.validateAssetPath("drawable-xhdpi/logo.png");
XuqmPluginPackageStager.validateAssetPath("raw/plugin_data.json");
assertThrows(
IllegalArgumentException.class,
() -> XuqmPluginPackageStager.validateAssetPath("../drawable-xhdpi/logo.png"));
assertThrows(
IllegalArgumentException.class,
() -> XuqmPluginPackageStager.validateAssetPath("assets/free-form-name.png"));
}
@Test
public void enforcesTheV1ArchiveEntryBoundaries() throws Exception {
XuqmPluginPackageStager.validateArchiveEntryCount(4096);
assertThrows(
java.io.IOException.class,
() -> XuqmPluginPackageStager.validateArchiveEntryCount(4097));
XuqmPluginPackageStager.validateEntryUncompressedSize(100L * 1024L * 1024L);
assertThrows(
java.io.IOException.class,
() -> XuqmPluginPackageStager.validateEntryUncompressedSize(
100L * 1024L * 1024L + 1L));
}
}

查看文件

@ -0,0 +1,42 @@
package com.xuqm.update;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import org.junit.Test;
public final class XuqmSemanticVersionTest {
@Test
public void followsSemVerPrereleasePrecedence() {
String[] ordered = {
"1.0.0-alpha",
"1.0.0-alpha.1",
"1.0.0-alpha.beta",
"1.0.0-beta",
"1.0.0-beta.2",
"1.0.0-beta.11",
"1.0.0-rc.1",
"1.0.0"
};
for (int index = 1; index < ordered.length; index += 1) {
assertTrue(XuqmSemanticVersion.compare(ordered[index], ordered[index - 1]) > 0);
}
}
@Test
public void ignoresBuildMetadataAndSupportsLargeIdentifiers() {
assertTrue(XuqmSemanticVersion.compare("1.2.3+app4", "1.2.3+sdk") == 0);
assertTrue(
XuqmSemanticVersion.compare(
"999999999999999999999.0.0", "999999999999999999998.999.999")
> 0);
}
@Test
public void invalidInstalledVersionNeverPassesTheNativeGate() {
assertFalse(XuqmSemanticVersion.isAtLeast("", "1.0.0"));
assertFalse(XuqmSemanticVersion.isAtLeast("01.0.0", "1.0.0"));
assertFalse(XuqmSemanticVersion.isAtLeast("1.0", "1.0.0"));
}
}

查看文件

@ -1,10 +1,13 @@
import javax.inject.Inject
import groovy.json.JsonSlurper
import org.gradle.api.DefaultTask
import org.gradle.api.file.ConfigurableFileCollection
import org.gradle.api.file.DirectoryProperty
import org.gradle.api.file.RegularFileProperty
import org.gradle.api.provider.Property
import org.gradle.api.tasks.Input
import org.gradle.api.tasks.InputFile
import org.gradle.api.tasks.InputFiles
import org.gradle.api.tasks.Internal
import org.gradle.api.tasks.OutputDirectory
import org.gradle.api.tasks.PathSensitive
@ -37,23 +40,47 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask {
@PathSensitive(PathSensitivity.RELATIVE)
abstract RegularFileProperty getPackageFile()
@InputFile
@PathSensitive(PathSensitivity.RELATIVE)
abstract RegularFileProperty getSignedConfigFile()
/**
* RN/ bundle native baseline
* Gradle out-of-date
*/
@InputFiles
@PathSensitive(PathSensitivity.RELATIVE)
abstract ConfigurableFileCollection getSourceFiles()
@Input
abstract Property<String> getNodeExecutable()
@Input
abstract Property<String> getAppVersion()
@Input
abstract Property<String> getBuildId()
@Input
abstract Property<String> getBugCollectMode()
@OutputDirectory
abstract DirectoryProperty getOutputDirectory()
abstract DirectoryProperty getAssetsOutputDirectory()
@OutputDirectory
abstract DirectoryProperty getResourcesOutputDirectory()
@Inject
abstract ExecOperations getExecOperations()
@TaskAction
void generate() {
def embeddedBundles = outputDirectory.get().dir("rn-bundles").asFile
def embeddedBundles = assetsOutputDirectory.get().dir("rn-bundles").asFile
def resources = resourcesOutputDirectory.get().asFile
execOperations.exec {
workingDir(projectRootDirectory.get().asFile)
environment("XUQM_BUILD_ID", buildId.get())
environment("XUQM_BUGCOLLECT_MODE", bugCollectMode.get())
commandLine(
nodeExecutable.get(),
cliFile.get().asFile.absolutePath,
@ -65,6 +92,23 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask {
appVersion.get(),
)
}.assertNormalExitValue()
project.copy {
from(signedConfigFile.get().asFile)
into(assetsOutputDirectory.get().dir("config"))
rename { "config.xuqmconfig" }
}
// Android Bundle AssetManager require()
// aapt2 drawable drawable-* assets
project.delete(resources)
embeddedBundles.listFiles()?.findAll {
it.isDirectory() && it.name.startsWith("drawable-")
}?.each { drawableDirectory ->
project.copy {
from(drawableDirectory)
into(new File(resources, drawableDirectory.name))
}
}
}
}
@ -73,10 +117,43 @@ if (!plugins.hasPlugin("com.android.application")) {
}
def xuqmProjectRoot = rootProject.projectDir.parentFile
def xuqmHostPackage = new JsonSlurper().parse(new File(xuqmProjectRoot, "package.json"))
def xuqmLocalDependencyDirectories = (xuqmHostPackage.dependencies ?: [:])
.findAll { _, requested -> requested instanceof String && requested.startsWith("file:") }
.collect { _, requested -> new File(xuqmProjectRoot, requested.substring("file:".length())).canonicalFile }
def xuqmUseMetro = providers.gradleProperty("USE_METRO")
.orElse(providers.environmentVariable("USE_METRO"))
.map { it.toBoolean() }
.orElse(false)
def xuqmReleaseVersionCode = providers.gradleProperty("XUQM_VERSION_CODE")
.orElse(providers.environmentVariable("XUQM_VERSION_CODE"))
if (xuqmReleaseVersionCode.isPresent()) {
def parsedVersionCode = Integer.parseInt(xuqmReleaseVersionCode.get())
if (parsedVersionCode <= 0) {
throw new GradleException("XUQM_VERSION_CODE must be a positive integer")
}
android.defaultConfig.versionCode = parsedVersionCode
}
def xuqmBuildId = providers.gradleProperty("XUQM_BUILD_ID")
.orElse(providers.environmentVariable("XUQM_BUILD_ID"))
.orElse("development")
def xuqmBugCollectMode = providers.gradleProperty("XUQM_BUGCOLLECT_MODE")
.orElse(providers.environmentVariable("XUQM_BUGCOLLECT_MODE"))
.orElse("platform")
if (!["platform", "disabled"].contains(xuqmBugCollectMode.get())) {
throw new GradleException("XUQM_BUGCOLLECT_MODE must be platform or disabled")
}
def xuqmSignedConfigDirectory = new File(xuqmProjectRoot, "src/assets/config")
def xuqmSignedConfigCandidates = xuqmSignedConfigDirectory.isDirectory()
? (xuqmSignedConfigDirectory.listFiles()?.findAll {
it.isFile() && it.name.toLowerCase(Locale.ROOT).endsWith(".xuqmconfig")
} ?: []).sort { left, right -> left.name <=> right.name }
: []
if (xuqmSignedConfigCandidates.size() > 1) {
throw new GradleException(
"src/assets/config must contain exactly one .xuqmconfig file; found ${xuqmSignedConfigCandidates.size()}"
)
}
def prepareXuqmBundles = tasks.register(
"prepareXuqmEmbeddedBundles",
@ -86,8 +163,52 @@ def prepareXuqmBundles = tasks.register(
description = "Builds and embeds all configured Xuqm RN plugin bundles."
projectRootDirectory.set(xuqmProjectRoot)
cliFile.set(new File(xuqmProjectRoot, "node_modules/@xuqm/rn-update/scripts/xuqm-rn.mjs"))
configFile.set(new File(xuqmProjectRoot, "xuqm.config.json"))
configFile.set(new File(xuqmProjectRoot, "xuqm.modules.json"))
packageFile.set(new File(xuqmProjectRoot, "package.json"))
signedConfigFile.set(
xuqmSignedConfigCandidates
? xuqmSignedConfigCandidates.first()
: new File(xuqmSignedConfigDirectory, "config.xuqmconfig")
)
sourceFiles.from(project.fileTree(xuqmProjectRoot) {
include("src/**")
include("assets/**")
include("android/**")
include("index.*")
include("babel.config.*")
include("metro.config.*")
include("react-native.config.*")
include("tsconfig*.json")
include("pnpm-lock.yaml")
include("yarn.lock")
exclude("android/**/build/**")
exclude("android/**/.cxx/**")
exclude("android/**/.gradle/**")
exclude("android/app/src/**/generated/**")
})
sourceFiles.from(project.fileTree(new File(
xuqmProjectRoot,
"node_modules/@xuqm/rn-update/scripts",
)) {
include("*.mjs")
})
// registry baselinefile:
// Gradle native baseline
// JS/TS 源码决定 bundle 内容,android/ios native baseline
xuqmLocalDependencyDirectories.each { dependencyDirectory ->
sourceFiles.from(project.fileTree(dependencyDirectory) {
include("src/**")
include("metro/**")
include("android/**")
include("ios/**")
include("*.podspec")
include("package.json")
exclude("**/build/**")
exclude("**/.cxx/**")
exclude("**/.gradle/**")
exclude("**/generated/**")
})
}
nodeExecutable.set(System.getenv("NODE_BINARY") ?: "node")
appVersion.set(providers.provider {
def version = android.defaultConfig.versionName
@ -96,15 +217,21 @@ def prepareXuqmBundles = tasks.register(
}
version.toString()
})
outputDirectory.set(layout.buildDirectory.dir("generated/xuqm/assets"))
outputs.upToDateWhen { false }
buildId.set(xuqmBuildId)
bugCollectMode.set(xuqmBugCollectMode)
assetsOutputDirectory.set(layout.buildDirectory.dir("generated/xuqm/assets"))
resourcesOutputDirectory.set(layout.buildDirectory.dir("generated/xuqm/res"))
}
androidComponents.onVariants(androidComponents.selector().all()) { variant ->
if (variant.buildType == "release" || !xuqmUseMetro.get()) {
variant.sources.assets?.addGeneratedSourceDirectory(
prepareXuqmBundles,
{ task -> task.outputDirectory },
{ task -> task.assetsOutputDirectory },
)
variant.sources.res?.addGeneratedSourceDirectory(
prepareXuqmBundles,
{ task -> task.resourcesOutputDirectory },
)
}
}

查看文件

@ -0,0 +1,103 @@
const fs = require('node:fs')
const path = require('node:path')
const { withXuqmConfig } = require('@xuqm/rn-common/metro')
const MODULE_RANGE_SIZE = 10_000_000
function readCache(file) {
if (!fs.existsSync(file)) return {}
try {
return JSON.parse(fs.readFileSync(file, 'utf8'))
} catch {
return {}
}
}
function writeCache(file, value) {
fs.mkdirSync(path.dirname(file), { recursive: true })
fs.writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
}
function hasModule(cache, modulePath) {
return Object.prototype.hasOwnProperty.call(cache, modulePath)
}
function isRuntimePrelude(modulePath) {
return modulePath.includes('__prelude__') || modulePath.includes('polyfills')
}
function createModuleSerializer(environment = process.env) {
const moduleId = environment.XUQM_MODULE_ID
const moduleType = environment.XUQM_MODULE_TYPE
if (!moduleId || !moduleType) return null
const cacheFile = path.resolve(
environment.XUQM_MODULE_CACHE_FILE ?? '.xuqm-cache/common-module-ids.json',
)
if (environment.XUQM_RESET_MODULE_CACHE === 'true') writeCache(cacheFile, {})
const sharedCache = readCache(cacheFile)
// processModuleFilter 会在 createModuleIdFactory 分配编号之后执行。必须冻结进入
// 当前 bundle 之前已经存在的共享模块;否则 common 新分配的模块刚写入缓存,随即
// 又会被当作 startup 已提供模块过滤,生成只含 require(entryId) 的无效空包。
const inheritedSharedModules = new Set(Object.keys(sharedCache))
const writesSharedCache = moduleType === 'startup' || moduleType === 'common'
const moduleIndex = Number.parseInt(environment.XUQM_MODULE_INDEX ?? '0', 10)
let nextId = writesSharedCache
? Object.values(sharedCache).reduce((maximum, value) => Math.max(maximum, Number(value)), -1) +
1
: (Number.isFinite(moduleIndex) && moduleIndex >= 0 ? moduleIndex + 1 : 1) * MODULE_RANGE_SIZE
const localIds = new Map()
return {
createModuleIdFactory() {
return modulePath => {
if (hasModule(sharedCache, modulePath)) return sharedCache[modulePath]
const existing = localIds.get(modulePath)
if (existing !== undefined) return existing
const id = nextId++
localIds.set(modulePath, id)
if (writesSharedCache) {
sharedCache[modulePath] = id
writeCache(cacheFile, sharedCache)
}
return id
}
},
processModuleFilter(module) {
if (moduleType === 'startup') return true
if (isRuntimePrelude(module.path)) return false
return !inheritedSharedModules.has(module.path)
},
}
}
/**
* 同一份 Metro 配置同时服务日常开发和插件构建CLI 构建插件时注入模块上下文
* 普通 `pnpm android/start` 没有这些变量因此保持标准 React Native 行为
*/
function withXuqmModuleConfig(metroConfig) {
const configured = withXuqmConfig(metroConfig)
const moduleSerializer = createModuleSerializer()
if (!moduleSerializer) return configured
const configuredFilter = configured.serializer?.processModuleFilter
return {
...configured,
serializer: {
...configured.serializer,
...moduleSerializer,
processModuleFilter(module) {
return (
(configuredFilter ? configuredFilter(module) : true) &&
moduleSerializer.processModuleFilter(module)
)
},
},
}
}
module.exports = {
createModuleSerializer,
withXuqmModuleConfig,
}

查看文件

@ -6,9 +6,18 @@
"main": "src/index.ts",
"react-native": "src/index.ts",
"types": "src/index.ts",
"exports": {
".": "./src/index.ts",
"./native-bundle": "./src/NativeBundle.ts",
"./plugin-assets": "./src/PluginAssets.ts",
"./plugin-registry": "./src/PluginRegistry.ts",
"./metro": "./metro/index.js",
"./package.json": "./package.json"
},
"files": [
"src",
"android",
"metro",
"scripts",
"templates",
"react-native.config.js",
@ -27,12 +36,13 @@
"pack:check": "node scripts/verify-package.mjs"
},
"dependencies": {
"fflate": "0.8.3",
"semver": "7.8.5"
},
"peerDependencies": {
"react-native": ">=0.76.0",
"@react-native-async-storage/async-storage": ">=1.21.0",
"@xuqm/rn-common": "workspace:>=0.6.0-alpha.4"
"@xuqm/rn-common": "workspace:>=0.6.0-alpha.4",
"react-native": ">=0.76.0"
},
"devDependencies": {
"@types/semver": "7.7.1",

查看文件

@ -0,0 +1,38 @@
export const BUGCOLLECT_ARTIFACT_UPLOAD_PATH = '/bugcollect/v1/artifacts/upload'
/** Source Map 与插件发布使用同一流水线 Bearer,不允许匿名上传构建制品。 */
export function createArtifactUploadRequest(apiToken, form) {
if (typeof apiToken !== 'string' || apiToken.trim() === '') {
throw new Error('artifact upload requires an API token')
}
return {
method: 'POST',
headers: { Authorization: `Bearer ${apiToken}` },
body: form,
}
}
/** RN Source Map 与 Android R8 Mapping 共用 artifacts 入口,但类型和身份字段明确分离。 */
export function createRnSourceMapForm({
appKey,
platform,
appVersion,
buildId,
moduleId,
moduleVersion,
bundleHash,
fileBytes,
fileName,
}) {
const form = new FormData()
form.append('artifactType', 'RN_SOURCEMAP')
form.append('appKey', appKey)
form.append('platform', platform)
form.append('appVersion', appVersion)
form.append('buildId', buildId)
form.append('moduleId', moduleId)
form.append('moduleVersion', moduleVersion)
form.append('bundleHash', bundleHash)
form.append('file', new Blob([fileBytes]), fileName)
return form
}

查看文件

@ -0,0 +1,45 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
BUGCOLLECT_ARTIFACT_UPLOAD_PATH,
createArtifactUploadRequest,
createRnSourceMapForm,
} from './artifact-upload.mjs'
test('RN Source Map uses the frozen artifact upload contract', () => {
const form = createRnSourceMapForm({
appKey: 'app',
platform: 'android',
appVersion: '8.0.0',
buildId: 'build-1',
moduleId: 'orders',
moduleVersion: '1.0.2',
bundleHash: 'a'.repeat(64),
fileBytes: new Uint8Array([1, 2, 3]),
fileName: 'orders.android.map',
})
assert.equal(BUGCOLLECT_ARTIFACT_UPLOAD_PATH, '/bugcollect/v1/artifacts/upload')
assert.deepEqual(
Object.fromEntries(
[...form.entries()]
.filter(([, value]) => typeof value === 'string')
.map(([key, value]) => [key, value]),
),
{
artifactType: 'RN_SOURCEMAP',
appKey: 'app',
platform: 'android',
appVersion: '8.0.0',
buildId: 'build-1',
moduleId: 'orders',
moduleVersion: '1.0.2',
bundleHash: 'a'.repeat(64),
},
)
assert.equal(form.get('file') instanceof Blob, true)
const request = createArtifactUploadRequest('release-token', form)
assert.deepEqual(request.headers, { Authorization: 'Bearer release-token' })
assert.equal(request.body, form)
assert.throws(() => createArtifactUploadRequest('', form), /requires an API token/)
})

查看文件

@ -0,0 +1,48 @@
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync } from 'node:fs'
import { createRequire } from 'node:module'
import { tmpdir } from 'node:os'
import path from 'node:path'
import test from 'node:test'
const require = createRequire(import.meta.url)
const { createModuleSerializer } = require('../metro')
function context(cacheFile, moduleId, moduleType, moduleIndex, reset = false) {
return {
XUQM_MODULE_CACHE_FILE: cacheFile,
XUQM_MODULE_ID: moduleId,
XUQM_MODULE_INDEX: String(moduleIndex),
XUQM_MODULE_TYPE: moduleType,
XUQM_RESET_MODULE_CACHE: reset ? 'true' : 'false',
}
}
test('Metro module ranges share startup/common and isolate every app or buz', () => {
const directory = mkdtempSync(path.join(tmpdir(), 'xuqm-metro-'))
const cacheFile = path.join(directory, 'ids.json')
try {
const startup = createModuleSerializer(context(cacheFile, 'startup', 'startup', 0, true))
const startupFactory = startup.createModuleIdFactory()
assert.equal(startupFactory('/runtime/prelude.js'), 0)
const common = createModuleSerializer(context(cacheFile, 'common', 'common', 1))
assert.equal(common.processModuleFilter({ path: '/runtime/prelude.js' }), false)
const commonFactory = common.createModuleIdFactory()
assert.equal(commonFactory('/runtime/prelude.js'), 0)
assert.equal(commonFactory('/shared/common.ts'), 1)
assert.equal(
common.processModuleFilter({ path: '/shared/common.ts' }),
true,
'common 本轮新分配的模块必须保留在当前 bundle 中',
)
const app = createModuleSerializer(context(cacheFile, 'app', 'app', 2))
const buz = createModuleSerializer(context(cacheFile, 'buz-a', 'buz', 3))
assert.equal(app.processModuleFilter({ path: '/shared/common.ts' }), false)
assert.equal(app.createModuleIdFactory()('/app/entry.ts'), 30_000_000)
assert.equal(buz.createModuleIdFactory()('/buz/entry.ts'), 40_000_000)
} finally {
rmSync(directory, { force: true, recursive: true })
}
})

查看文件

@ -83,6 +83,30 @@ function runtimeDependencyVersions(root, hostPackage) {
return runtime
}
/**
* 开发期 file: 依赖不会因为源码变化自动提升 package version因此必须把它们的
* 原生源码纳入完整包基线正式 registry 依赖仍由不可变版本号标识
*/
function localDependencyNativeFiles(root, hostPackage, platform) {
const dependencies = hostPackage.dependencies ?? {}
const files = []
for (const [name, requested] of Object.entries(dependencies).sort(([left], [right]) =>
left.localeCompare(right),
)) {
if (typeof requested !== 'string' || !requested.startsWith('file:')) continue
const dependencyRoot = path.resolve(root, requested.slice('file:'.length))
for (const file of walkFiles(
path.join(dependencyRoot, platform),
dependencyRoot,
new Set([...NATIVE_EXTENSIONS, ...PACKAGED_ASSET_EXTENSIONS]),
new Set(platform === 'android' ? ['gradlew'] : ['Podfile']),
)) {
files.push({ ...file, relative: `local-dependency/${name}/${file.relative}` })
}
}
return files
}
/** Deterministic identity of code and dependencies that can only change through a full APK. */
export function computeNativeBaseline(root = process.cwd(), platform = 'android') {
if (platform !== 'android' && platform !== 'ios') {
@ -104,7 +128,8 @@ export function computeNativeBaseline(root = process.cwd(), platform = 'android'
const sourceAssets = [path.join(root, 'src'), path.join(root, 'assets')].flatMap(directory =>
walkFiles(directory, root, PACKAGED_ASSET_EXTENSIONS),
)
for (const file of [...nativeFiles, ...sourceAssets].sort((a, b) =>
const localDependencyFiles = localDependencyNativeFiles(root, hostPackage, platform)
for (const file of [...nativeFiles, ...sourceAssets, ...localDependencyFiles].sort((a, b) =>
a.relative.localeCompare(b.relative),
)) {
hash.update(`file\0${file.relative}\0`)

查看文件

@ -72,3 +72,25 @@ test('native baselines are platform-specific', () => {
rmSync(root, { recursive: true, force: true })
}
})
test('native source from a local file dependency changes the full-app baseline', () => {
const root = fixture()
try {
const dependencyRoot = path.join(root, 'local-update-sdk')
const nativeFile = path.join(dependencyRoot, 'android', 'src', 'main', 'java', 'Bridge.java')
mkdirSync(path.dirname(nativeFile), { recursive: true })
writeFileSync(nativeFile, 'final class Bridge {}')
writeFileSync(
path.join(root, 'package.json'),
JSON.stringify({
dependencies: { 'runtime-library': '^1.0.0', '@xuqm/local': 'file:./local-update-sdk' },
}),
)
const initial = computeNativeBaseline(root, 'android')
writeFileSync(nativeFile, 'final class Bridge { int apiLevel = 2; }')
assert.notEqual(computeNativeBaseline(root, 'android'), initial)
} finally {
rmSync(root, { recursive: true, force: true })
}
})

查看文件

@ -0,0 +1,48 @@
const BUGCOLLECT_MODES = new Set(['platform', 'disabled'])
/**
* Release 打包参数的唯一解析入口CLI 同时接收标准的 `--bugcollect=disabled`
* 和命令行工具常见的空格写法但后续流程只消费归一化结果
*/
export function parsePackageOptions(optionArgs) {
const remaining = [...optionArgs]
const apkIndex = remaining.indexOf('--apk')
const apk = apkIndex >= 0
if (apk) remaining.splice(apkIndex, 1)
const parsedBugCollect = parseBugCollectOption(remaining)
if (parsedBugCollect.remaining.length) {
throw new Error(`unknown option(s): ${parsedBugCollect.remaining.join(' ')}`)
}
return { apk, bugCollectMode: parsedBugCollect.bugCollectMode }
}
export function parseBugCollectOption(optionArgs, defaultMode = 'platform') {
const remaining = [...optionArgs]
const equalsOptions = remaining.filter(option => option.startsWith('--bugcollect='))
const spaceIndexes = remaining
.map((option, index) => (option === '--bugcollect' ? index : -1))
.filter(index => index >= 0)
if (equalsOptions.length + spaceIndexes.length > 1) {
throw new Error('--bugcollect must be specified only once')
}
let bugCollectMode = defaultMode
if (equalsOptions.length === 1) {
const index = remaining.indexOf(equalsOptions[0])
bugCollectMode = equalsOptions[0].slice('--bugcollect='.length)
remaining.splice(index, 1)
} else if (spaceIndexes.length === 1) {
const index = spaceIndexes[0]
if (remaining[index + 1] === undefined) {
throw new Error('--bugcollect requires a value')
}
bugCollectMode = remaining[index + 1]
remaining.splice(index, 2)
}
if (!BUGCOLLECT_MODES.has(bugCollectMode)) {
throw new Error('--bugcollect must be platform or disabled')
}
return { bugCollectMode, remaining }
}

查看文件

@ -0,0 +1,40 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { parseBugCollectOption, parsePackageOptions } from './package-options.mjs'
test('package options normalize equals and space bugcollect syntax', () => {
assert.deepEqual(parsePackageOptions(['--bugcollect=disabled']), {
apk: false,
bugCollectMode: 'disabled',
})
assert.deepEqual(parsePackageOptions(['--apk', '--bugcollect', 'disabled']), {
apk: true,
bugCollectMode: 'disabled',
})
assert.deepEqual(parsePackageOptions([]), {
apk: false,
bugCollectMode: 'platform',
})
})
test('publish and package share one BugCollect option parser', () => {
assert.deepEqual(parseBugCollectOption(['--module', 'app', '--bugcollect=disabled']), {
bugCollectMode: 'disabled',
remaining: ['--module', 'app'],
})
assert.deepEqual(parseBugCollectOption(['--note', 'release'], 'disabled'), {
bugCollectMode: 'disabled',
remaining: ['--note', 'release'],
})
})
test('package options reject duplicate, missing and invalid values', () => {
assert.throws(
() => parsePackageOptions(['--bugcollect=disabled', '--bugcollect', 'platform']),
/only once/,
)
assert.throws(() => parsePackageOptions(['--bugcollect']), /requires a value/)
assert.throws(() => parsePackageOptions(['--bugcollect=unknown']), /platform or disabled/)
assert.throws(() => parsePackageOptions(['--unknown']), /unknown option/)
})

查看文件

@ -0,0 +1,39 @@
import { readFileSync } from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
/**
* 整包与插件发布共用的配置验证门禁只阻断新产物已安装 App 不在线查询吊销状态
*/
export async function validateReleaseConfig({ projectRoot, packageName }) {
const hostRequire = createRequire(path.join(projectRoot, 'package.json'))
const commonMetro = hostRequire('@xuqm/rn-common/metro')
const configFile = commonMetro.findConfigFile(projectRoot)
if (!configFile) {
throw new Error('exactly one .xuqmconfig file is required in src/assets/config for Release')
}
const content = readFileSync(configFile, 'utf8').trim()
const resolved = commonMetro.decryptConfigAtBuildTime(content)
if (typeof packageName !== 'string' || packageName.trim() === '') {
throw new Error('A package identity is required for Release validation')
}
const response = await fetch(
`${String(resolved.serverUrl).replace(/\/$/, '')}/api/sdk/build/config/validate`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content, packageName }),
},
)
if (!response.ok) {
throw new Error(`Release config validation service failed: HTTP ${response.status}`)
}
const payload = await response.json()
const result = payload.data ?? payload
if (result.valid !== true) {
throw new Error(
`Release config rejected (${result.errorCode ?? 'CONFIG_INVALID'}): ${result.message ?? 'unknown error'}`,
)
}
}

查看文件

@ -0,0 +1,66 @@
import { createHash } from 'node:crypto'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
const MAX_ANDROID_VERSION_CODE = 2_100_000_000
function readPreviousVersionCode(cacheFile) {
try {
return Number(JSON.parse(readFileSync(cacheFile, 'utf8')).versionCode) || 0
} catch {
return 0
}
}
/**
* 为同一 versionName 的连续测试包生成可安装的新构建身份
*
* 本地缓存只负责同一工作区内的单调递增CI 若需要跨工作区全局唯一必须通过
* XUQM_VERSION_CODE 注入流水线唯一编号
*/
export function createAndroidReleaseIdentity({
projectRoot,
environment = process.env,
now = Date.now(),
entropy = `${process.pid}:${Math.random()}`,
}) {
const cacheFile = path.join(projectRoot, '.xuqm-cache', 'release-build.json')
const previousVersionCode = readPreviousVersionCode(cacheFile)
const requestedText = environment.XUQM_VERSION_CODE
const requested = requestedText === undefined ? undefined : Number(requestedText)
if (
requestedText !== undefined &&
(!Number.isInteger(requested) || requested <= 0 || requested > MAX_ANDROID_VERSION_CODE)
) {
throw new Error(
`XUQM_VERSION_CODE must be an integer between 1 and ${MAX_ANDROID_VERSION_CODE}`,
)
}
const epochSeconds = Math.floor(now / 1_000)
const versionCode = requested ?? Math.max(epochSeconds, previousVersionCode + 1)
if (versionCode > MAX_ANDROID_VERSION_CODE) {
throw new Error('Generated Android versionCode exceeds limit')
}
const buildId =
environment.XUQM_BUILD_ID ??
createHash('sha256').update(`${versionCode}:${now}:${entropy}`).digest('hex').slice(0, 32)
if (!/^[A-Za-z0-9._-]{1,128}$/.test(buildId)) {
throw new Error('XUQM_BUILD_ID must contain only letters, numbers, dot, underscore or hyphen')
}
mkdirSync(path.dirname(cacheFile), { recursive: true })
writeFileSync(
cacheFile,
`${JSON.stringify(
{
versionCode,
buildId,
generatedAt: new Date(now).toISOString(),
},
null,
2,
)}\n`,
)
return { versionCode, buildId }
}

查看文件

@ -0,0 +1,68 @@
import assert from 'node:assert/strict'
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { createAndroidReleaseIdentity } from './release-identity.mjs'
test('release identity increases for repeated builds with the same app version', () => {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-identity-'))
try {
const first = createAndroidReleaseIdentity({
projectRoot: root,
environment: {},
now: 1_800_000_000_000,
entropy: 'first',
})
const second = createAndroidReleaseIdentity({
projectRoot: root,
environment: {},
now: 1_800_000_000_000,
entropy: 'second',
})
assert.equal(second.versionCode, first.versionCode + 1)
assert.notEqual(second.buildId, first.buildId)
const cached = JSON.parse(
readFileSync(path.join(root, '.xuqm-cache', 'release-build.json'), 'utf8'),
)
assert.deepEqual({ versionCode: cached.versionCode, buildId: cached.buildId }, second)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('release identity accepts explicit CI identity and validates its contract', () => {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-identity-ci-'))
try {
assert.deepEqual(
createAndroidReleaseIdentity({
projectRoot: root,
environment: {
XUQM_VERSION_CODE: '123456',
XUQM_BUILD_ID: 'jenkins-123456',
},
now: 1_800_000_000_000,
}),
{ versionCode: 123456, buildId: 'jenkins-123456' },
)
assert.throws(
() =>
createAndroidReleaseIdentity({
projectRoot: root,
environment: { XUQM_VERSION_CODE: 'invalid' },
}),
/XUQM_VERSION_CODE/,
)
assert.throws(
() =>
createAndroidReleaseIdentity({
projectRoot: root,
environment: { XUQM_BUILD_ID: 'contains whitespace' },
}),
/XUQM_BUILD_ID/,
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})

查看文件

@ -0,0 +1,105 @@
import { createHash } from 'node:crypto'
import { readFileSync } from 'node:fs'
import { strFromU8, unzipSync } from 'fflate'
function requiredString(manifest, key) {
const value = manifest?.[key]
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`plugin package manifest ${key} is missing`)
}
return value
}
/**
* 发布身份只能来自即将上传的 ZIP不能根据环境变量或缓存再次推导这样 Bundle
* Source Map 和服务端登记始终使用同一个构建产生的精确身份
*/
export function readReleasePackageIdentity(packageFile, expected) {
const archive = unzipSync(new Uint8Array(readFileSync(packageFile)))
const manifestBytes = archive['rn-manifest.json']
if (!manifestBytes) throw new Error('plugin package manifest is missing')
let manifest
try {
manifest = JSON.parse(strFromU8(manifestBytes))
} catch {
throw new Error('plugin package manifest is invalid JSON')
}
const packageName = requiredString(manifest, 'packageName')
const moduleId = requiredString(manifest, 'moduleId')
const platform = requiredString(manifest, 'platform')
const moduleVersion = requiredString(manifest, 'version')
const appVersion = requiredString(manifest, 'appVersion')
const buildId = requiredString(manifest, 'buildId')
const bundleFile = requiredString(manifest, 'bundleFile')
const bundleHash = requiredString(manifest, 'bundleSha256')
const bundleFormat = requiredString(manifest, 'bundleFormat')
const nativeBaselineId = requiredString(manifest, 'builtAgainstNativeBaselineId')
const appVersionRange = requiredString(manifest, 'appVersionRange')
const minNativeApiLevel = manifest.minNativeApiLevel
if (buildId === 'development') {
throw new Error('plugin package was built without a release buildId; rebuild before publishing')
}
if (expected.packageName !== packageName) {
throw new Error(
`plugin package packageName mismatch: expected ${expected.packageName}, got ${packageName}`,
)
}
if (expected.moduleId !== moduleId) {
throw new Error(
`plugin package moduleId mismatch: expected ${expected.moduleId}, got ${moduleId}`,
)
}
if (expected.platform !== platform) {
throw new Error(
`plugin package platform mismatch: expected ${expected.platform}, got ${platform}`,
)
}
if (expected.moduleVersion !== moduleVersion) {
throw new Error(
`plugin package version mismatch: expected ${expected.moduleVersion}, got ${moduleVersion}`,
)
}
if (expected.appVersion !== appVersion) {
throw new Error(
`plugin package appVersion mismatch: expected ${expected.appVersion}, got ${appVersion}`,
)
}
if (!/^[a-f0-9]{64}$/i.test(bundleHash)) {
throw new Error('plugin package bundleSha256 is invalid')
}
if (!Number.isInteger(minNativeApiLevel) || minNativeApiLevel < 1) {
throw new Error('plugin package minNativeApiLevel is invalid')
}
if (
manifest.commonVersionRange !== undefined &&
(typeof manifest.commonVersionRange !== 'string' || !manifest.commonVersionRange.trim())
) {
throw new Error('plugin package commonVersionRange is invalid')
}
const bundleBytes = archive[bundleFile]
if (!bundleBytes) throw new Error(`plugin package bundle is missing: ${bundleFile}`)
const actualBundleHash = createHash('sha256').update(bundleBytes).digest('hex')
if (actualBundleHash !== bundleHash.toLowerCase()) {
throw new Error('plugin package bundleSha256 does not match bundle content')
}
return {
manifest,
packageName,
moduleId,
platform,
moduleVersion,
appVersion,
buildId,
bundleHash: actualBundleHash,
bundleFormat,
nativeBaselineId,
appVersionRange,
commonVersionRange: manifest.commonVersionRange,
minNativeApiLevel,
}
}

查看文件

@ -0,0 +1,99 @@
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { strToU8, zipSync } from 'fflate'
import { readReleasePackageIdentity } from './release-package.mjs'
function createPackage(root, overrides = {}, bundle = 'bundle') {
const bundleBytes = strToU8(bundle)
const manifest = {
schemaVersion: 1,
packageName: 'com.example.host',
moduleId: 'app',
platform: 'android',
version: '1.0.1',
appVersion: '8.0.0',
buildId: 'build-20260726',
bundleFile: 'app.android.bundle',
bundleSha256: createHash('sha256').update(bundleBytes).digest('hex'),
bundleFormat: 'hermes-bytecode',
builtAgainstNativeBaselineId: 'native-1',
appVersionRange: '>=8.0.0 <9.0.0',
commonVersionRange: '>=1.0.0 <2.0.0',
minNativeApiLevel: 2,
...overrides,
}
const file = path.join(root, 'app.android.xuqm.zip')
writeFileSync(
file,
zipSync({
'rn-manifest.json': strToU8(JSON.stringify(manifest)),
'app.android.bundle': bundleBytes,
}),
)
return file
}
const expected = {
packageName: 'com.example.host',
moduleId: 'app',
platform: 'android',
moduleVersion: '1.0.1',
appVersion: '8.0.0',
}
test('release identity is read from and verified against the packaged Bundle', () => {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-'))
try {
const identity = readReleasePackageIdentity(createPackage(root), expected)
assert.equal(identity.packageName, 'com.example.host')
assert.equal(identity.buildId, 'build-20260726')
assert.equal(identity.bundleFormat, 'hermes-bytecode')
assert.equal(identity.nativeBaselineId, 'native-1')
assert.equal(identity.appVersionRange, '>=8.0.0 <9.0.0')
assert.equal(identity.commonVersionRange, '>=1.0.0 <2.0.0')
assert.equal(identity.minNativeApiLevel, 2)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('publishing requires and exactly matches the packaged packageName', () => {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-name-'))
try {
assert.throws(
() => readReleasePackageIdentity(createPackage(root, { packageName: undefined }), expected),
/manifest packageName is missing/,
)
assert.throws(
() =>
readReleasePackageIdentity(
createPackage(root, { packageName: 'com.example.other' }),
expected,
),
/packageName mismatch: expected com\.example\.host, got com\.example\.other/,
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('publishing rejects development identity and mismatched packaged Bundle hash', () => {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-invalid-'))
try {
assert.throws(
() => readReleasePackageIdentity(createPackage(root, { buildId: 'development' }), expected),
/without a release buildId/,
)
assert.throws(
() =>
readReleasePackageIdentity(createPackage(root, { bundleSha256: 'a'.repeat(64) }), expected),
/does not match bundle content/,
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})

查看文件

@ -0,0 +1,45 @@
import { readFileSync, writeFileSync } from 'node:fs'
import path from 'node:path'
function normalize(value) {
return value.replaceAll('\\', '/')
}
function externalSourceName(normalized) {
const nodeModulesMarker = '/node_modules/'
const markerIndex = normalized.lastIndexOf(nodeModulesMarker)
if (markerIndex >= 0) {
return `external/node_modules/${normalized.slice(markerIndex + nodeModulesMarker.length)}`
}
return `external/${normalized.split('/').filter(Boolean).at(-1) ?? 'unknown-source'}`
}
/**
* root 内绝对路径变成项目相对路径root 外绝对路径只保留稳定的外部标识
* 绝不把 `../` 剥掉后伪装成项目源码也不泄露开发机用户名或目录
*/
export function sanitizeSourcePath(source, projectRoot) {
if (typeof source !== 'string') return source
const normalizedSource = normalize(source)
const normalizedRoot = normalize(projectRoot).replace(/\/+$/, '')
const windowsAbsolute = path.win32.isAbsolute(source)
const posixAbsolute = path.posix.isAbsolute(normalizedSource)
if (!windowsAbsolute && !posixAbsolute) return normalizedSource
const pathApi = windowsAbsolute ? path.win32 : path.posix
const comparableSource = windowsAbsolute ? source : normalizedSource
const comparableRoot = windowsAbsolute ? projectRoot : normalizedRoot
const relative = pathApi.relative(comparableRoot, comparableSource)
const outside =
relative === '..' || relative.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(relative)
return outside ? externalSourceName(normalizedSource) : normalize(relative || '.')
}
export function sanitizeSourceMapFile(sourceMapFile, projectRoot) {
const sourceMap = JSON.parse(readFileSync(sourceMapFile, 'utf8'))
delete sourceMap.sourcesContent
if (Array.isArray(sourceMap.sources)) {
sourceMap.sources = sourceMap.sources.map(source => sanitizeSourcePath(source, projectRoot))
}
writeFileSync(sourceMapFile, JSON.stringify(sourceMap))
}

查看文件

@ -0,0 +1,31 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { sanitizeSourcePath } from './source-map.mjs'
test('Unix sources distinguish project files from external absolute paths', () => {
assert.equal(sanitizeSourcePath('/workspace/app/src/Home.tsx', '/workspace/app'), 'src/Home.tsx')
assert.equal(
sanitizeSourcePath('/Users/alice/shared/secret.ts', '/workspace/app'),
'external/secret.ts',
)
assert.equal(
sanitizeSourcePath('/Users/alice/app/node_modules/react/index.js', '/workspace/app'),
'external/node_modules/react/index.js',
)
})
test('Windows sources distinguish project files without leaking host directories', () => {
assert.equal(
sanitizeSourcePath('C:\\workspace\\app\\src\\Home.tsx', 'C:\\workspace\\app'),
'src/Home.tsx',
)
assert.equal(
sanitizeSourcePath('D:\\Users\\alice\\private\\secret.ts', 'C:\\workspace\\app'),
'external/secret.ts',
)
assert.equal(
sanitizeSourcePath('D:\\cache\\node_modules\\react\\index.js', 'C:\\workspace\\app'),
'external/node_modules/react/index.js',
)
})

查看文件

@ -2,7 +2,8 @@ import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import semver from 'semver'
export const CONFIG_FILE_NAME = 'xuqm.config.json'
/** 插件构建清单;与平台签发的 config.xuqmconfig 初始化文件没有关系。 */
export const CONFIG_FILE_NAME = 'xuqm.modules.json'
export const SUPPORTED_PLATFORMS = new Set(['android', 'ios'])
export const SUPPORTED_MODULE_TYPES = new Set(['startup', 'common', 'app', 'buz'])
@ -26,6 +27,18 @@ export function readConfig(root = process.cwd()) {
export function validateConfig(config, root = process.cwd()) {
const errors = []
if (!config.appId) errors.push('appId is required')
if (
config.packageName !== undefined &&
(typeof config.packageName !== 'string' || config.packageName.trim() === '')
) {
errors.push('packageName must be a non-empty string')
}
if (
config.iosBundleId !== undefined &&
(typeof config.iosBundleId !== 'string' || config.iosBundleId.trim() === '')
) {
errors.push('iosBundleId must be a non-empty string')
}
if (!config.mainModuleName) errors.push('mainModuleName is required')
if (typeof config.pluginVersion !== 'string' || !semver.valid(config.pluginVersion)) {
errors.push('pluginVersion must be SemVer')
@ -33,10 +46,19 @@ export function validateConfig(config, root = process.cwd()) {
if (typeof config.appVersionRange !== 'string' || !semver.validRange(config.appVersionRange)) {
errors.push('appVersionRange must be a valid SemVer range')
}
if (config.release && Object.prototype.hasOwnProperty.call(config.release, 'apiToken')) {
errors.push('release.apiToken is forbidden; Jenkins must inject XUQM_API_TOKEN')
}
if (!Array.isArray(config.modules) || config.modules.length === 0) {
errors.push('modules must contain at least one module')
return errors
}
if (config.modules.length > 1 && !config.metroConfig) {
errors.push('metroConfig is required for multi-module builds')
}
if (config.metroConfig && !existsSync(path.resolve(root, config.metroConfig))) {
errors.push(`Metro config not found: ${config.metroConfig}`)
}
const ids = new Set()
for (const module of config.modules) {
@ -73,10 +95,45 @@ export function validateConfig(config, root = process.cwd()) {
if (module.metroConfig && !existsSync(path.resolve(root, module.metroConfig))) {
errors.push(`Metro config not found for ${module.id}: ${module.metroConfig}`)
}
if (module.ownershipRoots !== undefined) {
if (module.type !== 'common') {
errors.push(`ownershipRoots is only supported by the common module: ${module.id}`)
} else if (!Array.isArray(module.ownershipRoots) || module.ownershipRoots.length === 0) {
errors.push(`ownershipRoots must be a non-empty string array for ${module.id}`)
} else {
for (const ownershipRoot of module.ownershipRoots) {
if (
typeof ownershipRoot !== 'string' ||
ownershipRoot.trim() === '' ||
!existsSync(path.resolve(root, ownershipRoot))
) {
errors.push(
`ownership root not found for ${module.id}: ${ownershipRoot ?? '<missing>'}`,
)
}
}
}
}
}
return errors
}
/**
* wire 协议统一使用 packageName但值必须是目标平台自己的宿主身份所有构建校验
* 与发布入口只能通过这里解析避免 Android applicationId 泄漏到 iOS 插件
*/
export function resolveHostPackageId(config, platform) {
if (!SUPPORTED_PLATFORMS.has(platform)) {
throw new Error(`unsupported platform: ${platform}`)
}
const key = platform === 'ios' ? 'iosBundleId' : 'packageName'
const value = config?.[key]
if (typeof value !== 'string' || value.trim() === '') {
throw new Error(`${key} is required for ${platform} build and release`)
}
return value.trim()
}
export function readPackageVersion(root = process.cwd()) {
const packageFile = path.join(root, 'package.json')
if (!existsSync(packageFile)) throw new Error('package.json is missing')
@ -111,7 +168,7 @@ export function resolveVersionedModules(config, root = process.cwd(), appVersion
...(module.commonVersionRange === undefined && ['app', 'buz'].includes(module.type)
? { commonVersionRange: defaultCommonRange }
: {}),
minNativeApiLevel: module.minNativeApiLevel ?? 1,
minNativeApiLevel: module.minNativeApiLevel ?? 2,
})),
}
}

查看文件

@ -1,15 +1,16 @@
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import test from 'node:test'
import { resolveVersionedModules, validateConfig } from './xuqm-config.mjs'
import { resolveHostPackageId, resolveVersionedModules, validateConfig } from './xuqm-config.mjs'
function fixture() {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-config-'))
writeFileSync(path.join(root, 'package.json'), JSON.stringify({ version: '7.2.14' }))
writeFileSync(path.join(root, 'common.ts'), '')
writeFileSync(path.join(root, 'app.ts'), '')
writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n')
return root
}
@ -18,9 +19,11 @@ test('plugins start at the configured plugin version independently from the app
try {
const config = {
appId: 'example',
packageName: 'com.example.host',
mainModuleName: 'Example',
pluginVersion: '1.0.0',
appVersionRange: '>=7.2.14 <8.0.0',
metroConfig: './metro.config.js',
modules: [
{ id: 'common', type: 'common', entry: './common.ts' },
{ id: 'app', type: 'app', entry: './app.ts' },
@ -32,7 +35,7 @@ test('plugins start at the configured plugin version independently from the app
assert.equal(resolved.modules[0].moduleVersion, '1.0.0')
assert.equal(resolved.modules[1].moduleVersion, '1.0.0')
assert.equal(resolved.modules[1].commonVersionRange, '>=1.0.0 <2.0.0')
assert.equal(resolved.modules[1].minNativeApiLevel, 1)
assert.equal(resolved.modules[1].minNativeApiLevel, 2)
} finally {
rmSync(root, { recursive: true, force: true })
}
@ -61,3 +64,93 @@ test('an explicitly independently released module keeps its own version', () =>
rmSync(root, { recursive: true, force: true })
}
})
test('only common can own existing shared source roots', () => {
const root = fixture()
try {
mkdirSync(path.join(root, 'src', 'common'), { recursive: true })
const base = {
appId: 'example',
packageName: 'com.example.host',
mainModuleName: 'Example',
pluginVersion: '1.0.0',
appVersionRange: '>=1.0.0 <2.0.0',
metroConfig: './metro.config.js',
}
assert.deepEqual(
validateConfig(
{
...base,
modules: [
{
id: 'common',
type: 'common',
entry: './common.ts',
ownershipRoots: ['./src/common'],
},
],
},
root,
),
[],
)
assert.match(
validateConfig(
{
...base,
modules: [
{
id: 'app',
type: 'app',
entry: './app.ts',
ownershipRoots: ['./src/common'],
},
],
},
root,
).join('\n'),
/only supported by the common module/,
)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('release token cannot be stored in xuqm.modules.json', () => {
const root = fixture()
try {
const errors = validateConfig(
{
appId: 'example',
packageName: 'com.example.host',
mainModuleName: 'Example',
pluginVersion: '1.0.0',
appVersionRange: '>=1.0.0 <2.0.0',
metroConfig: './metro.config.js',
release: { apiToken: 'must-not-enter-source' },
modules: [{ id: 'common', type: 'common', entry: './common.ts' }],
},
root,
)
assert.match(errors.join('\n'), /release\.apiToken is forbidden.*XUQM_API_TOKEN/)
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('host package identity resolves exactly once for each target platform', () => {
const config = {
packageName: 'com.example.android',
iosBundleId: 'com.example.ios',
}
assert.equal(resolveHostPackageId(config, 'android'), 'com.example.android')
assert.equal(resolveHostPackageId(config, 'ios'), 'com.example.ios')
assert.throws(
() => resolveHostPackageId({ packageName: 'com.example.android' }, 'ios'),
/iosBundleId is required for ios build and release/,
)
assert.throws(
() => resolveHostPackageId({ iosBundleId: 'com.example.ios' }, 'android'),
/packageName is required for android build and release/,
)
})

查看文件

@ -1,6 +1,7 @@
#!/usr/bin/env node
import { execFileSync } from 'node:child_process'
import { createHash } from 'node:crypto'
import {
copyFileSync,
existsSync,
@ -14,6 +15,7 @@ import {
import { createRequire } from 'node:module'
import path from 'node:path'
import process from 'node:process'
import { strToU8, zipSync } from 'fflate'
import {
CONFIG_FILE_NAME,
SUPPORTED_PLATFORMS,
@ -22,11 +24,16 @@ import {
compatibleAppRange,
parseModuleOptions,
readConfig,
resolveHostPackageId,
resolveVersionedModules,
selectModules,
validateConfig,
} from './xuqm-config.mjs'
import { computeNativeBaseline } from './native-baseline.mjs'
import { parseBugCollectOption, parsePackageOptions } from './package-options.mjs'
import { validateReleaseConfig } from './release-config.mjs'
import { createAndroidReleaseIdentity } from './release-identity.mjs'
import { sanitizeSourceMapFile } from './source-map.mjs'
const root = process.cwd()
const args = process.argv.slice(2)
@ -41,8 +48,15 @@ function writeJson(file, value) {
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
}
function childEnvironment() {
const environment = { ...process.env }
async function validateReleaseConfigOnline(config, platform) {
await validateReleaseConfig({
projectRoot: root,
packageName: resolveHostPackageId(config, platform),
})
}
function childEnvironment(overrides = {}) {
const environment = { ...process.env, ...overrides }
// Metro may set FORCE_COLOR in its transformer workers. Forward a single
// explicit color flag so those workers never inherit a contradictory pair.
if (environment.NO_COLOR !== undefined) {
@ -52,13 +66,13 @@ function childEnvironment() {
return environment
}
function runReactNative(cliArgs) {
function runReactNative(cliArgs, environment = {}) {
const reactNativeCli = path.join(root, 'node_modules', 'react-native', 'cli.js')
if (!existsSync(reactNativeCli)) fail('react-native CLI is missing; install dependencies first')
execFileSync(process.execPath, [reactNativeCli, ...cliArgs], {
stdio: 'inherit',
cwd: root,
env: childEnvironment(),
env: childEnvironment(environment),
})
}
@ -138,10 +152,12 @@ function init() {
writeJson(configFile, {
schemaVersion: 3,
appId: pkg.name ?? 'react-native-app',
packageName: pkg.name ?? 'react-native-app',
mainModuleName: pkg.name ?? 'ReactNativeApp',
pluginVersion: '1.0.0',
appVersionRange: compatibleAppRange(pkg.version ?? '1.0.0'),
outputDir: './bundle',
metroConfig: './metro.config.js',
embeddedOutput: {
android: './android/app/src/main/assets/rn-bundles',
ios: './bundle/embedded/ios/rn-bundles',
@ -233,12 +249,88 @@ function parseSelectedModules(config, optionArgs) {
}
}
/**
* 独立构建 app/buz 时也必须先生成 startup/common 的共享模块表
* 依赖 bundle 只参与本次构建不会因为选择了单个 buz 就被一并发布
*/
function resolveBuildModules(config, selectedModules) {
const selectedIds = new Set(selectedModules.map(module => module.id))
const needsCommon = selectedModules.some(module => ['app', 'buz'].includes(module.type))
const needsStartup = selectedModules.some(module => module.type !== 'startup')
const priorities = { startup: 0, common: 1, app: 2, buz: 3 }
return config.modules
.filter(
module =>
selectedIds.has(module.id) ||
(needsCommon && module.type === 'common') ||
(needsStartup && module.type === 'startup'),
)
.sort((left, right) => priorities[left.type] - priorities[right.type])
}
const OWNED_SOURCE_EXTENSION = /\.(?:js|jsx|ts|tsx)$/
const EXCLUDED_OWNERSHIP_SOURCE =
/(?:^|\/)(?:__tests__|tests?)(?:\/|$)|\.(?:spec|test)\.[^.]+$|\.d\.ts$/
function ownershipSourceFiles(directory, platform) {
const selected = new Map()
for (const relative of walkFiles(directory)) {
if (!OWNED_SOURCE_EXTENSION.test(relative) || EXCLUDED_OWNERSHIP_SOURCE.test(relative)) continue
const platformMatch = relative.match(/\.(android|ios|native|web)(?=\.[^.]+$)/)
const variant = platformMatch?.[1]
if (variant && variant !== platform && variant !== 'native') continue
const key = platformMatch ? relative.replace(`.${variant}`, '') : relative
const priority = variant === platform ? 3 : variant === 'native' ? 2 : 1
const existing = selected.get(key)
if (!existing || priority > existing.priority) {
selected.set(key, { absolute: path.join(directory, relative), priority })
}
}
return [...selected.values()].map(item => item.absolute).sort()
}
/**
* common 可声明若干唯一所有权目录CLI 为当前平台生成一个临时入口把这些目录
* 纳入 common 的依赖图但不执行模块后续 app/buz 会按共享模块表过滤它们
* 目录规则由 SDK 统一实现宿主不再维护易遗漏的 require 清单
*/
function buildEntryFile(module, platform) {
if (module.type !== 'common' || !module.ownershipRoots?.length) return module.entry
const generatedDirectory = path.join(root, '.xuqm-cache', 'ownership')
const generatedFile = path.join(generatedDirectory, `${module.id}.${platform}.js`)
const ownedFiles = module.ownershipRoots.flatMap(ownershipRoot =>
ownershipSourceFiles(path.resolve(root, ownershipRoot), platform),
)
const importPath = file => {
const relative = path.relative(generatedDirectory, file).split(path.sep).join('/')
return relative.startsWith('.') ? relative : `./${relative}`
}
const lines = [
`require(${JSON.stringify(importPath(path.resolve(root, module.entry)))})`,
'',
'function retainXuqmCommonOwnership() {',
...ownedFiles.map(file => ` require(${JSON.stringify(importPath(file))})`),
'}',
'',
'void retainXuqmCommonOwnership',
'',
]
mkdirSync(generatedDirectory, { recursive: true })
writeFileSync(generatedFile, lines.join('\n'))
return generatedFile
}
function build(platform, optionArgs = []) {
assertPlatform(platform)
const config = doctor()
const modules = parseSelectedModules(config, optionArgs)
resolveHostPackageId(config, platform)
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
const versionedConfig = { ...config, modules: versioned.modules }
const modules = parseSelectedModules(versionedConfig, optionArgs)
const buildModules = resolveBuildModules(versionedConfig, modules)
const useHermes = platform === 'android' && androidUsesHermes()
for (const module of modules) {
const nativeBaselineId = computeNativeBaseline(root, platform)
for (const [buildIndex, module] of buildModules.entries()) {
const outputDirectory = bundleOutputDirectory(root, config, platform, module.id)
rmSync(outputDirectory, { force: true, recursive: true })
mkdirSync(outputDirectory, { recursive: true })
@ -250,7 +342,7 @@ function build(platform, optionArgs = []) {
'--dev',
'false',
'--entry-file',
module.entry,
buildEntryFile(module, platform),
'--bundle-output',
bundleFile(root, config, platform, module.id),
'--assets-dest',
@ -259,13 +351,30 @@ function build(platform, optionArgs = []) {
]
const metroConfig = module.metroConfig ?? config.metroConfig
if (metroConfig) cliArgs.push('--config', metroConfig)
if (module.sourceMap === true) {
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))
if (useHermes)
compileHermesBundle(bundleFile(root, config, platform, module.id), module.sourceMap === true)
runReactNative(cliArgs.slice(1), {
XUQM_MODULE_CACHE_FILE: path.join(root, '.xuqm-cache', 'common-module-ids.json'),
XUQM_MODULE_ID: module.id,
XUQM_MODULE_INDEX: String(config.modules.findIndex(item => item.id === module.id)),
XUQM_MODULE_TYPE: module.type,
XUQM_RESET_MODULE_CACHE: buildIndex === 0 ? 'true' : 'false',
})
if (useHermes) compileHermesBundle(bundleFile(root, config, platform, module.id), true)
sanitizeSourceMapFile(`${bundleFile(root, config, platform, module.id)}.map`, root)
// startup 是随宿主 APK 固化的最小引导代码,不属于可在线发布插件。为它生成
// 发布包既没有安装入口,也容易让发布人员误认为它可以脱离整包升级。
if (module.type !== 'startup') {
createModulePackage(
config,
platform,
module,
versioned.appVersion,
nativeBaselineId,
useHermes ? 'hermes-bytecode' : 'javascript',
)
}
}
return { config, modules }
}
@ -283,10 +392,68 @@ function walkFiles(directory, relativePrefix = '') {
function moduleAssetFiles(rootDirectory, bundleName) {
return walkFiles(rootDirectory).filter(
file => file !== bundleName && file !== 'raw/keep.xml' && !file.endsWith('.map'),
file =>
file !== bundleName &&
file !== 'raw/keep.xml' &&
!file.endsWith('.map') &&
!file.endsWith('.xuqm.zip'),
)
}
function modulePackageFile(config, platform, moduleId) {
return path.join(
bundleOutputDirectory(root, config, platform, moduleId),
`${moduleId}.${platform}.xuqm.zip`,
)
}
/**
* 线上插件与 APK 内嵌插件共用同一份模块元数据发布物始终是二进制 ZIP避免
* Hermes 字节码经过 UTF-8 转换损坏并让插件自己的图片字体等资源原子到达
*/
function createModulePackage(config, platform, module, appVersion, nativeBaselineId, bundleFormat) {
const directory = bundleOutputDirectory(root, config, platform, module.id)
const bundleName = `${module.id}.${platform}.bundle`
const bundleBytes = readFileSync(path.join(directory, bundleName))
const assetFiles = moduleAssetFiles(directory, bundleName)
const assets = assetFiles.map(relative => {
const bytes = readFileSync(path.join(directory, relative))
return {
path: relative,
byteLength: bytes.byteLength,
sha256: createHash('sha256').update(bytes).digest('hex'),
}
})
const manifest = {
schemaVersion: 1,
packageName: resolveHostPackageId(config, platform),
moduleId: module.id,
type: module.type,
platform,
version: module.moduleVersion,
appVersion,
appVersionRange: module.appVersionRange,
builtAgainstNativeBaselineId: nativeBaselineId,
bundleFormat,
buildId: process.env.XUQM_BUILD_ID ?? 'development',
bugCollectMode: process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
bundleFile: bundleName,
bundleByteLength: bundleBytes.byteLength,
bundleSha256: createHash('sha256').update(bundleBytes).digest('hex'),
...(module.commonVersionRange ? { commonVersionRange: module.commonVersionRange } : {}),
minNativeApiLevel: module.minNativeApiLevel,
assets,
}
const archive = {
'rn-manifest.json': strToU8(`${JSON.stringify(manifest, null, 2)}\n`),
[bundleName]: new Uint8Array(bundleBytes),
}
for (const asset of assets) {
archive[`assets/${asset.path}`] = new Uint8Array(readFileSync(path.join(directory, asset.path)))
}
writeFileSync(modulePackageFile(config, platform, module.id), zipSync(archive, { level: 9 }))
}
function copyWithoutConflict(source, target, owners, owner) {
const relative = path.relative(path.dirname(target), target)
if (existsSync(target)) {
@ -307,11 +474,14 @@ function createManifest(config, platform, modules, appVersion, nativeBaselineId)
for (const module of modules) {
const directory = bundleOutputDirectory(root, config, platform, module.id)
const bundleName = `${module.id}.${platform}.bundle`
const bundleBytes = readFileSync(path.join(directory, bundleName))
entries[module.id] = {
id: module.id,
type: module.type,
directory: module.directory ?? module.id,
bundleFile: bundleName,
sha256: createHash('sha256').update(bundleBytes).digest('hex'),
byteLength: bundleBytes.byteLength,
moduleVersion: module.moduleVersion,
appVersionRange: module.appVersionRange,
builtAgainstNativeBaselineId: nativeBaselineId,
@ -333,6 +503,8 @@ function createManifest(config, platform, modules, appVersion, nativeBaselineId)
bundleFormat: platform === 'android' && androidUsesHermes() ? 'hermes-bytecode' : 'javascript',
version: config.manifestVersion ?? 1,
nativeBaselineId,
buildId: process.env.XUQM_BUILD_ID ?? 'development',
bugCollectMode: process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
modules: entries,
}
}
@ -375,11 +547,22 @@ function embed(platform, optionArgs = []) {
rmSync(target, { force: true, recursive: true })
mkdirSync(target, { recursive: true })
const owners = new Map()
const assetOwners = new Map()
const nativeBaselineId = computeNativeBaseline(root, platform)
for (const module of versioned.modules) {
const sourceRoot = bundleOutputDirectory(root, config, platform, module.id)
const bundleName = `${module.id}.${platform}.bundle`
for (const relative of [bundleName, ...moduleAssetFiles(sourceRoot, bundleName)]) {
const assets = moduleAssetFiles(sourceRoot, bundleName)
for (const relative of assets) {
const existingOwner = assetOwners.get(relative)
if (existingOwner) {
throw new Error(
`asset ownership collision: ${relative} belongs to both ${existingOwner} and ${module.id}; move shared assets into common ownershipRoots`,
)
}
assetOwners.set(relative, module.id)
}
for (const relative of [bundleName, ...assets]) {
copyWithoutConflict(
path.join(sourceRoot, relative),
path.join(target, relative),
@ -398,33 +581,82 @@ function embed(platform, optionArgs = []) {
}
function publish(platform, optionArgs) {
const { moduleIds } = parseModuleOptions(optionArgs)
const { bugCollectMode, remaining } = parseBugCollectOption(
optionArgs,
process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
)
const { moduleIds } = parseModuleOptions(remaining)
const buildOptions = moduleIds.flatMap(moduleId => ['--module', moduleId])
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
const config = doctor()
await validateReleaseConfigOnline(config, platform)
const buildIdentity = createAndroidReleaseIdentity({ projectRoot: root })
const androidDirectory = path.join(root, 'android')
const wrapper = path.join(
androidDirectory,
process.platform === 'win32' ? 'gradlew.bat' : 'gradlew',
)
if (!existsSync(wrapper)) fail('Android Gradle wrapper is missing')
execFileSync(wrapper, [apk ? 'assembleRelease' : 'bundleRelease'], {
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) {
@ -485,7 +717,7 @@ try {
publish(args.shift() ?? 'android', args)
break
case 'package':
packageApp(args.shift() ?? 'android', args)
await packageApp(args.shift() ?? 'android', args)
break
default:
printHelp()

某些文件未显示,因为此 diff 中更改的文件太多 显示更多