feat(sdk): harden automatic config and plugin releases
这个提交包含在:
父节点
10977341d1
当前提交
3d2134e08a
1
.gitignore
vendored
1
.gitignore
vendored
@ -7,6 +7,7 @@ target/
|
||||
build/
|
||||
.gradle/
|
||||
.native-gradle/
|
||||
.xuqm-cache/
|
||||
*.iml
|
||||
.idea/
|
||||
*.log
|
||||
|
||||
37
README.md
37
README.md
@ -4,14 +4,14 @@ XuqmGroup 通用 React Native SDK 工作区。`@xuqm/rn-common` 是唯一基础
|
||||
|
||||
## 包结构
|
||||
|
||||
| 包 | 用途 | 可独立使用 |
|
||||
| --- | --- | --- |
|
||||
| `@xuqm/rn-common` | 可选 SDK 上下文、网络、文件、时间、加密、设备与通用 UI | 是,且无需初始化或登录 |
|
||||
| `@xuqm/rn-update` | App 更新、RN 插件注册、下载、校验与运行 | 否,依赖 common |
|
||||
| `@xuqm/rn-xwebview` | WebView、JSBridge、权限与下载 | 否,依赖 common |
|
||||
| `@xuqm/rn-bugcollect` | 错误、事件、漏斗和批量上报 | 否,依赖 common |
|
||||
| `@xuqm/rn-im` | IM 连接、消息和本地数据 | 否,依赖 common |
|
||||
| `@xuqm/rn-push` | 多厂商原生推送桥接 | 否,依赖 common |
|
||||
| 包 | 用途 | 可独立使用 |
|
||||
| --------------------- | ------------------------------------------------------ | ---------------------- |
|
||||
| `@xuqm/rn-common` | 可选 SDK 上下文、网络、文件、时间、加密、设备与通用 UI | 是,且无需初始化或登录 |
|
||||
| `@xuqm/rn-update` | App 更新、RN 插件注册、下载、校验与运行 | 否,依赖 common |
|
||||
| `@xuqm/rn-xwebview` | WebView、JSBridge、权限与下载 | 否,依赖 common |
|
||||
| `@xuqm/rn-bugcollect` | 错误、事件、漏斗和批量上报 | 否,依赖 common |
|
||||
| `@xuqm/rn-im` | IM 连接、消息和本地数据 | 否,依赖 common |
|
||||
| `@xuqm/rn-push` | 多厂商原生推送桥接 | 否,依赖 common |
|
||||
|
||||
根包 `@xuqm/rn-sdk` 只用于仓库内聚合开发,不对外发布。应用应按需安装具体包,避免引入未使用的原生能力。
|
||||
|
||||
@ -46,34 +46,31 @@ pnpm add @xuqm/rn-update @xuqm/rn-xwebview @xuqm/rn-bugcollect
|
||||
把平台生成的 `config.xuqmconfig` 放在 `src/assets/config/`,然后只包装一次 Metro 配置。使用 update 插件化时采用 update 组合器(它已包含 common 自动初始化):
|
||||
|
||||
```js
|
||||
const {getDefaultConfig} = require('@react-native/metro-config');
|
||||
const {withXuqmModuleConfig} = require('@xuqm/rn-update/metro');
|
||||
const { getDefaultConfig } = require('@react-native/metro-config')
|
||||
const { withXuqmModuleConfig } = require('@xuqm/rn-update/metro')
|
||||
|
||||
module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname));
|
||||
module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname))
|
||||
```
|
||||
|
||||
不使用 update 时仍调用 `@xuqm/rn-common/metro` 的 `withXuqmConfig()`。两种组合器都会把初始化模块注册为 Metro pre-main module,避免 `inlineRequires` 延迟扩展包加载。应用登录成功后只更新一次公共会话:
|
||||
|
||||
```ts
|
||||
import {XuqmSDK} from '@xuqm/rn-common';
|
||||
import { XuqmSDK } from '@xuqm/rn-common'
|
||||
|
||||
await XuqmSDK.awaitInitialization();
|
||||
await XuqmSDK.awaitInitialization()
|
||||
await XuqmSDK.login({
|
||||
userId: 'user-id',
|
||||
accessToken: 'http-access-token',
|
||||
userSig: 'optional-im-credential',
|
||||
});
|
||||
})
|
||||
|
||||
await XuqmSDK.logout();
|
||||
await XuqmSDK.logout()
|
||||
```
|
||||
|
||||
扩展包通过 `@xuqm/rn-common/internal` 接收同一次会话变化。宿主业务代码不得导入该内部子路径,也不得逐级传递公共 token 或用户状态。
|
||||
|
||||
无法使用配置文件自动初始化时,才调用一次:
|
||||
|
||||
```ts
|
||||
await XuqmSDK.initialize({appKey: 'app-key'});
|
||||
```
|
||||
平台签发配置与 Metro 自动初始化是扩展 SDK 的唯一初始化入口;不提供手动
|
||||
`initialize`。common-only 宿主不放配置,也不初始化。
|
||||
|
||||
## 开发与验证
|
||||
|
||||
|
||||
@ -4,6 +4,78 @@
|
||||
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
|
||||
> 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。
|
||||
|
||||
## 2026-07-26 / 当前实施状态
|
||||
|
||||
- common 已增加 `idle / initializing / ready / degraded / failed` 初始化状态、
|
||||
`XuqmError` 结构化错误、七天最后成功配置缓存,以及最多三次的指数退避抖动。
|
||||
平台暂不可用时优先进入 degraded;没有缓存时扩展返回 `XUQM_NOT_READY`,不向宿主
|
||||
制造未处理异常。最后成功配置使用 AES-GCM 完整性加密,密钥由签发配置和
|
||||
app/package/environment 上下文派生且不写入缓存;这里是应用沙箱内防明文与防篡改
|
||||
边界,不宣称具备 Android Keystore/iOS Keychain 的硬件保密性。
|
||||
- 扩展 SDK 的唯一正式初始化入口是平台签发 `.xuqmconfig` 与 Metro 自动初始化;
|
||||
`XuqmSDK.initialize(options)` 及公开 `XuqmInitOptions` 已删除,不保留兼容壳。
|
||||
common-only 不初始化。未使用 `withXuqmConfig` 时,公开
|
||||
`@xuqm/rn-common/auto-init-config` 空实现保证 Metro 能完成静态解析;真实 Metro
|
||||
fixture 已覆盖无配置构建。
|
||||
- 唯一隐私入口为 `XuqmSDK.setPrivacyConsent(boolean)`。BugCollect 只有在平台启用、
|
||||
用户已同意且非 Debug 时自动注册采集;Debug 单次验证使用
|
||||
`BugCollect.sendTestErrorAndFlush()`,宿主不再手动 `startCapture()`。
|
||||
- BugCollect 收到 `BUGCOLLECT_DISABLED` 会立即停用并清除队列;上传请求不进入自身
|
||||
HTTP 拦截链。部署中的 `{code:403,status:'1',message:'BUGCOLLECT_DISABLED'}` 同样
|
||||
识别。持久化事件最多 500 条、保留七天,复用 common AES-GCM 沙箱加密,失败不会
|
||||
先删除队列。冷启动首次获得“平台关闭”配置时,即使尚未创建内存 LogQueue,也会
|
||||
直接删除该 appKey 的遗留加密队列;Debug 单次测试上传同样要求隐私同意。
|
||||
- update 从原生 manifest/state 提供当前 Bundle 精确身份;BugCollect 仅在 stack 唯一
|
||||
命中模块时附带 `buildId/moduleId/moduleVersion/bundleHash`。无法唯一归属时标记
|
||||
`symbolicationStatus=unavailable`,不伪造 latest 身份。
|
||||
- Update 使用平台动态 `updateRequiresLogin`。需要登录时未登录正常跳过;登录后自动
|
||||
补检一次并通过 `onAutomaticCheck` 交给宿主决定 UI。账号变化会取消旧下载授权和
|
||||
灰度缓存;后台补检失败不影响宿主登录。服务端权威字段
|
||||
`allowAnonymousUpdateCheck` 缺失时安全默认禁止匿名检查。
|
||||
- Debug 的 `XuqmRuntime.start()` 不做远程整包或插件检查;开发页仍可显式调用公开
|
||||
check/install API 做一次性验证。
|
||||
- 插件构建清单已从 `xuqm.config.json` 更名为 `xuqm.modules.json`;租户平台签发的
|
||||
`.xuqmconfig` 仍是唯一初始化源。Metro 使用 `src/assets/config/` 内唯一文件,
|
||||
保留平台下载文件名;零个保持 common-only,多个构建失败。Android/iOS 生成目录
|
||||
中的构建副本才固定命名为 `config.xuqmconfig`。
|
||||
- Metro 只接受严格 `XUQM-CONFIG-V2`:使用内置 `keyId` 公钥先做 Ed25519 验签,再
|
||||
解密并校验 canonical JSON、Schema、UUID、revision 和有效期。V1、未知 keyId 与
|
||||
签名失败在构建期阻断。当前内置开发公钥为 `xuqm-config-dev-2026-07`,私钥未入仓。
|
||||
- Release 每个模块始终生成独立 Source Map,删除 `sourcesContent` 并清理绝对路径。
|
||||
`--bugcollect=disabled` 只归档不上传;启用时按完整 Bundle 身份上传,失败阻断发布。
|
||||
上传契约固定为 `POST /bugcollect/v1/artifacts/upload` 且
|
||||
`artifactType=RN_SOURCEMAP`;旧 Metro serializer 上传器已删除。发布登记和
|
||||
Source Map 上传的 `buildId/moduleId/moduleVersion/bundleHash` 均从待上传 ZIP 的
|
||||
`rn-manifest.json` 和真实 Bundle 校验后取得,禁止根据环境变量二次推导身份;
|
||||
artifact 上传与插件发布使用同一 Jenkins 发布 Bearer,匿名请求不会发出。
|
||||
- 整包和插件发布前调用 `/api/sdk/build/config/validate`。服务不可达、配置过期、
|
||||
revision 撤销或包名不匹配均阻断新 Release;已安装 App 运行时不查询吊销状态。
|
||||
- 相同 versionName 的连续 Android Release 自动生成递增 versionCode 和独立 buildId;
|
||||
同一工作区通过 `.xuqm-cache/release-build.json` 保证单调递增,跨工作区 CI 通过
|
||||
`XUQM_VERSION_CODE` 和 `XUQM_BUILD_ID` 注入全局唯一流水线身份。
|
||||
- Gradle 内嵌任务把 `buildId` 与 `bugCollectMode` 声明为显式 `@Input` 并传入 embed
|
||||
子进程;测试验证 APK 内嵌 manifest 和模块 ZIP 使用同一 Release identity。
|
||||
`package` 与 `publish` 共用唯一 `--bugcollect` 参数解析器,等号和空格写法一致。
|
||||
- Android 相同模块版本使用 SHA-256 识别新版 APK 内容;真正更高且兼容 native
|
||||
baseline 的远程插件继续保留。新 release 允许两次进程启动握手,失败 releaseId
|
||||
本机隔离,服务端内容未变化前不重复安装。
|
||||
- 原生最终恢复入口只删除 SDK 私有 `files/rn-bundles`,并递增 reset generation;
|
||||
common/app 初始化后仅清除 `xuqm_update_*` 状态并确认该 generation,重试不会误清
|
||||
宿主、BugCollect 或签名 SDK 数据。
|
||||
- 已执行全部包类型检查、common 30 项、bugcollect 11 项、update CLI 25 项、update
|
||||
领域 13 项、xwebview 7 项测试、update 包内容校验、Prettier 和 `git diff --check`,
|
||||
均通过;update 发布包内容为 49 个文件。`pnpm validate` 本次因 Corepack/pnpm
|
||||
尝试联网读取 registry 且要求交互确认重建 `node_modules` 而未执行成功,已使用仓内
|
||||
已安装工具逐项执行等价门禁。`native-test` 的 Gradle 在任务配置前因本机
|
||||
`libnative-platform.dylib` 无法加载而失败,不能据此声称本轮原生 Java 已编译。
|
||||
尚未执行宿主 Android 构建、设备运行、开发环境接口和真实 Source Map 上传;这些
|
||||
结果不得从静态检查推断。
|
||||
- License 离线激活 SDK 已审计:RNSDK 不存在对应包、API、依赖、脚本、文档或空目录;
|
||||
npm 的 `license: UNLICENSED` 元数据和 RN 插件事务 `activation` 不属于离线授权。
|
||||
- 配置中的 `signingKey` 明确是客户端可提取的请求完整性/缓存派生材料,不是 License、
|
||||
用户授权或服务端秘密;敏感 API 仍依赖登录 `accessToken`,构建制品上传依赖独立
|
||||
Jenkins Bearer,并由服务端执行授权。
|
||||
|
||||
## 1. 本轮目标
|
||||
|
||||
1. `rn-common` 可以完全独立使用公共工具,不要求初始化或登录。
|
||||
@ -75,9 +147,9 @@
|
||||
- common-only 宿主缺少配置文件时不会触发自动初始化,已有测试覆盖。
|
||||
- 配置文件自动初始化遇到临时网络失败时会清理失败 Promise;扩展下一次等待初始化时使用同一加密配置重试,不要求宿主重新传 appKey/URL,也不会产生未处理 Promise 或重复错误日志。
|
||||
- bugcollect、update、xwebview 通过 `@xuqm/rn-common/internal` 触发一次自动初始化入口。
|
||||
- update CLI 已能从 schema v3 `xuqm.config.json` 构建 startup/common/app/buz,生成携带 SemVer 兼容范围和原生 API 等级的内嵌 manifest。
|
||||
- update CLI 已能从 schema v3 `xuqm.modules.json` 构建 startup/common/app/buz,生成携带 SemVer 兼容范围和原生 API 等级的内嵌 manifest。
|
||||
- update 的 `withXuqmModuleConfig()` 是多 Bundle 模块编号的唯一实现:日常 Metro 不变,CLI 构建时自动先生成 startup/common 共享模块表,并按配置序号为每个 app/buz 分配独立区间;宿主不再维护多份 Metro 配置或业务名硬编码 offset。
|
||||
- Android bundle 引擎不在 `xuqm.config.json` 重复声明:CLI 直接读取宿主 `android/gradle.properties` 的 `hermesEnabled`。启用时所有插件统一编译为 Hermes bytecode、组合 source map,并在 manifest/上传表单记录 `bundleFormat`。
|
||||
- Android bundle 引擎不在 `xuqm.modules.json` 重复声明:CLI 直接读取宿主 `android/gradle.properties` 的 `hermesEnabled`。启用时所有插件统一编译为 Hermes bytecode、组合 source map,并在 manifest/上传表单记录 `bundleFormat`。
|
||||
- common 的唯一 XWebView 契约新增实时导航状态;全屏与内嵌容器统一提供 `canGoBack`、`canGoForward`、当前标题和 URL,并支持宿主配置回调。Bridge 不再重复声明第二份 controller 类型。
|
||||
- RN update 已有 release-set 纯领域规划器;Android 原生模块已改为整组 staging/激活/确认/回滚,并从 APK assets 恢复内嵌基线。
|
||||
|
||||
@ -238,7 +310,7 @@ pnpm --dir packages/update test
|
||||
- 更新检查改为按入口依赖闭包:启动时整包优先,其后 app+common;buz 进入前只检查当前 buz+common。
|
||||
- SDK 不决定宿主 UI:整包与插件均提供独立检查/安装 API;App4 进入 buz 使用 `checkAndInstallPlugin`,需要弹窗的宿主使用 `checkPluginRelease` 后再调用 `installPluginRelease`。
|
||||
- release-set 记录检查时的全部本地基线版本;弹窗停留期间状态变化会触发 `StaleReleaseSetError`,不会安装过期计划。
|
||||
- `xuqm.config.json` 统一升级到 schema v3:`commonVersionRange` + `minNativeApiLevel`,删除 `minCommonVersion`/`minNativeVersion` 双重语义。
|
||||
- `xuqm.modules.json` 统一使用 schema v3:`commonVersionRange` + `minNativeApiLevel`,删除 `minCommonVersion`/`minNativeVersion` 双重语义。
|
||||
- 验证:update typecheck、CLI 6 测试、release-set 6 测试通过;Android 原生代码尚待宿主工程编译验证。
|
||||
|
||||
### 2026-07-17 / Android 整包安装桥接
|
||||
|
||||
@ -18,19 +18,25 @@ await XuqmSDK.login({
|
||||
await XuqmSDK.logout()
|
||||
```
|
||||
|
||||
- `initialize(options)`:仅在不能使用配置文件自动初始化时调用。
|
||||
- `awaitInitialization()`:等待唯一初始化完成;未配置且未手动初始化会抛出明确错误。
|
||||
- `awaitInitialization()`:等待唯一自动初始化完成;缺少平台签发配置或 Metro 集成时
|
||||
抛出明确错误。
|
||||
- `login(options)`:唯一登录入口;同一会话重复调用幂等,不同会话串行切换。
|
||||
- `setPrivacyConsent(consented)`:唯一隐私同意同步入口;扩展 SDK 不自行弹窗。
|
||||
- `getInitializationState()`:返回 `idle / initializing / ready / degraded / failed` 及
|
||||
可选结构化错误。
|
||||
- `logout()`:唯一登出入口。
|
||||
- `getUserId()` / `getUserInfo()`:只读获取当前会话。
|
||||
|
||||
不提供 `setUserInfo`、`setUserId` 或公开的配置文件初始化入口。
|
||||
不提供 `initialize`、`setUserInfo`、`setUserId` 或公开的配置文件初始化入口。
|
||||
|
||||
### 地址职责
|
||||
|
||||
- `platformUrl`:Xuqm 租户平台地址。远程配置、整包更新、插件 release set 检查等平台接口只使用该地址;自动配置未提供时使用 SDK 内置的公有平台地址。
|
||||
- `apiUrl`:租户平台返回的业务扩展服务地址,只供明确声明使用该业务服务的扩展能力读取,不能替代 `platformUrl`。
|
||||
- common 的 `apiRequest()` 默认基础地址在 SDK 初始化后固定为 `platformUrl`;访问其它业务服务时必须传绝对 URL 或在 common-only 模式显式 `configureHttp()`,禁止用远程 `apiUrl` 静默改写平台请求。
|
||||
- 配置中的 `signingKey` 是可被客户端提取的请求完整性材料,不代表用户身份或接口
|
||||
授权;敏感接口必须继续校验登录 `accessToken`,构建制品上传使用 Jenkins 发布
|
||||
Bearer。
|
||||
|
||||
## common 通用能力
|
||||
|
||||
@ -39,17 +45,21 @@ await XuqmSDK.logout()
|
||||
- `apiRequest`、`configureHttp`、`useRequest`、`useApi`、`usePageApi`
|
||||
- `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`
|
||||
- `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`
|
||||
- `decryptXuqmFile`、`hmacSha256Base64Url`
|
||||
- `hmacSha256Base64Url`、`sha256Hex`
|
||||
- `getDeviceId`、`getDeviceInfo`、`detectPushVendor`
|
||||
- `showToast`、`showAlert`、`showConfirm`、`ScaledImage`
|
||||
|
||||
## 扩展包
|
||||
|
||||
- `@xuqm/rn-update`:`UpdateSDK`、`XuqmRuntime`、`definePlugin` 和 `xuqm-rn` CLI。
|
||||
原生最终恢复页使用 `UpdateSDK.resetToEmbedded()`;它只清理 Update SDK 私有
|
||||
Bundle/发布状态,不影响宿主账号、业务数据、BugCollect 或签名 SDK。
|
||||
- `@xuqm/rn-xwebview`:根部一次挂载 `XWebViewHost`,业务使用
|
||||
`openWebView(config)` 打开全屏页面;`XWebViewView` 只用于内嵌浏览器。业务
|
||||
JSBridge 通过 `config.bridge` 注入,不属于 XWebView 自身协议。
|
||||
- `@xuqm/rn-bugcollect`:`BugCollect` 错误、事件、漏斗和队列上报。
|
||||
Debug 单次联调使用 `BugCollect.sendTestErrorAndFlush()`,App 不调用
|
||||
`startCapture()`。
|
||||
- `@xuqm/rn-im`:在 common 会话含 `userSig` 且服务启用时连接。
|
||||
- `@xuqm/rn-push`:在 common 会话变化时同步原生推送绑定。
|
||||
|
||||
|
||||
@ -9,7 +9,7 @@ pnpm exec xuqm-rn init
|
||||
pnpm xuqm:doctor
|
||||
```
|
||||
|
||||
`init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件配置文件为根目录 `xuqm.config.json`。
|
||||
`init` 只创建缺失的统一配置和必要脚本,不覆盖宿主已有代码。唯一插件构建清单为根目录 `xuqm.modules.json`。它与租户平台签发、宿主不可编辑的 `config.xuqmconfig` 初始化文件职责完全分离。
|
||||
|
||||
开发启动也使用同一 CLI,不由宿主复制 Node 包装脚本:
|
||||
|
||||
@ -30,7 +30,7 @@ pnpm exec xuqm-rn plugin create prescription
|
||||
|
||||
- 以小写字母开头
|
||||
- 只包含小写字母、数字和连字符
|
||||
- 在 `xuqm.config.json` 中唯一
|
||||
- 在 `xuqm.modules.json` 中唯一
|
||||
|
||||
CLI 会生成:
|
||||
|
||||
@ -40,7 +40,7 @@ src/plugins/prescription/
|
||||
PrescriptionScreen.tsx
|
||||
```
|
||||
|
||||
并向 `xuqm.config.json.modules` 增加唯一模块声明。不会修改 Babel alias、TypeScript paths、Metro offset 或增加模块专属 package script。
|
||||
并向 `xuqm.modules.json.modules` 增加唯一模块声明。不会修改 Babel alias、TypeScript paths、Metro offset 或增加模块专属 package script。
|
||||
|
||||
## 插件入口
|
||||
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
|
||||
`@xuqm/rn-common` 是唯一可以独立使用的基础包,负责:
|
||||
|
||||
- 为官方扩展提供从 `src/assets/config/config.xuqmconfig` 自动初始化的共享上下文;common 单独使用时不启动初始化;
|
||||
- 为官方扩展提供从 `src/assets/config/` 内唯一 `.xuqmconfig` 自动初始化的共享上下文;common 单独使用时不启动初始化;
|
||||
- 维护唯一的 SDK 配置和用户会话;
|
||||
- 网络、访问令牌、签名、文件、时间、加密、设备信息和通用 UI;
|
||||
- 将一次 `XuqmSDK.login()` / `logout()` 传播给官方扩展包。
|
||||
|
||||
@ -8,7 +8,7 @@ Xuqm RN 扩展 SDK 只支持一种自动初始化方案:宿主保存一份平
|
||||
|
||||
- 不放 `.xuqmconfig`
|
||||
- 不使用 `withXuqmConfig()`
|
||||
- 不调用 `XuqmSDK.initialize()`
|
||||
- 不存在也不需要调用 `XuqmSDK.initialize()`
|
||||
- 不调用 `XuqmSDK.login()`
|
||||
|
||||
common-only 模式保持零初始化、零登录。
|
||||
@ -18,14 +18,12 @@ common-only 模式保持零初始化、零登录。
|
||||
安装 update、bugcollect、xwebview 等扩展后,将平台签发的原始文件放到:
|
||||
|
||||
```text
|
||||
src/assets/config/config.xuqmconfig
|
||||
src/assets/config/<平台下载的文件名>.xuqmconfig
|
||||
```
|
||||
|
||||
文件名也可以是其他 `*.xuqmconfig`。扫描优先级为:
|
||||
|
||||
1. `config.xuqmconfig`
|
||||
2. `config.xuqm`
|
||||
3. 目录中的第一个 `*.xuqmconfig`
|
||||
文件名由租户平台决定,不要求宿主改名。该目录没有 `.xuqmconfig` 时保持
|
||||
common-only;恰好一个时自动使用;存在多个时构建明确失败,禁止按名称或排序猜测
|
||||
权威配置。不会扫描其它目录。
|
||||
|
||||
Metro 配置只包装一次:
|
||||
|
||||
@ -44,9 +42,11 @@ module.exports = withXuqmConfig(baseConfig)
|
||||
|
||||
```text
|
||||
Metro 读取唯一 .xuqmconfig
|
||||
→ 验证平台 Ed25519 签名
|
||||
→ 校验 V2 Schema、有效期和 canonical JSON
|
||||
→ AES-GCM 解密
|
||||
→ 生成构建期传输模块
|
||||
→ 注册 @xuqm/rn-common/internal 为 pre-main module
|
||||
→ 解密配置
|
||||
→ 使用 appKey 请求远程 SDK 配置
|
||||
→ 初始化唯一 HTTP/扩展上下文
|
||||
→ 执行业务入口
|
||||
@ -56,29 +56,34 @@ pre-main 机制保证初始化不受 Metro `inlineRequires` 和业务模块加
|
||||
|
||||
## 原生配置同步
|
||||
|
||||
`withXuqmConfig()` 会把同一份源文件幂等同步到:
|
||||
Android Release 构建任务会在 V2 验签和 Bundle 构建成功后,把同一份源文件复制到:
|
||||
|
||||
```text
|
||||
android/app/src/main/assets/config/config.xuqmconfig
|
||||
ios/<App>/config/config.xuqmconfig
|
||||
android/app/build/generated/xuqm/assets/config/config.xuqmconfig
|
||||
```
|
||||
|
||||
这些位置是构建目标,不是第二配置源。宿主只维护 `src/assets/config` 下的原文件。
|
||||
该位置是被 Git 排除的固定构建目标,不是第二配置源。宿主只维护
|
||||
`src/assets/config/` 中平台下载的唯一 `.xuqmconfig`;不得把副本写进 Android/iOS
|
||||
源码目录。
|
||||
|
||||
## 加密文件
|
||||
|
||||
文件格式:
|
||||
|
||||
```text
|
||||
XUQM-CONFIG-V1.{salt}.{iv}.{ciphertextAndTag}
|
||||
XUQM-CONFIG-V2.{keyId}.{salt}.{iv}.{ciphertextAndTag}.{signature}
|
||||
```
|
||||
|
||||
- PBKDF2-HMAC-SHA256
|
||||
- AES-256-GCM
|
||||
- Ed25519 验签覆盖前五段的精确 ASCII,必须先验签再解密
|
||||
- `keyId` 只能命中 SDK 内置的 X.509 Ed25519 公钥集合
|
||||
- PBKDF2-HMAC-SHA256 + AES-256-GCM 仅用于混淆正文
|
||||
- 12 字节 IV
|
||||
- 16 字节认证标签
|
||||
|
||||
解密后的必要字段为 `appKey`,可选字段包括 `serverUrl`、`baseUrl` 和 `signingKey`。`serverUrl` 优先、`baseUrl` 次之,二者都表示 Xuqm 租户平台地址并在运行时归一为 `platformUrl`;它们不是 App 业务接口地址。明文结构由平台负责签发,业务仓库不得自行构造。
|
||||
明文必须是键按字典序排列、无空白、忽略 null 的 canonical JSON。必要字段为
|
||||
`schemaVersion=2`、`configId`、正整数 `revision`、`issuedAt`、`appKey`、`appName`、
|
||||
`serverUrl` 和 `signingKey`;`expiresAt` 省略表示长期有效。平台重新生成配置后,
|
||||
旧 `configId/revision` 只会阻止后续 Release 构建,不会追溯影响已经安装的 App。
|
||||
|
||||
远程配置响应中的 `apiUrl` 是业务扩展服务地址,与配置文件中的平台地址职责不同:
|
||||
|
||||
@ -121,7 +126,9 @@ await XuqmSDK.logout()
|
||||
## 失败规则
|
||||
|
||||
- 扩展项目缺少配置:`awaitInitialization()` 明确报错。
|
||||
- 解密失败或远程配置失败:初始化 Promise 拒绝并保留原始错误。
|
||||
- 签名、V2 Schema、canonical JSON 或有效期校验失败:构建立即失败。
|
||||
- 远程配置失败且存在七天内的最后成功配置:进入 `degraded` 并继续启动。
|
||||
- 首次启动没有缓存:扩展能力返回 `XUQM_NOT_READY`,宿主核心业务继续运行。
|
||||
- 不自动切换到硬编码配置,不静默创建默认租户,不提供多级兼容回退。
|
||||
- common-only 项目没有配置时不会执行初始化,也不会报错。
|
||||
|
||||
@ -131,3 +138,12 @@ await XuqmSDK.logout()
|
||||
- 不提交明文 appKey、signingKey 或租户密钥。
|
||||
- 不在多个目录手工维护配置副本。
|
||||
- `.xuqmconfig` 变更必须通过平台重新签发。
|
||||
- 最后成功配置不会明文写入 AsyncStorage。SDK 使用签发配置中的 `signingKey`、
|
||||
appKey、包名和平台地址派生独立 AES-GCM 缓存密钥,密钥材料不落缓存;篡改、
|
||||
跨包或跨环境读取都会失败。该边界属于应用沙箱内加密,不宣称具备 Android
|
||||
Keystore/iOS Keychain 的硬件密钥保密性。
|
||||
- `signingKey` 是随客户端配置交付、可被终端提取的请求签名材料,只用于请求完整性
|
||||
标记和本地缓存派生,不是 License、授权凭据或服务端秘密,服务端不得仅凭该签名
|
||||
授予敏感权限。
|
||||
- 用户数据、灰度发布、上传构建制品等敏感 API 仍必须使用登录得到的用户
|
||||
`accessToken` 或 Jenkins 发布流水线的独立 Bearer,并由服务端执行真实授权。
|
||||
|
||||
@ -24,8 +24,18 @@ try {
|
||||
}
|
||||
```
|
||||
|
||||
初始化只由 common 自动完成,登录只由 `XuqmSDK.login()` 完成。配置中启用 bugcollect 后,SDK 在共享初始化完成时自动执行一次 `startCapture()`;手工调用仍然幂等,但正常集成不需要调用。`BugCollect.flush()` 可在 App 转入后台前主动刷新队列。
|
||||
初始化只由 common 自动完成,登录只由 `XuqmSDK.login()` 完成。宿主通过
|
||||
`XuqmSDK.setPrivacyConsent(true)` 同步已取得的隐私同意;只有平台启用、用户同意且
|
||||
非 Debug 三个条件同时满足时,SDK 才自动注册采集。不存在宿主手工启动入口。
|
||||
`BugCollect.flush()` 可在 App 转入后台前主动刷新队列;Debug 开发页使用
|
||||
`BugCollect.sendTestErrorAndFlush()` 做一次性真实上报验证。
|
||||
|
||||
错误指纹使用 common 的统一 SHA-256 实现,事件里的 SDK 版本直接读取当前发布包版本,不再维护第二份硬编码版本号。
|
||||
|
||||
SourceMap 上传使用独立打包后处理流程;不要再叠加基于 Metro `customSerializer` 的旧插件方案,以免破坏新版 Metro 的 exports 与序列化流程。
|
||||
|
||||
fatal/error 持久队列复用 common 的 AES-GCM 沙箱加密能力;没有签发密钥时不降级写入
|
||||
包含堆栈和设备信息的明文。Issue 只有在错误堆栈能唯一匹配当前原生 Bundle
|
||||
state/manifest 时才携带 `buildId/moduleId/moduleVersion/bundleHash` 并标记
|
||||
`symbolicationStatus=exact`;无法归属时明确为 `unavailable`,服务端不得猜测 latest
|
||||
Source Map。
|
||||
|
||||
@ -1,220 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const fs = require('fs')
|
||||
const path = require('path')
|
||||
const https = require('https')
|
||||
const http = require('http')
|
||||
|
||||
/**
|
||||
* 读取配置文件获取 bugCollectApiUrl
|
||||
*/
|
||||
function readConfig(projectRoot) {
|
||||
// 1. 尝试读取 xuqm.config.js
|
||||
const configJsPath = path.join(projectRoot, 'xuqm.config.js')
|
||||
if (fs.existsSync(configJsPath)) {
|
||||
try {
|
||||
const config = require(configJsPath)
|
||||
if (config.bugCollectApiUrl) return config.bugCollectApiUrl
|
||||
if (config.platformUrl) return config.platformUrl.replace(/\/$/, '') + '/api'
|
||||
} catch (e) {
|
||||
console.warn('[BugCollect] Failed to read xuqm.config.js:', e.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 2. 尝试读取 .xuqmconfig (加密配置文件)
|
||||
const configDirs = ['config', 'xuqm']
|
||||
for (const dir of configDirs) {
|
||||
const configDir = path.join(projectRoot, 'src', 'assets', dir)
|
||||
if (!fs.existsSync(configDir)) continue
|
||||
|
||||
const files = fs.readdirSync(configDir)
|
||||
const configFile = files.find(f => f.endsWith('.xuqmconfig') || f.endsWith('.xuqm'))
|
||||
if (configFile) {
|
||||
// 加密配置文件需要解密,这里先跳过
|
||||
// 实际使用时应该通过 SDK 解密
|
||||
console.warn('[BugCollect] Encrypted config found but cannot be read directly')
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 从环境变量读取
|
||||
if (process.env.BUG_COLLECT_API_URL) return process.env.BUG_COLLECT_API_URL
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传 SourceMap 文件
|
||||
*/
|
||||
function uploadSourceMap(apiUrl, appKey, platform, appVersion, sourceMapPath) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const url = new URL(`${apiUrl}/bugcollect/v1/sourcemaps/upload`)
|
||||
const isHttps = url.protocol === 'https:'
|
||||
const client = isHttps ? https : http
|
||||
|
||||
// 读取 sourcemap 文件
|
||||
const fileContent = fs.readFileSync(sourceMapPath)
|
||||
const fileName = path.basename(sourceMapPath)
|
||||
|
||||
// 构建 multipart/form-data
|
||||
const boundary = '----BugCollectBoundary' + Date.now()
|
||||
const parts = []
|
||||
|
||||
// appKey
|
||||
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="appKey"\r\n\r\n${appKey}`)
|
||||
|
||||
// platform
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="platform"\r\n\r\n${platform}`,
|
||||
)
|
||||
|
||||
// appVersion
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="appVersion"\r\n\r\n${appVersion}`,
|
||||
)
|
||||
|
||||
// bundleName
|
||||
parts.push(`--${boundary}\r\nContent-Disposition: form-data; name="bundleName"\r\n\r\nindex`)
|
||||
|
||||
// file
|
||||
parts.push(
|
||||
`--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="${fileName}"\r\nContent-Type: application/json\r\n\r\n`,
|
||||
)
|
||||
|
||||
const header = Buffer.from(parts.join('\r\n') + '\r\n')
|
||||
const footer = Buffer.from(`\r\n--${boundary}--\r\n`)
|
||||
const body = Buffer.concat([header, fileContent, footer])
|
||||
|
||||
const options = {
|
||||
hostname: url.hostname,
|
||||
port: url.port || (isHttps ? 443 : 80),
|
||||
path: url.pathname,
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': `multipart/form-data; boundary=${boundary}`,
|
||||
'Content-Length': body.length,
|
||||
},
|
||||
}
|
||||
|
||||
const req = client.request(options, res => {
|
||||
let data = ''
|
||||
res.on('data', chunk => (data += chunk))
|
||||
res.on('end', () => {
|
||||
if (res.statusCode >= 200 && res.statusCode < 300) {
|
||||
console.log(`[BugCollect] SourceMap uploaded successfully: ${fileName}`)
|
||||
resolve(data)
|
||||
} else {
|
||||
console.warn(`[BugCollect] SourceMap upload failed: ${res.statusCode} ${data}`)
|
||||
reject(new Error(`Upload failed: ${res.statusCode}`))
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
req.on('error', err => {
|
||||
console.warn('[BugCollect] SourceMap upload error:', err.message)
|
||||
reject(err)
|
||||
})
|
||||
|
||||
req.write(body)
|
||||
req.end()
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取应用版本号
|
||||
*/
|
||||
function getAppVersion(projectRoot) {
|
||||
try {
|
||||
const packageJson = require(path.join(projectRoot, 'package.json'))
|
||||
return packageJson.version || '1.0.0'
|
||||
} catch {
|
||||
return '1.0.0'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* withBugCollect(metroConfig)
|
||||
* 包裹 Metro 配置,打 Release 包时自动上传 SourceMap。
|
||||
*/
|
||||
function withBugCollect(metroConfig) {
|
||||
const projectRoot = metroConfig.projectRoot || process.cwd()
|
||||
|
||||
return {
|
||||
...metroConfig,
|
||||
serializer: {
|
||||
...metroConfig.serializer,
|
||||
customSerializer: async (entryPoint, preModules, graph, options) => {
|
||||
// 仅在宿主已有 customSerializer 时委托;否则保持 result 未定义并交回 Metro。
|
||||
// 注意:本插件应通过显式包裹在已有 serializer 之上使用(见 README),
|
||||
// 不建议自动链式启用(Metro 默认 serializer 不可经 exports 获取)。
|
||||
const baseSerializer = metroConfig.serializer?.customSerializer
|
||||
const result = baseSerializer
|
||||
? await baseSerializer(entryPoint, preModules, graph, options)
|
||||
: undefined
|
||||
|
||||
// 仅 Release 包上传 SourceMap(dev 模式跳过)
|
||||
if (!options.dev) {
|
||||
try {
|
||||
// 获取配置
|
||||
const apiUrl = readConfig(projectRoot)
|
||||
if (!apiUrl) {
|
||||
console.warn('[BugCollect] No API URL configured, skipping SourceMap upload')
|
||||
return result
|
||||
}
|
||||
|
||||
// 从 graph 中获取 appKey
|
||||
const appKey = graph.transformer?.config?.xuqmAppKey || process.env.XUQM_APP_KEY || ''
|
||||
|
||||
if (!appKey) {
|
||||
console.warn('[BugCollect] No appKey configured, skipping SourceMap upload')
|
||||
return result
|
||||
}
|
||||
|
||||
// 获取平台和版本
|
||||
const platform = options.platform || 'android'
|
||||
const appVersion = getAppVersion(projectRoot)
|
||||
|
||||
// 查找 sourcemap 文件路径
|
||||
// Metro 会在 options.sourceMapUrl 中指定 sourcemap 路径
|
||||
const sourceMapUrl = options.sourceMapUrl
|
||||
let sourceMapPath = null
|
||||
|
||||
if (sourceMapUrl) {
|
||||
// 从 URL 中提取本地路径
|
||||
if (sourceMapUrl.startsWith('file://')) {
|
||||
sourceMapPath = sourceMapUrl.replace('file://', '')
|
||||
} else if (sourceMapUrl.startsWith('/')) {
|
||||
sourceMapPath = sourceMapUrl
|
||||
}
|
||||
}
|
||||
|
||||
// 如果没有从 URL 获取,尝试默认路径
|
||||
if (!sourceMapPath || !fs.existsSync(sourceMapPath)) {
|
||||
const defaultPaths = [
|
||||
path.join(projectRoot, `${platform}.bundle.map`),
|
||||
path.join(projectRoot, 'index.bundle.map'),
|
||||
path.join(projectRoot, 'bundle.map'),
|
||||
]
|
||||
sourceMapPath = defaultPaths.find(p => fs.existsSync(p))
|
||||
}
|
||||
|
||||
if (!sourceMapPath || !fs.existsSync(sourceMapPath)) {
|
||||
console.warn('[BugCollect] SourceMap file not found, skipping upload')
|
||||
return result
|
||||
}
|
||||
|
||||
// 异步上传(不阻塞构建)
|
||||
uploadSourceMap(apiUrl, appKey, platform, appVersion, sourceMapPath).catch(err => {
|
||||
console.warn('[BugCollect] SourceMap upload failed:', err.message)
|
||||
})
|
||||
} catch (err) {
|
||||
console.warn('[BugCollect] SourceMap upload error:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
return result
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { withBugCollect }
|
||||
@ -6,10 +6,8 @@
|
||||
"main": "src/index.ts",
|
||||
"react-native": "src/index.ts",
|
||||
"types": "src/index.ts",
|
||||
"metro": "metro/index.js",
|
||||
"files": [
|
||||
"src",
|
||||
"metro"
|
||||
"src"
|
||||
],
|
||||
"private": false,
|
||||
"publishConfig": {
|
||||
|
||||
@ -1,5 +1,7 @@
|
||||
import { Platform, Dimensions } from 'react-native'
|
||||
import { getConfig, isInitialized, getUserId } from '@xuqm/rn-common'
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { getConfig, hasPrivacyConsent, isInitialized, getUserId } from '@xuqm/rn-common'
|
||||
import { _resolveBundleIdentity } from '@xuqm/rn-common/internal'
|
||||
import { LogQueue } from './queue/LogQueue'
|
||||
import { ErrorCapture } from './capture/ErrorCapture'
|
||||
import { HttpInterceptor } from './interceptor/HttpInterceptor'
|
||||
@ -7,6 +9,7 @@ import { computeFingerprint } from './fingerprint'
|
||||
import { FunnelTracker } from './funnel/FunnelTracker'
|
||||
import { BUGCOLLECT_SDK_NAME, BUGCOLLECT_SDK_VERSION } from './sdkInfo'
|
||||
import { ReportPolicy } from './reportPolicy'
|
||||
import { assertBugCollectPrivacyConsent, sanitizeDiagnostic } from './privacy'
|
||||
import type {
|
||||
LogLevel,
|
||||
Environment,
|
||||
@ -28,6 +31,12 @@ let _queue: LogQueue | null = null
|
||||
let _errorCaptureStarted = false
|
||||
let _httpInterceptorStarted = false
|
||||
let _breadcrumbs: BreadcrumbItem[] = []
|
||||
let _platformDisabled = false
|
||||
const DISABLED_KEY_PREFIX = '@xuqm_bugcollect:disabled:'
|
||||
|
||||
function disabledKey(): string {
|
||||
return `${DISABLED_KEY_PREFIX}${getConfig().appKey}`
|
||||
}
|
||||
|
||||
const reportPolicy = new ReportPolicy()
|
||||
|
||||
@ -93,14 +102,69 @@ function _getDeviceInfo(): DeviceInfo {
|
||||
}
|
||||
}
|
||||
|
||||
function _enqueue(event: BugCollectEvent): void {
|
||||
if (!_queue) {
|
||||
const cfg = getConfig()
|
||||
if (!cfg.bugCollectApiUrl || !cfg.bugCollectEnabled) return
|
||||
_queue = new LogQueue({ bugCollectApiUrl: cfg.bugCollectApiUrl, appKey: cfg.appKey })
|
||||
}
|
||||
function _createQueue(): LogQueue {
|
||||
const cfg = getConfig()
|
||||
return new LogQueue({
|
||||
bugCollectApiUrl: cfg.bugCollectApiUrl,
|
||||
appKey: cfg.appKey,
|
||||
encryptionSecret: cfg.signingKey,
|
||||
onDisabled: () => {
|
||||
_platformDisabled = true
|
||||
void AsyncStorage.setItem(disabledKey(), 'true').catch(() => undefined)
|
||||
stopCapture()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function _withBundleIdentity(event: BugCollectEvent): Promise<BugCollectEvent> {
|
||||
if (event.type !== 'issue') return event
|
||||
const stacktrace = event.exception?.stacktrace
|
||||
if (!stacktrace) return { ...event, symbolicationStatus: 'unavailable' }
|
||||
const identity = await _resolveBundleIdentity(stacktrace).catch(() => null)
|
||||
return identity
|
||||
? { ...event, ...identity, symbolicationStatus: 'exact' }
|
||||
: { ...event, symbolicationStatus: 'unavailable' }
|
||||
}
|
||||
|
||||
async function _enqueue(event: BugCollectEvent): Promise<void> {
|
||||
if (!_canCollect()) return
|
||||
if (!_queue) _queue = _createQueue()
|
||||
if (!_shouldReport(event)) return
|
||||
void _queue.push(event)
|
||||
const enriched = await _withBundleIdentity(event)
|
||||
await _queue?.push(sanitizeDiagnostic(enriched))
|
||||
}
|
||||
|
||||
function _enqueueInBackground(event: BugCollectEvent): void {
|
||||
void _enqueue(event).catch(() => undefined)
|
||||
}
|
||||
|
||||
function _canCollect(): boolean {
|
||||
if (!isInitialized() || !hasPrivacyConsent() || _platformDisabled) return false
|
||||
const cfg = getConfig()
|
||||
return !cfg.debug && cfg.bugCollectEnabled && Boolean(cfg.bugCollectApiUrl)
|
||||
}
|
||||
|
||||
function startCapture(): void {
|
||||
if (!_canCollect()) return
|
||||
if (!_errorCaptureStarted) {
|
||||
_errorCaptureStarted = true
|
||||
ErrorCapture.start(BugCollect.captureError.bind(BugCollect))
|
||||
}
|
||||
if (!_httpInterceptorStarted) {
|
||||
_httpInterceptorStarted = true
|
||||
HttpInterceptor.start(BugCollect.captureError.bind(BugCollect))
|
||||
}
|
||||
}
|
||||
|
||||
function stopCapture(): void {
|
||||
if (_errorCaptureStarted) {
|
||||
ErrorCapture.stop()
|
||||
_errorCaptureStarted = false
|
||||
}
|
||||
if (_httpInterceptorStarted) {
|
||||
HttpInterceptor.stop()
|
||||
_httpInterceptorStarted = false
|
||||
}
|
||||
}
|
||||
|
||||
function _buildIssue(level: Level, error: unknown, metadata?: Record<string, unknown>): IssueEvent {
|
||||
@ -125,6 +189,7 @@ function _buildIssue(level: Level, error: unknown, metadata?: Record<string, unk
|
||||
tags: metadata,
|
||||
device: _getDeviceInfo(),
|
||||
sdk: { name: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION },
|
||||
symbolicationStatus: 'unavailable',
|
||||
}
|
||||
}
|
||||
|
||||
@ -168,20 +233,20 @@ export const BugCollect = {
|
||||
device: _getDeviceInfo(),
|
||||
sdk: { name: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION },
|
||||
}
|
||||
_enqueue(event)
|
||||
_enqueueInBackground(event)
|
||||
FunnelTracker.track(name, properties)
|
||||
},
|
||||
|
||||
/** Upload a JS exception as an error-level issue. */
|
||||
captureError(error: unknown, metadata?: Record<string, unknown>): void {
|
||||
if (!isInitialized()) return
|
||||
_enqueue(_buildIssue('error', error, metadata))
|
||||
_enqueueInBackground(_buildIssue('error', error, metadata))
|
||||
},
|
||||
|
||||
/** Upload a JS exception as a fatal-level issue (persisted across restarts). */
|
||||
captureFatal(error: unknown, metadata?: Record<string, unknown>): void {
|
||||
if (!isInitialized()) return
|
||||
_enqueue(_buildIssue('fatal', error, metadata))
|
||||
_enqueueInBackground(_buildIssue('fatal', error, metadata))
|
||||
},
|
||||
|
||||
/** Log a warning (if log level allows). */
|
||||
@ -203,8 +268,9 @@ export const BugCollect = {
|
||||
sessionId: _sessionId,
|
||||
tags: metadata,
|
||||
sdk: { name: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION },
|
||||
symbolicationStatus: 'unavailable',
|
||||
}
|
||||
_enqueue(issue)
|
||||
_enqueueInBackground(issue)
|
||||
},
|
||||
|
||||
/** Log an informational event (if log level allows). */
|
||||
@ -225,31 +291,34 @@ export const BugCollect = {
|
||||
sessionId: _sessionId,
|
||||
tags: metadata,
|
||||
sdk: { name: BUGCOLLECT_SDK_NAME, version: BUGCOLLECT_SDK_VERSION },
|
||||
symbolicationStatus: 'unavailable',
|
||||
}
|
||||
_enqueue(issue)
|
||||
_enqueueInBackground(issue)
|
||||
},
|
||||
|
||||
/**
|
||||
* Enable automatic capture of JS global errors and unhandled Promise rejections,
|
||||
* plus HTTP/接口报错 自动上报(包裹 global.fetch)。
|
||||
* Call once at app startup after XuqmSDK.initialize().
|
||||
*/
|
||||
startCapture(): void {
|
||||
if (!_errorCaptureStarted) {
|
||||
_errorCaptureStarted = true
|
||||
ErrorCapture.start(BugCollect.captureError.bind(BugCollect))
|
||||
/** Debug 开发页的一次性测试入口;不改变真实隐私同意状态或自动采集开关。 */
|
||||
async sendTestErrorAndFlush(message = 'Xuqm BugCollect test error'): Promise<void> {
|
||||
if (!isInitialized()) throw new Error('[BugCollect] SDK is not initialized')
|
||||
assertBugCollectPrivacyConsent(hasPrivacyConsent())
|
||||
const cfg = getConfig()
|
||||
if (!cfg.bugCollectEnabled || !cfg.bugCollectApiUrl || _platformDisabled) {
|
||||
throw new Error('[BugCollect] Service is disabled')
|
||||
}
|
||||
if (!_httpInterceptorStarted) {
|
||||
_httpInterceptorStarted = true
|
||||
HttpInterceptor.start(BugCollect.captureError.bind(BugCollect))
|
||||
if (!_queue) {
|
||||
_queue = _createQueue()
|
||||
}
|
||||
const issue = await _withBundleIdentity(
|
||||
_buildIssue('error', new Error(message), { testEvent: true }),
|
||||
)
|
||||
await _queue.push(sanitizeDiagnostic(issue))
|
||||
await _queue.flush(true)
|
||||
},
|
||||
|
||||
defineFunnel: FunnelTracker.define.bind(FunnelTracker),
|
||||
getFunnelProgress: FunnelTracker.getProgress.bind(FunnelTracker),
|
||||
|
||||
async flush(): Promise<void> {
|
||||
if (_queue) await _queue.flush()
|
||||
if (_queue) await _queue.flush(true)
|
||||
},
|
||||
|
||||
destroy(): void {
|
||||
@ -257,11 +326,43 @@ export const BugCollect = {
|
||||
_queue.destroy()
|
||||
_queue = null
|
||||
}
|
||||
if (_httpInterceptorStarted) {
|
||||
HttpInterceptor.stop()
|
||||
_httpInterceptorStarted = false
|
||||
}
|
||||
stopCapture()
|
||||
reportPolicy.clear()
|
||||
_breadcrumbs = []
|
||||
},
|
||||
}
|
||||
|
||||
/** 仅供包内初始化/隐私生命周期使用,不从 package index 导出。 */
|
||||
export const _BugCollectLifecycle = {
|
||||
startCapture,
|
||||
|
||||
async applyEnabledConfig(authoritative: boolean): Promise<boolean> {
|
||||
if (authoritative) {
|
||||
_platformDisabled = false
|
||||
await AsyncStorage.removeItem(disabledKey()).catch(() => undefined)
|
||||
return true
|
||||
}
|
||||
_platformDisabled = (await AsyncStorage.getItem(disabledKey()).catch(() => null)) === 'true'
|
||||
return !_platformDisabled
|
||||
},
|
||||
|
||||
async disableAndClear(): Promise<void> {
|
||||
_platformDisabled = true
|
||||
if (isInitialized()) {
|
||||
await AsyncStorage.setItem(disabledKey(), 'true').catch(() => undefined)
|
||||
}
|
||||
stopCapture()
|
||||
await _queue?.clear()
|
||||
if (isInitialized()) await LogQueue.clearStored(getConfig().appKey)
|
||||
_queue?.destroy()
|
||||
_queue = null
|
||||
},
|
||||
|
||||
async clearPendingAndStop(): Promise<void> {
|
||||
stopCapture()
|
||||
await _queue?.clear()
|
||||
if (isInitialized()) await LogQueue.clearStored(getConfig().appKey)
|
||||
_queue?.destroy()
|
||||
_queue = null
|
||||
},
|
||||
}
|
||||
|
||||
@ -11,24 +11,41 @@ declare const ErrorUtils: {
|
||||
|
||||
// React Native / Hermes exposes onunhandledrejection on globalThis
|
||||
type UnhandledRejectionHandler = ((event: { reason: unknown }) => void) | null | undefined
|
||||
let previousErrorHandler: ((error: Error, isFatal?: boolean) => void) | null = null
|
||||
let previousUnhandledHandler: UnhandledRejectionHandler = null
|
||||
let started = false
|
||||
|
||||
export const ErrorCapture = {
|
||||
start(onError: (error: unknown, meta?: Record<string, unknown>) => void): void {
|
||||
if (started) return
|
||||
started = true
|
||||
// JS global error handler
|
||||
const prevError = typeof ErrorUtils !== 'undefined' ? ErrorUtils.getGlobalHandler() : null
|
||||
previousErrorHandler = typeof ErrorUtils !== 'undefined' ? ErrorUtils.getGlobalHandler() : null
|
||||
if (typeof ErrorUtils !== 'undefined') {
|
||||
ErrorUtils.setGlobalHandler((error, isFatal) => {
|
||||
onError(error, { isFatal: isFatal ?? false })
|
||||
prevError?.(error, isFatal)
|
||||
previousErrorHandler?.(error, isFatal)
|
||||
})
|
||||
}
|
||||
|
||||
// Unhandled Promise rejection
|
||||
const g = globalThis as unknown as Record<string, unknown>
|
||||
const prevUnhandled = (g['onunhandledrejection'] as UnhandledRejectionHandler) ?? null
|
||||
previousUnhandledHandler = (g['onunhandledrejection'] as UnhandledRejectionHandler) ?? null
|
||||
g['onunhandledrejection'] = (event: { reason: unknown }) => {
|
||||
onError(event.reason, { type: 'unhandledRejection' })
|
||||
prevUnhandled?.(event)
|
||||
previousUnhandledHandler?.(event)
|
||||
}
|
||||
},
|
||||
|
||||
stop(): void {
|
||||
if (!started) return
|
||||
if (typeof ErrorUtils !== 'undefined') {
|
||||
ErrorUtils.setGlobalHandler(previousErrorHandler ?? (() => undefined))
|
||||
}
|
||||
const g = globalThis as unknown as Record<string, unknown>
|
||||
g['onunhandledrejection'] = previousUnhandledHandler ?? null
|
||||
previousErrorHandler = null
|
||||
previousUnhandledHandler = null
|
||||
started = false
|
||||
},
|
||||
}
|
||||
|
||||
@ -1,12 +1,27 @@
|
||||
import { _registerInitializationHandler } from '@xuqm/rn-common/internal'
|
||||
import {
|
||||
_registerInitializationHandler,
|
||||
_registerPrivacyConsentHandler,
|
||||
} from '@xuqm/rn-common/internal'
|
||||
import { hasPrivacyConsent } from '@xuqm/rn-common'
|
||||
|
||||
import { BugCollect } from './BugCollect'
|
||||
import { _BugCollectLifecycle, BugCollect } from './BugCollect'
|
||||
|
||||
_registerInitializationHandler('rn-bugcollect', config => {
|
||||
_registerInitializationHandler('rn-bugcollect', async config => {
|
||||
if (config.bugCollectEnabled && config.bugCollectApiUrl) {
|
||||
BugCollect.startCapture()
|
||||
const enabled = await _BugCollectLifecycle.applyEnabledConfig(
|
||||
config.configurationSource === 'remote',
|
||||
)
|
||||
if (enabled && hasPrivacyConsent() && !config.debug) _BugCollectLifecycle.startCapture()
|
||||
else void _BugCollectLifecycle.clearPendingAndStop()
|
||||
} else {
|
||||
void _BugCollectLifecycle.disableAndClear()
|
||||
}
|
||||
})
|
||||
|
||||
_registerPrivacyConsentHandler('rn-bugcollect', consented => {
|
||||
if (consented) _BugCollectLifecycle.startCapture()
|
||||
else return _BugCollectLifecycle.clearPendingAndStop()
|
||||
})
|
||||
|
||||
export { BugCollect }
|
||||
export type { LogLevel, Environment, LogEvent, IssueEvent, BugCollectEvent } from './types'
|
||||
|
||||
@ -26,6 +26,16 @@ function getRequestMetadata(input: RequestInfo | URL, init?: RequestInit) {
|
||||
}
|
||||
}
|
||||
|
||||
function isBugCollectRequest(input: RequestInfo | URL): boolean {
|
||||
const rawUrl =
|
||||
typeof input === 'string' ? input : input instanceof URL ? input.toString() : input.url
|
||||
try {
|
||||
return new URL(rawUrl).pathname.includes('/bugcollect/')
|
||||
} catch {
|
||||
return rawUrl.includes('/bugcollect/')
|
||||
}
|
||||
}
|
||||
|
||||
export const HttpInterceptor = {
|
||||
start(onError: OnError): void {
|
||||
if (_originalFetch) return // already installed
|
||||
@ -38,6 +48,7 @@ export const HttpInterceptor = {
|
||||
init?: RequestInit,
|
||||
): Promise<Response> => {
|
||||
const original = _originalFetch!
|
||||
if (isBugCollectRequest(input)) return original(input, init)
|
||||
try {
|
||||
const res = await original(input, init)
|
||||
if (!res.ok) {
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
const SENSITIVE_KEY =
|
||||
/authorization|cookie|token|secret|password|passwd|pin|idcard|identity|phone|mobile/i
|
||||
const PHONE = /(?<!\d)1[3-9]\d{9}(?!\d)/g
|
||||
const ID_CARD = /(?<![0-9A-Z])\d{17}[0-9X](?![0-9A-Z])/gi
|
||||
const BEARER = /Bearer\s+[A-Za-z0-9._~+/=-]+/gi
|
||||
|
||||
export function assertBugCollectPrivacyConsent(consented: boolean): void {
|
||||
if (!consented) {
|
||||
throw new Error('[BugCollect] Privacy consent is required')
|
||||
}
|
||||
}
|
||||
|
||||
function sanitizeString(value: string): string {
|
||||
return value
|
||||
.replace(BEARER, 'Bearer [REDACTED]')
|
||||
.replace(PHONE, '[PHONE]')
|
||||
.replace(ID_CARD, '[ID_CARD]')
|
||||
}
|
||||
|
||||
/** BugCollect 入队前的唯一深度脱敏实现,避免网络与持久化分支出现不同策略。 */
|
||||
export function sanitizeDiagnostic<T>(value: T, seen = new WeakSet<object>()): T {
|
||||
if (typeof value === 'string') return sanitizeString(value) as T
|
||||
if (value === null || typeof value !== 'object') return value
|
||||
if (seen.has(value)) return '[CIRCULAR]' as T
|
||||
seen.add(value)
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => sanitizeDiagnostic(item, seen)) as T
|
||||
}
|
||||
const result: Record<string, unknown> = {}
|
||||
for (const [key, item] of Object.entries(value)) {
|
||||
result[key] = SENSITIVE_KEY.test(key) ? '[REDACTED]' : sanitizeDiagnostic(item, seen)
|
||||
}
|
||||
return result as T
|
||||
}
|
||||
@ -2,25 +2,50 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import type { BugCollectEvent } from '../types'
|
||||
import { BUGCOLLECT_SDK_NAME, BUGCOLLECT_SDK_VERSION } from '../sdkInfo'
|
||||
import { shouldPersist } from './queuePolicy'
|
||||
|
||||
const STORED_QUEUE_KEY = '@xuqm_bugcollect:queue'
|
||||
import { retryWithBackoff } from '@xuqm/rn-common'
|
||||
import {
|
||||
clearStoredQueue,
|
||||
isBugCollectDisabledPayload,
|
||||
openQueue,
|
||||
sealQueue,
|
||||
storedQueueKey,
|
||||
} from './queueStorage'
|
||||
const BATCH_SIZE = 30
|
||||
const FLUSH_INTERVAL_MS = 10_000 // 10 seconds
|
||||
const MAX_QUEUE_SIZE = 500
|
||||
const MAX_RETRY = 3
|
||||
const MAX_STORED_AGE_MS = 7 * 24 * 60 * 60 * 1_000
|
||||
const DISABLED_ERROR_CODE = 'BUGCOLLECT_DISABLED'
|
||||
|
||||
export class BugCollectDisabledError extends Error {
|
||||
readonly name = 'BugCollectDisabledError'
|
||||
readonly code = DISABLED_ERROR_CODE
|
||||
}
|
||||
|
||||
export class LogQueue {
|
||||
private _memory: BugCollectEvent[] = []
|
||||
private flushTimer: ReturnType<typeof setInterval> | null = null
|
||||
private retryCount = 0
|
||||
private disabled = false
|
||||
private flushPromise: Promise<void> | null = null
|
||||
|
||||
constructor(private cfg: { bugCollectApiUrl: string; appKey: string }) {
|
||||
constructor(
|
||||
private cfg: {
|
||||
bugCollectApiUrl: string
|
||||
appKey: string
|
||||
encryptionSecret?: string
|
||||
onDisabled?: () => void | Promise<void>
|
||||
},
|
||||
) {
|
||||
this.flushTimer = setInterval(() => {
|
||||
void this.flush()
|
||||
}, FLUSH_INTERVAL_MS)
|
||||
}
|
||||
|
||||
static async clearStored(appKey: string): Promise<void> {
|
||||
await clearStoredQueue(AsyncStorage, appKey).catch(() => undefined)
|
||||
}
|
||||
|
||||
async push(event: BugCollectEvent): Promise<void> {
|
||||
if (this.disabled) return
|
||||
if (shouldPersist(event)) {
|
||||
const stored = await this._readStored()
|
||||
if (stored.length >= MAX_QUEUE_SIZE) stored.shift()
|
||||
@ -32,7 +57,16 @@ export class LogQueue {
|
||||
}
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
async flush(throwOnFailure = false): Promise<void> {
|
||||
if (this.flushPromise) return this.flushPromise
|
||||
this.flushPromise = this._flushOnce(throwOnFailure).finally(() => {
|
||||
this.flushPromise = null
|
||||
})
|
||||
return this.flushPromise
|
||||
}
|
||||
|
||||
private async _flushOnce(throwOnFailure: boolean): Promise<void> {
|
||||
if (this.disabled) return
|
||||
const stored = await this._readStored()
|
||||
const combined = [...stored, ...this._memory]
|
||||
if (combined.length === 0) return
|
||||
@ -41,30 +75,38 @@ export class LogQueue {
|
||||
const storedUsed = Math.min(stored.length, BATCH_SIZE)
|
||||
const memUsed = batch.length - storedUsed
|
||||
|
||||
// Optimistically remove from sources before sending to avoid duplicates
|
||||
await this._writeStored(stored.slice(storedUsed))
|
||||
this._memory = this._memory.slice(memUsed)
|
||||
|
||||
const issues = batch.filter(e => e.type !== 'event')
|
||||
const events = batch.filter(e => e.type === 'event')
|
||||
|
||||
try {
|
||||
if (issues.length > 0) await this._post('/bugcollect/v1/issues/batch', issues)
|
||||
if (events.length > 0) await this._post('/bugcollect/v1/events/batch', events)
|
||||
this.retryCount = 0
|
||||
} catch {
|
||||
this.retryCount++
|
||||
if (this.retryCount < MAX_RETRY) {
|
||||
// Re-queue only the persistent subset (re-queuing memory events would inflate duplicates)
|
||||
const toRequeue = batch.filter(shouldPersist)
|
||||
if (toRequeue.length > 0) {
|
||||
const current = await this._readStored()
|
||||
await this._writeStored([...toRequeue, ...current])
|
||||
}
|
||||
await retryWithBackoff(
|
||||
async () => {
|
||||
if (issues.length > 0) await this._post('/bugcollect/v1/issues/batch', issues)
|
||||
if (events.length > 0) await this._post('/bugcollect/v1/events/batch', events)
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
shouldRetry: error => !(error instanceof BugCollectDisabledError),
|
||||
},
|
||||
)
|
||||
await this._writeStored(stored.slice(storedUsed))
|
||||
this._memory = this._memory.slice(memUsed)
|
||||
} catch (error) {
|
||||
if (error instanceof BugCollectDisabledError) {
|
||||
this.disabled = true
|
||||
await this.clear()
|
||||
await this.cfg.onDisabled?.()
|
||||
}
|
||||
// 后台上报失败由队列吸收;保留原队列,等待网络恢复或下次进程启动。
|
||||
if (throwOnFailure) throw error
|
||||
}
|
||||
}
|
||||
|
||||
async clear(): Promise<void> {
|
||||
this._memory = []
|
||||
await AsyncStorage.removeItem(this.storageKey).catch(() => undefined)
|
||||
}
|
||||
|
||||
destroy(): void {
|
||||
if (this.flushTimer !== null) {
|
||||
clearInterval(this.flushTimer)
|
||||
@ -87,20 +129,57 @@ export class LogQueue {
|
||||
body,
|
||||
})
|
||||
if (!res.ok) {
|
||||
let code: string | undefined
|
||||
let disabledByPlatform = false
|
||||
try {
|
||||
const payload = (await res.json()) as Record<string, unknown>
|
||||
disabledByPlatform = isBugCollectDisabledPayload(payload)
|
||||
code = String(payload.code ?? payload.errorCode ?? payload.message ?? '')
|
||||
} catch {
|
||||
// 非 JSON 错误由通用重试策略处理。
|
||||
}
|
||||
if (disabledByPlatform || code === DISABLED_ERROR_CODE) {
|
||||
throw new BugCollectDisabledError()
|
||||
}
|
||||
throw new Error(`[BugCollect] Upload failed: ${res.status}`)
|
||||
}
|
||||
}
|
||||
|
||||
private get storageKey(): string {
|
||||
return storedQueueKey(this.cfg.appKey)
|
||||
}
|
||||
|
||||
private get storageContext(): string {
|
||||
return `bugcollect-queue|${this.cfg.appKey}`
|
||||
}
|
||||
|
||||
private async _readStored(): Promise<BugCollectEvent[]> {
|
||||
const raw = await AsyncStorage.getItem(STORED_QUEUE_KEY)
|
||||
return raw ? (JSON.parse(raw) as BugCollectEvent[]) : []
|
||||
const raw = await AsyncStorage.getItem(this.storageKey)
|
||||
if (!raw) return []
|
||||
if (!this.cfg.encryptionSecret) {
|
||||
await AsyncStorage.removeItem(this.storageKey)
|
||||
return []
|
||||
}
|
||||
try {
|
||||
const cutoff = Date.now() - MAX_STORED_AGE_MS
|
||||
return openQueue(raw, this.cfg.encryptionSecret, this.storageContext, cutoff)
|
||||
} catch {
|
||||
await AsyncStorage.removeItem(this.storageKey)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
private async _writeStored(queue: BugCollectEvent[]): Promise<void> {
|
||||
if (queue.length === 0) {
|
||||
await AsyncStorage.removeItem(STORED_QUEUE_KEY)
|
||||
await AsyncStorage.removeItem(this.storageKey)
|
||||
} else if (!this.cfg.encryptionSecret) {
|
||||
// 没有签发密钥时绝不降级写入含栈和设备信息的明文持久队列。
|
||||
await AsyncStorage.removeItem(this.storageKey)
|
||||
} else {
|
||||
await AsyncStorage.setItem(STORED_QUEUE_KEY, JSON.stringify(queue))
|
||||
await AsyncStorage.setItem(
|
||||
this.storageKey,
|
||||
sealQueue(queue, this.cfg.encryptionSecret, this.storageContext),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -0,0 +1,36 @@
|
||||
import { openLocalCache, sealLocalCache } from '@xuqm/rn-common/secure-cache'
|
||||
import type { BugCollectEvent } from '../types'
|
||||
|
||||
const STORED_QUEUE_KEY_PREFIX = '@xuqm_bugcollect:queue:'
|
||||
|
||||
export function storedQueueKey(appKey: string): string {
|
||||
return `${STORED_QUEUE_KEY_PREFIX}${appKey}`
|
||||
}
|
||||
|
||||
export async function clearStoredQueue(
|
||||
storage: { removeItem(key: string): Promise<unknown> },
|
||||
appKey: string,
|
||||
): Promise<void> {
|
||||
await storage.removeItem(storedQueueKey(appKey))
|
||||
}
|
||||
|
||||
export function sealQueue(queue: BugCollectEvent[], secret: string, context: string): string {
|
||||
return sealLocalCache(JSON.stringify(queue), secret, context)
|
||||
}
|
||||
|
||||
export function openQueue(
|
||||
payload: string,
|
||||
secret: string,
|
||||
context: string,
|
||||
cutoff: number,
|
||||
): BugCollectEvent[] {
|
||||
const parsed = JSON.parse(openLocalCache(payload, secret, context)) as BugCollectEvent[]
|
||||
if (!Array.isArray(parsed)) throw new Error('BugCollect queue payload is not an array')
|
||||
return parsed.filter(event => Number.isFinite(event.timestamp) && event.timestamp >= cutoff)
|
||||
}
|
||||
|
||||
export function isBugCollectDisabledPayload(payload: Record<string, unknown>): boolean {
|
||||
return [payload.code, payload.errorCode, payload.message]
|
||||
.map(value => String(value ?? ''))
|
||||
.includes('BUGCOLLECT_DISABLED')
|
||||
}
|
||||
@ -79,6 +79,12 @@ export interface IssueEvent {
|
||||
device?: DeviceInfo
|
||||
tags?: Record<string, unknown>
|
||||
sdk?: SdkInfo
|
||||
/** 四项齐全时服务端才允许精确 Source Map 符号化。 */
|
||||
buildId?: string
|
||||
moduleId?: string
|
||||
moduleVersion?: string
|
||||
bundleHash?: string
|
||||
symbolicationStatus: 'exact' | 'unavailable'
|
||||
}
|
||||
|
||||
export type BugCollectEvent = LogEvent | IssueEvent
|
||||
|
||||
@ -30,3 +30,23 @@ test('HTTP interception removes host, query and request contents before reportin
|
||||
const serialized = JSON.stringify(reports)
|
||||
assert.doesNotMatch(serialized, /example\.com|13800000000|token|secret|authorization|userId/)
|
||||
})
|
||||
|
||||
test('BugCollect transport failures are excluded to prevent recursive reporting', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const reports: unknown[] = []
|
||||
globalThis.fetch = async () => new Response(null, { status: 503 })
|
||||
|
||||
try {
|
||||
HttpInterceptor.start(error => reports.push(error))
|
||||
const response = await globalThis.fetch(
|
||||
'https://bugcollect.example.com/bugcollect/v1/issues/batch',
|
||||
{ method: 'POST' },
|
||||
)
|
||||
assert.equal(response.status, 503)
|
||||
} finally {
|
||||
HttpInterceptor.stop()
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
|
||||
assert.deepEqual(reports, [])
|
||||
})
|
||||
|
||||
@ -44,6 +44,7 @@ test('fatal and error issues are the only events persisted for restart recovery'
|
||||
exception: { type: 'Error', value: 'value' },
|
||||
sessionId: 'session',
|
||||
sdk: { name: 'bugcollect.rn', version: '1.0.0' },
|
||||
symbolicationStatus: 'unavailable',
|
||||
} as const
|
||||
assert.equal(shouldPersist({ ...base, level: 'fatal' } as BugCollectEvent), true)
|
||||
assert.equal(shouldPersist({ ...base, level: 'error' } as BugCollectEvent), true)
|
||||
|
||||
@ -0,0 +1,22 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { assertBugCollectPrivacyConsent, sanitizeDiagnostic } from '../src/privacy'
|
||||
|
||||
test('diagnostic sanitization removes secrets and common identity values recursively', () => {
|
||||
const sanitized = sanitizeDiagnostic({
|
||||
authorization: 'Bearer secret-token',
|
||||
nested: {
|
||||
message: 'phone=13800138000 id=110101199001011234',
|
||||
token: 'secret',
|
||||
},
|
||||
})
|
||||
const serialized = JSON.stringify(sanitized)
|
||||
assert.doesNotMatch(serialized, /secret-token|13800138000|110101199001011234|"secret"/)
|
||||
assert.match(serialized, /REDACTED|PHONE|ID_CARD/)
|
||||
})
|
||||
|
||||
test('manual BugCollect test upload still requires privacy consent', () => {
|
||||
assert.throws(() => assertBugCollectPrivacyConsent(false), /Privacy consent is required/)
|
||||
assert.doesNotThrow(() => assertBugCollectPrivacyConsent(true))
|
||||
})
|
||||
@ -0,0 +1,58 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
clearStoredQueue,
|
||||
isBugCollectDisabledPayload,
|
||||
openQueue,
|
||||
sealQueue,
|
||||
} from '../src/queue/queueStorage'
|
||||
import type { IssueEvent } from '../src/types'
|
||||
|
||||
const issue: IssueEvent = {
|
||||
type: 'issue',
|
||||
level: 'error',
|
||||
platform: 'android',
|
||||
fingerprint: 'fingerprint',
|
||||
timestamp: 1_000,
|
||||
appKey: 'app',
|
||||
release: '8.0.0',
|
||||
exception: { type: 'Error', value: 'secret', stacktrace: 'private stack' },
|
||||
symbolicationStatus: 'unavailable',
|
||||
}
|
||||
|
||||
test('persistent queue is encrypted, context-bound and tamper-evident', () => {
|
||||
const encrypted = sealQueue([issue], 'signing-key', 'bugcollect-queue|app')
|
||||
assert.equal(encrypted.includes('private stack'), false)
|
||||
assert.deepEqual(openQueue(encrypted, 'signing-key', 'bugcollect-queue|app', 0), [issue])
|
||||
assert.throws(() => openQueue(encrypted, 'signing-key', 'bugcollect-queue|other', 0))
|
||||
const parts = encrypted.split('.')
|
||||
parts[2] = `${parts[2][0] === 'A' ? 'B' : 'A'}${parts[2].slice(1)}`
|
||||
assert.throws(() => openQueue(parts.join('.'), 'signing-key', 'bugcollect-queue|app', 0))
|
||||
})
|
||||
|
||||
test('platform disabled response recognizes the deployed message contract', () => {
|
||||
assert.equal(
|
||||
isBugCollectDisabledPayload({
|
||||
code: 403,
|
||||
status: '1',
|
||||
message: 'BUGCOLLECT_DISABLED',
|
||||
}),
|
||||
true,
|
||||
)
|
||||
assert.equal(isBugCollectDisabledPayload({ code: 'BUGCOLLECT_DISABLED' }), true)
|
||||
assert.equal(isBugCollectDisabledPayload({ code: 403, message: 'forbidden' }), false)
|
||||
})
|
||||
|
||||
test('cold-start disable removes a persisted queue before LogQueue is created', async () => {
|
||||
const removed: string[] = []
|
||||
await clearStoredQueue(
|
||||
{
|
||||
async removeItem(key) {
|
||||
removed.push(key)
|
||||
},
|
||||
},
|
||||
'app',
|
||||
)
|
||||
assert.deepEqual(removed, ['@xuqm_bugcollect:queue:app'])
|
||||
})
|
||||
@ -25,7 +25,8 @@ const state = expirationStatus('2027-01-01T00:00:00Z')
|
||||
|
||||
## 扩展包自动初始化
|
||||
|
||||
把平台生成的加密配置放到 `src/assets/config/config.xuqmconfig`,并只包装一次 Metro 配置:
|
||||
把平台生成的加密配置原名放到 `src/assets/config/`(目录内只能有一个
|
||||
`.xuqmconfig`),并只包装一次 Metro 配置:
|
||||
|
||||
```js
|
||||
const { getDefaultConfig, mergeConfig } = require('@react-native/metro-config')
|
||||
@ -40,15 +41,7 @@ module.exports = withXuqmConfig(
|
||||
|
||||
使用 update、bugcollect、xwebview 等扩展包时,`withXuqmConfig()` 会在 Node/Metro 构建进程中解密配置,生成只读的虚拟配置模块,并把 common 的初始化入口注册为 Metro pre-main module,确保初始化早于业务入口且不受 `inlineRequires` 影响。Metro 插件还会把同一份加密配置同步到 Android assets;宿主不需要在多个位置维护副本。PBKDF2 与 AES-GCM 解密不在 Hermes 启动线程执行,应用启动时只消费构建已经解析的配置并发起共享初始化。
|
||||
|
||||
common-only 项目不要放置 `.xuqmconfig`,也不要使用 `withXuqmConfig()`,即可保持零初始化。是否初始化只由这一条规则决定,不提供额外 alias 或多套配置约定。
|
||||
|
||||
无法使用配置文件时,才调用一次手动初始化:
|
||||
|
||||
```ts
|
||||
await XuqmSDK.initialize({ appKey: 'app-key' })
|
||||
```
|
||||
|
||||
同一配置的并发初始化会复用同一个 Promise;重复使用不同配置会直接报错。
|
||||
common-only 项目不要放置 `.xuqmconfig`,也不要使用 `withXuqmConfig()`,即可保持零初始化。是否初始化只由这一条规则决定,不提供手动初始化、额外 alias 或多套配置约定。
|
||||
|
||||
Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败,common 会终止该 Promise,避免形成未处理异常。随后第一个真正需要配置的扩展调用共享 `awaitInitialization()` 时,会使用同一份已解析配置重新尝试;配置、appKey 和平台地址不需要由页面或子 SDK 再次传入。持续失败由宿主入口统一记录一次,SDK 不重复打印同一错误。构建期配置损坏、密钥不匹配或 Schema 非法会直接阻断构建,禁止打出运行时才失败的安装包。
|
||||
|
||||
@ -80,7 +73,8 @@ await XuqmSDK.logout()
|
||||
- `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`:统一时间解析与过期判断。
|
||||
- `formatNumericDateTime`:为 API 参数和固定数字布局提供不受 locale 标点/顺序影响的本地时间格式,调用方通过选项控制日期、时间、秒和分隔符。
|
||||
- `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`、`registerAndroidDownloadedFile`:统一文件与 Android 下载登记操作。
|
||||
- `decryptXuqmFile`、`hmacSha256Base64Url`:基于 `@noble/*` 的跨平台加密实现。
|
||||
- `hmacSha256Base64Url`、`sha256Hex`:签名与文件摘要使用的统一实现。平台签发的
|
||||
`.xuqmconfig` 只允许由 Metro 构建插件验签和解析,不向运行时暴露通用解密 API。
|
||||
- `getDeviceInfo`、`getDeviceId`、`useApi`、`usePageApi`、`showToast` 等项目通用能力。
|
||||
|
||||
业务代码不得导入 `@xuqm/rn-common/internal`。该子路径仅供官方 `@xuqm` 扩展包接入公共生命周期。
|
||||
|
||||
@ -6,8 +6,8 @@
|
||||
* 宿主只需将平台下载的 .xuqmconfig 文件放入 src/assets/config/,
|
||||
* SDK 自动读取并完成初始化,无需重命名或创建 .ts 包装文件。
|
||||
*
|
||||
* 对齐 Android SDK 的 ConfigFileReader 逻辑:
|
||||
* 优先 config.xuqmconfig,否则取第一个 *.xuqmconfig。
|
||||
* 目录内没有配置时保持 common-only;唯一一个 *.xuqmconfig 自动使用;多个文件
|
||||
* 无法判断权威配置,必须在构建期明确失败。
|
||||
*
|
||||
* @param {import('@react-native/metro-config').MetroConfig} metroConfig
|
||||
* @returns {import('@react-native/metro-config').MetroConfig}
|
||||
@ -16,17 +16,13 @@ const fs = require('fs')
|
||||
const path = require('path')
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const VIRTUAL_MODULE_ID = '@xuqm/autoinit-config'
|
||||
const VIRTUAL_MODULE_ID = '@xuqm/rn-common/auto-init-config'
|
||||
const AUTO_INIT_MODULE = path.resolve(__dirname, '../src/internal.ts')
|
||||
const CONFIG_DIR_CANDIDATES = [
|
||||
'src/assets/config',
|
||||
'src/assets/xuqm',
|
||||
'assets/config',
|
||||
'assets/xuqm',
|
||||
]
|
||||
const CONFIG_MAGIC = 'XUQM-CONFIG-V1'
|
||||
const CONFIG_PASSPHRASE = 'xuqm-config-file-v1.2026.internal'
|
||||
const CONFIG_DIRECTORY_RELATIVE_PATH = 'src/assets/config'
|
||||
const CONFIG_MAGIC = 'XUQM-CONFIG-V2'
|
||||
const CONFIG_PASSPHRASE = 'xuqm-config-file-v2.2026.internal'
|
||||
const PBKDF2_ITERATIONS = 120_000
|
||||
const TRUSTED_CONFIG_KEYS = require('./trusted-config-keys.json')
|
||||
|
||||
function decodeBase64Url(value) {
|
||||
return Buffer.from(value, 'base64url')
|
||||
@ -36,15 +32,92 @@ function decodeBase64Url(value) {
|
||||
* 配置文件只在 Node/Metro 构建期解密。移动端运行时直接消费解析后的配置,
|
||||
* 避免 Hermes 在应用启动阶段执行 12 万次 PBKDF2 并阻塞 JS 线程。
|
||||
*/
|
||||
function decryptConfigAtBuildTime(content) {
|
||||
function canonicalJson(value) {
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`
|
||||
if (value && typeof value === 'object') {
|
||||
return `{${Object.keys(value)
|
||||
.filter(key => value[key] !== null && value[key] !== undefined)
|
||||
.sort()
|
||||
.map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`)
|
||||
.join(',')}}`
|
||||
}
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
|
||||
function assertConfigPayload(config, plaintext) {
|
||||
const allowedFields = new Set([
|
||||
'schemaVersion',
|
||||
'configId',
|
||||
'revision',
|
||||
'issuedAt',
|
||||
'expiresAt',
|
||||
'appKey',
|
||||
'appName',
|
||||
'packageName',
|
||||
'iosBundleId',
|
||||
'harmonyBundleName',
|
||||
'serverUrl',
|
||||
'signingKey',
|
||||
])
|
||||
const unknownField = Object.keys(config).find(field => !allowedFields.has(field))
|
||||
if (unknownField) throw new Error(`配置包含不受支持字段:${unknownField}`)
|
||||
if (config.schemaVersion !== 2) throw new Error('配置 schemaVersion 必须为 2')
|
||||
if (
|
||||
typeof config.configId !== 'string' ||
|
||||
!/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
config.configId,
|
||||
)
|
||||
) {
|
||||
throw new Error('配置 configId 不是有效 UUID')
|
||||
}
|
||||
if (!Number.isInteger(config.revision) || config.revision <= 0) {
|
||||
throw new Error('配置 revision 必须为正整数')
|
||||
}
|
||||
for (const field of ['issuedAt', 'appKey', 'appName', 'serverUrl', 'signingKey']) {
|
||||
if (typeof config[field] !== 'string' || !config[field].trim()) {
|
||||
throw new Error(`配置 ${field} 不能为空`)
|
||||
}
|
||||
}
|
||||
if (!config.issuedAt.endsWith('Z') || Number.isNaN(Date.parse(config.issuedAt))) {
|
||||
throw new Error('配置 issuedAt 必须为 RFC3339 UTC 时间')
|
||||
}
|
||||
if (config.expiresAt !== undefined) {
|
||||
const expiresAt = Date.parse(config.expiresAt)
|
||||
if (!config.expiresAt.endsWith('Z') || Number.isNaN(expiresAt)) {
|
||||
throw new Error('配置 expiresAt 必须为 RFC3339 UTC 时间')
|
||||
}
|
||||
if (expiresAt <= Date.now()) throw new Error('配置文件已过期,请从租户平台重新生成')
|
||||
}
|
||||
if (canonicalJson(config) !== plaintext) {
|
||||
throw new Error('配置正文不是规范 canonical JSON')
|
||||
}
|
||||
}
|
||||
|
||||
function decryptConfigAtBuildTime(content, trustedKeys = TRUSTED_CONFIG_KEYS) {
|
||||
const parts = content.trim().split('.')
|
||||
if (parts.length !== 4 || parts[0] !== CONFIG_MAGIC) {
|
||||
throw new Error('配置文件格式无效')
|
||||
if (parts.length !== 6 || parts[0] !== CONFIG_MAGIC) {
|
||||
throw new Error('仅支持 XUQM-CONFIG-V2;请从租户平台重新下载配置文件')
|
||||
}
|
||||
|
||||
const salt = decodeBase64Url(parts[1])
|
||||
const iv = decodeBase64Url(parts[2])
|
||||
const encrypted = decodeBase64Url(parts[3])
|
||||
const keyId = parts[1]
|
||||
const publicKeyBase64 = trustedKeys[keyId]
|
||||
if (!publicKeyBase64) throw new Error(`配置签名 keyId 不受信任:${keyId}`)
|
||||
const unsignedToken = parts.slice(0, 5).join('.')
|
||||
const signatureValid = crypto.verify(
|
||||
null,
|
||||
Buffer.from(unsignedToken, 'ascii'),
|
||||
{
|
||||
key: Buffer.from(publicKeyBase64, 'base64'),
|
||||
format: 'der',
|
||||
type: 'spki',
|
||||
},
|
||||
decodeBase64Url(parts[5]),
|
||||
)
|
||||
if (!signatureValid) throw new Error('配置文件签名验证失败')
|
||||
|
||||
const salt = decodeBase64Url(parts[2])
|
||||
const iv = decodeBase64Url(parts[3])
|
||||
const encrypted = decodeBase64Url(parts[4])
|
||||
if (salt.length === 0 || iv.length !== 12 || encrypted.length <= 16) {
|
||||
throw new Error('配置文件加密参数无效')
|
||||
}
|
||||
@ -57,30 +130,30 @@ function decryptConfigAtBuildTime(content) {
|
||||
|
||||
try {
|
||||
const plaintext = Buffer.concat([decipher.update(body), decipher.final()])
|
||||
return JSON.parse(plaintext.toString('utf8'))
|
||||
const text = plaintext.toString('utf8')
|
||||
const config = JSON.parse(text)
|
||||
assertConfigPayload(config, text)
|
||||
return config
|
||||
} catch {
|
||||
throw new Error('配置文件解密或内容校验失败')
|
||||
}
|
||||
}
|
||||
|
||||
function findConfigFile(projectRoot) {
|
||||
for (const dir of CONFIG_DIR_CANDIDATES) {
|
||||
const absDir = path.resolve(projectRoot, dir)
|
||||
if (!fs.existsSync(absDir)) continue
|
||||
|
||||
const files = fs.readdirSync(absDir)
|
||||
// 优先 config.xuqmconfig 或 config.xuqm
|
||||
const preferred = files.find(f => f === 'config.xuqmconfig' || f === 'config.xuqm')
|
||||
if (preferred) {
|
||||
return path.join(absDir, preferred)
|
||||
}
|
||||
// 否则取第一个 .xuqmconfig
|
||||
const fallback = files.find(f => f.endsWith('.xuqmconfig'))
|
||||
if (fallback) {
|
||||
return path.join(absDir, fallback)
|
||||
}
|
||||
const configDirectory = path.resolve(projectRoot, CONFIG_DIRECTORY_RELATIVE_PATH)
|
||||
if (!fs.existsSync(configDirectory)) return null
|
||||
const candidates = fs
|
||||
.readdirSync(configDirectory, { withFileTypes: true })
|
||||
.filter(entry => entry.isFile() && entry.name.toLowerCase().endsWith('.xuqmconfig'))
|
||||
.map(entry => path.join(configDirectory, entry.name))
|
||||
.sort()
|
||||
if (candidates.length === 0) return null
|
||||
if (candidates.length > 1) {
|
||||
throw new Error(
|
||||
`[XuqmConfig] ${configDirectory}: 只能存在一个 .xuqmconfig 文件,当前发现 ${candidates.length} 个`,
|
||||
)
|
||||
}
|
||||
return null
|
||||
return candidates[0]
|
||||
}
|
||||
|
||||
function applyBugCollect(metroConfig) {
|
||||
@ -90,56 +163,6 @@ function applyBugCollect(metroConfig) {
|
||||
return metroConfig
|
||||
}
|
||||
|
||||
/** 写入目标文件(仅在缺失或内容不一致时),返回是否发生写入。 */
|
||||
function syncFile(content, destPath, label) {
|
||||
try {
|
||||
if (fs.existsSync(destPath) && fs.readFileSync(destPath, 'utf-8').trim() === content) {
|
||||
return false
|
||||
}
|
||||
fs.mkdirSync(path.dirname(destPath), { recursive: true })
|
||||
fs.writeFileSync(destPath, content, 'utf-8')
|
||||
console.log(`[XuqmConfig] 已同步 .xuqmconfig → ${label}`)
|
||||
return true
|
||||
} catch (e) {
|
||||
console.warn(`[XuqmConfig] 同步 .xuqmconfig 到 ${label} 失败:${e.message}`)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 单点放置:宿主只在 RN 侧放置一份 .xuqmconfig,构建/运行时自动同步到
|
||||
* 原生位置(Android assets/config,供 XuqmMergedProvider 自动初始化;iOS 资源目录)。
|
||||
* 缺失或内容不一致才复制,幂等。
|
||||
*/
|
||||
function syncNativeConfig(projectRoot, content) {
|
||||
const fileName = 'config.xuqmconfig'
|
||||
|
||||
// Android:原生 ContentProvider 扫描 assets/config 下的 .xuqmconfig
|
||||
if (fs.existsSync(path.resolve(projectRoot, 'android'))) {
|
||||
syncFile(
|
||||
content,
|
||||
path.resolve(projectRoot, 'android/app/src/main/assets/config', fileName),
|
||||
'android assets/config',
|
||||
)
|
||||
}
|
||||
|
||||
// iOS:复制到 app 资源目录(需在 Xcode「Copy Bundle Resources」中引用一次)
|
||||
const iosDir = path.resolve(projectRoot, 'ios')
|
||||
if (fs.existsSync(iosDir)) {
|
||||
try {
|
||||
const appDir = fs
|
||||
.readdirSync(iosDir, { withFileTypes: true })
|
||||
.filter(d => d.isDirectory() && fs.existsSync(path.join(iosDir, d.name, 'Info.plist')))
|
||||
.map(d => d.name)[0]
|
||||
if (appDir) {
|
||||
syncFile(content, path.join(iosDir, appDir, 'config', fileName), `ios ${appDir}/config`)
|
||||
}
|
||||
} catch {
|
||||
// 忽略 iOS 同步异常,不阻断打包
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function withXuqmConfig(metroConfig) {
|
||||
const projectRoot = metroConfig.projectRoot ?? process.cwd()
|
||||
const configFile = findConfigFile(projectRoot)
|
||||
@ -152,9 +175,6 @@ function withXuqmConfig(metroConfig) {
|
||||
// 读取加密配置内容
|
||||
const encryptedContent = fs.readFileSync(configFile, 'utf-8').trim()
|
||||
|
||||
// 单点放置:自动同步到原生 Android/iOS(缺失或不一致才复制)
|
||||
syncNativeConfig(projectRoot, encryptedContent)
|
||||
|
||||
let resolvedConfig
|
||||
try {
|
||||
resolvedConfig = decryptConfigAtBuildTime(encryptedContent)
|
||||
@ -171,11 +191,11 @@ function withXuqmConfig(metroConfig) {
|
||||
const virtualFile = path.join(generatedDir, 'autoinit-config.ts')
|
||||
fs.writeFileSync(
|
||||
virtualFile,
|
||||
`// Auto-generated by @xuqm/rn-common/metro — do not edit\nexport const RESOLVED_CONFIG = ${JSON.stringify(resolvedConfig)} as const;\n`,
|
||||
`// Auto-generated by @xuqm/rn-common/metro — do not edit\nexport const RESOLVED_CONFIG = ${JSON.stringify(resolvedConfig)} as const;\nexport const BUILD_OPTIONS = ${JSON.stringify({ bugCollectMode: process.env.XUQM_BUGCOLLECT_MODE ?? 'platform' })} as const;\n`,
|
||||
'utf-8',
|
||||
)
|
||||
|
||||
// 拦截模块解析,将 @xuqm/autoinit-config 指向虚拟文件
|
||||
// 拦截默认空实现,将 @xuqm/rn-common/auto-init-config 指向虚拟文件
|
||||
const existingResolveRequest = metroConfig.resolver?.resolveRequest
|
||||
const existingGetModulesRunBeforeMainModule =
|
||||
metroConfig.serializer?.getModulesRunBeforeMainModule
|
||||
@ -211,4 +231,4 @@ function withXuqmConfig(metroConfig) {
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { withXuqmConfig }
|
||||
module.exports = { decryptConfigAtBuildTime, findConfigFile, withXuqmConfig }
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
{
|
||||
"xuqm-config-dev-2026-07": "MCowBQYDK2VwAyEAFt9MBN8jNwCfalex4wg7kuy2DRAbXkGxZyA/Jaz+6YA="
|
||||
}
|
||||
@ -8,12 +8,14 @@
|
||||
"types": "src/index.ts",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./auto-init-config": "./src/autoInitConfig.ts",
|
||||
"./crypto": "./src/crypto.ts",
|
||||
"./download": "./src/download.ts",
|
||||
"./file-name": "./src/fileName.ts",
|
||||
"./internal": "./src/internal.ts",
|
||||
"./metro": "./metro/index.js",
|
||||
"./package.json": "./package.json",
|
||||
"./secure-cache": "./src/secureCache.ts",
|
||||
"./version": "./src/version.ts"
|
||||
},
|
||||
"metro": "metro/index.js",
|
||||
|
||||
@ -27,11 +27,20 @@ export type SafeApiErrorReport = {
|
||||
* 网络诊断只能描述请求形态,不得复制请求/响应 body、headers 或完整 URL。
|
||||
* 这些字段可能包含手机号、userId、sessionId、签名和其它业务数据。
|
||||
*/
|
||||
export function safeRequestPath(input?: string): string | undefined {
|
||||
if (!input) return undefined
|
||||
try {
|
||||
return new URL(input, 'https://xuqm.invalid').pathname
|
||||
} catch {
|
||||
return input.split(/[?#]/, 1)[0]
|
||||
}
|
||||
}
|
||||
|
||||
function requestShape(response: AxiosResponse<unknown>) {
|
||||
return {
|
||||
httpStatus: response.status,
|
||||
method: response.config.method?.toUpperCase(),
|
||||
path: response.config.url,
|
||||
path: safeRequestPath(response.config.url),
|
||||
}
|
||||
}
|
||||
|
||||
@ -60,7 +69,7 @@ export function createAxiosDiagnostic(error: AxiosError): SafeApiDiagnostic {
|
||||
code: error.code,
|
||||
httpStatus: error.response?.status,
|
||||
method: error.config?.method?.toUpperCase(),
|
||||
path: error.config?.url,
|
||||
path: safeRequestPath(error.config?.url),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
* 2. metro.config.js 使用 withXuqmConfig(metroConfig) 包装
|
||||
* 3. Metro 在业务入口前加载本模块并完成一次共享初始化
|
||||
*
|
||||
* 如果配置未找到(require 失败),静默跳过,不崩溃。
|
||||
* 未使用 withXuqmConfig 时解析到包内空实现,静默跳过且仍可完成 Metro Bundle。
|
||||
*/
|
||||
|
||||
import { isInitialized } from './config'
|
||||
@ -17,15 +17,22 @@ import { _initializeFromResolvedConfig } from './sdk'
|
||||
|
||||
let _autoInitAttempted = false
|
||||
|
||||
function tryRequireConfig(): DecryptedConfig | null {
|
||||
interface AutoInitPayload {
|
||||
config: DecryptedConfig
|
||||
buildOptions?: { bugCollectMode?: 'platform' | 'disabled' }
|
||||
}
|
||||
|
||||
function tryRequireConfig(): AutoInitPayload | null {
|
||||
try {
|
||||
// withXuqmConfig 将此模块映射到构建期已解密、已校验的配置传输模块。
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const mod = require('@xuqm/autoinit-config') as {
|
||||
const mod = require('@xuqm/rn-common/auto-init-config') as {
|
||||
RESOLVED_CONFIG?: DecryptedConfig
|
||||
default?: DecryptedConfig
|
||||
BUILD_OPTIONS?: { bugCollectMode?: 'platform' | 'disabled'; buildId?: string }
|
||||
}
|
||||
return mod?.RESOLVED_CONFIG ?? mod?.default ?? null
|
||||
const config = mod?.RESOLVED_CONFIG ?? mod?.default ?? null
|
||||
return config ? { config, buildOptions: mod.BUILD_OPTIONS } : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
@ -42,10 +49,13 @@ export function tryAutoInit(): void {
|
||||
_autoInitAttempted = true
|
||||
if (isInitialized()) return
|
||||
|
||||
const config = tryRequireConfig()
|
||||
if (!config) return
|
||||
const payload = tryRequireConfig()
|
||||
if (!payload) return
|
||||
|
||||
void _initializeFromResolvedConfig(config, { debug: __DEV__ }).catch(() => undefined)
|
||||
void _initializeFromResolvedConfig(payload.config, {
|
||||
debug: __DEV__,
|
||||
bugCollectMode: payload.buildOptions?.bugCollectMode,
|
||||
}).catch(() => undefined)
|
||||
}
|
||||
|
||||
tryAutoInit()
|
||||
|
||||
@ -0,0 +1,6 @@
|
||||
/**
|
||||
* common-only 宿主的真实空实现。withXuqmConfig 会在 Metro 解析阶段把该公开子路径
|
||||
* 精确替换为构建期配置模块;未使用 Metro 包装器时仍可正常完成静态依赖解析。
|
||||
*/
|
||||
export const RESOLVED_CONFIG = undefined
|
||||
export const BUILD_OPTIONS = undefined
|
||||
@ -0,0 +1,28 @@
|
||||
export interface XuqmBundleIdentity {
|
||||
buildId: string
|
||||
moduleId: string
|
||||
moduleVersion: string
|
||||
bundleHash: string
|
||||
}
|
||||
|
||||
export type XuqmBundleIdentityProvider = (stacktrace: string) => Promise<XuqmBundleIdentity | null>
|
||||
|
||||
let provider: { owner: string; resolve: XuqmBundleIdentityProvider } | null = null
|
||||
|
||||
/** @internal update 是 Bundle 状态唯一事实源,只允许注册一个解析器。 */
|
||||
export function _registerBundleIdentityProvider(
|
||||
owner: string,
|
||||
next: XuqmBundleIdentityProvider,
|
||||
): void {
|
||||
if (provider && provider.owner !== owner) {
|
||||
throw new Error('[XuqmSDK] A bundle identity provider is already registered.')
|
||||
}
|
||||
provider = { owner, resolve: next }
|
||||
}
|
||||
|
||||
/** @internal 无法精确归属时返回 null,调用方不得猜测 latest 或 app Bundle。 */
|
||||
export async function _resolveBundleIdentity(
|
||||
stacktrace: string,
|
||||
): Promise<XuqmBundleIdentity | null> {
|
||||
return provider ? provider.resolve(stacktrace) : null
|
||||
}
|
||||
@ -1,7 +1,14 @@
|
||||
export interface XuqmInitOptions {
|
||||
/** @internal 仅由 Metro 已验签配置驱动,不属于宿主公开契约。 */
|
||||
export interface InternalInitOptions {
|
||||
appKey: string
|
||||
platformUrl?: string // 不传则使用内置默认公有平台地址
|
||||
debug?: boolean
|
||||
/** 构建工具只允许将 BugCollect 降级为 disabled,不能反向覆盖平台关闭状态。 */
|
||||
bugCollectMode?: 'platform' | 'disabled'
|
||||
/** 自动配置内部使用。 */
|
||||
packageName?: string
|
||||
/** @internal 远程配置来源,用于扩展判断缓存是否有权覆盖服务端停用锁。 */
|
||||
configSource?: 'remote' | 'cache'
|
||||
}
|
||||
|
||||
export interface XuqmConfig {
|
||||
@ -19,10 +26,24 @@ export interface XuqmConfig {
|
||||
// 崩溃采集服务(rn-bugcollect 使用)
|
||||
bugCollectApiUrl: string
|
||||
bugCollectEnabled: boolean
|
||||
/** Update 灰度检查是否要求共享登录态;由租户平台动态配置。 */
|
||||
updateRequiresLogin: boolean
|
||||
configurationSource: 'remote' | 'cache'
|
||||
// 请求签名密钥(从配置文件读取)
|
||||
signingKey?: string
|
||||
}
|
||||
|
||||
export type XuqmInitializationState = 'idle' | 'initializing' | 'ready' | 'degraded' | 'failed'
|
||||
|
||||
export interface XuqmInitializationSnapshot {
|
||||
state: XuqmInitializationState
|
||||
source?: 'remote' | 'cache'
|
||||
error?: {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface XuqmUserInfo {
|
||||
userId: string
|
||||
userSig?: string // IM 登录凭证;未开通 IM 时可不传
|
||||
@ -39,13 +60,17 @@ export interface XuqmLoginOptions extends XuqmUserInfo {
|
||||
|
||||
export type XuqmUserInfoHandler = (info: XuqmUserInfo | null) => void | Promise<void>
|
||||
export type XuqmInitializationHandler = (config: Readonly<XuqmConfig>) => void | Promise<void>
|
||||
export type XuqmPrivacyConsentHandler = (consented: boolean) => void | Promise<void>
|
||||
|
||||
// ─── Internal state ────────────────────────────────────────────────────────────
|
||||
|
||||
let _config: XuqmConfig | null = null
|
||||
let _userInfo: XuqmUserInfo | null = null
|
||||
let _privacyConsented = false
|
||||
let _initializationSnapshot: XuqmInitializationSnapshot = { state: 'idle' }
|
||||
const _userInfoHandlers = new Map<string, XuqmUserInfoHandler>()
|
||||
const _initializationHandlers = new Map<string, XuqmInitializationHandler>()
|
||||
const _privacyConsentHandlers = new Map<string, XuqmPrivacyConsentHandler>()
|
||||
|
||||
// ─── Config ────────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -58,10 +83,11 @@ export interface XuqmRemoteConfig {
|
||||
pushEnabled?: boolean
|
||||
bugCollectApiUrl?: string
|
||||
bugCollectEnabled?: boolean
|
||||
updateRequiresLogin?: boolean
|
||||
}
|
||||
|
||||
export function initConfigFromRemote(
|
||||
options: XuqmInitOptions,
|
||||
options: InternalInitOptions,
|
||||
remote: XuqmRemoteConfig,
|
||||
signingKey?: string,
|
||||
): void {
|
||||
@ -77,13 +103,18 @@ export function initConfigFromRemote(
|
||||
imEnabled: remote.imEnabled ?? !!remote.imWsUrl,
|
||||
pushEnabled: remote.pushEnabled ?? true,
|
||||
bugCollectApiUrl: remote.bugCollectApiUrl ?? '',
|
||||
bugCollectEnabled: remote.bugCollectEnabled ?? false,
|
||||
bugCollectEnabled: options.bugCollectMode !== 'disabled' && (remote.bugCollectEnabled ?? false),
|
||||
// 服务端未下发匿名策略时按安全默认禁止匿名更新检查。
|
||||
updateRequiresLogin: remote.updateRequiresLogin ?? true,
|
||||
configurationSource: options.configSource ?? 'remote',
|
||||
signingKey,
|
||||
}
|
||||
}
|
||||
|
||||
export function getConfig(): XuqmConfig {
|
||||
if (!_config) throw new Error('[XuqmSDK] Not initialized — call XuqmSDK.initialize() first.')
|
||||
if (!_config) {
|
||||
throw new Error('[XuqmSDK] Not initialized — verify the signed config and Metro integration.')
|
||||
}
|
||||
return _config
|
||||
}
|
||||
|
||||
@ -99,6 +130,42 @@ export function isInitialized(): boolean {
|
||||
return _config !== null
|
||||
}
|
||||
|
||||
export function getInitializationSnapshot(): Readonly<XuqmInitializationSnapshot> {
|
||||
return _initializationSnapshot
|
||||
}
|
||||
|
||||
export function _setInitializationSnapshot(snapshot: XuqmInitializationSnapshot): void {
|
||||
_initializationSnapshot = snapshot
|
||||
}
|
||||
|
||||
export function hasPrivacyConsent(): boolean {
|
||||
return _privacyConsented
|
||||
}
|
||||
|
||||
export async function _setPrivacyConsent(consented: boolean): Promise<void> {
|
||||
if (_privacyConsented === consented) return
|
||||
_privacyConsented = consented
|
||||
const results = await Promise.allSettled(
|
||||
Array.from(_privacyConsentHandlers.values(), handler => Promise.resolve(handler(consented))),
|
||||
)
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
console.warn('[XuqmSDK] Privacy consent handler failed:', result.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function _registerPrivacyConsentHandler(
|
||||
name: string,
|
||||
handler: XuqmPrivacyConsentHandler,
|
||||
): () => void {
|
||||
if (_privacyConsentHandlers.has(name)) {
|
||||
throw new Error(`[XuqmSDK] Privacy consent handler "${name}" is already registered.`)
|
||||
}
|
||||
_privacyConsentHandlers.set(name, handler)
|
||||
return () => _privacyConsentHandlers.delete(name)
|
||||
}
|
||||
|
||||
export function _registerInitializationHandler(
|
||||
name: string,
|
||||
handler: XuqmInitializationHandler,
|
||||
@ -162,11 +229,11 @@ export async function _setUserInfo(info: XuqmUserInfo | null): Promise<void> {
|
||||
}
|
||||
}),
|
||||
)
|
||||
const failures = results
|
||||
.filter((result): result is PromiseRejectedResult => result.status === 'rejected')
|
||||
.map(result => String(result.reason))
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`[XuqmSDK] Session propagation failed: ${failures.join('; ')}`)
|
||||
for (const result of results) {
|
||||
if (result.status === 'rejected') {
|
||||
// 扩展登录失败不能反向破坏宿主已经完成的业务登录。
|
||||
console.warn('[XuqmSDK] Session propagation failed:', result.reason)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,36 +1,22 @@
|
||||
import { gcm } from '@noble/ciphers/aes.js'
|
||||
import { hmac } from '@noble/hashes/hmac.js'
|
||||
import { pbkdf2Async } from '@noble/hashes/pbkdf2.js'
|
||||
import { sha256 } from '@noble/hashes/sha2.js'
|
||||
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'
|
||||
|
||||
const MAGIC_CONFIG = 'XUQM-CONFIG-V1'
|
||||
const PASSPHRASES = {
|
||||
[MAGIC_CONFIG]: 'xuqm-config-file-v1.2026.internal',
|
||||
} as const
|
||||
const PBKDF2_ITERATIONS = 120_000
|
||||
const AES_KEY_BYTES = 32
|
||||
|
||||
export type XuqmEncryptedFileMagic = keyof typeof PASSPHRASES
|
||||
|
||||
export interface DecryptedConfig {
|
||||
schemaVersion: 2
|
||||
configId: string
|
||||
revision: number
|
||||
appKey: string
|
||||
appName?: string
|
||||
companyName?: string
|
||||
baseUrl?: string
|
||||
serverUrl?: string
|
||||
signingKey?: string
|
||||
issuedAt?: string
|
||||
appName: string
|
||||
packageName?: string
|
||||
iosBundleId?: string
|
||||
harmonyBundleName?: string
|
||||
serverUrl: string
|
||||
signingKey: string
|
||||
issuedAt: string
|
||||
expiresAt?: string
|
||||
}
|
||||
|
||||
function base64UrlDecode(value: string): Uint8Array {
|
||||
const padded =
|
||||
value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4)
|
||||
const binary = atob(padded)
|
||||
return Uint8Array.from({ length: binary.length }, (_, index) => binary.charCodeAt(index))
|
||||
}
|
||||
|
||||
/** Encode arbitrary binary data without exceeding Hermes argument-count limits. */
|
||||
export function bytesToBase64(value: Uint8Array): string {
|
||||
let binary = ''
|
||||
@ -45,41 +31,6 @@ function base64UrlEncode(value: Uint8Array): string {
|
||||
return bytesToBase64(value).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode UTF-8 without relying on the optional TextDecoder implementation in
|
||||
* the React Native runtime. Hermes versions and third-party polyfills have
|
||||
* returned invalid text for Uint8Array views in real applications.
|
||||
*/
|
||||
function utf8BytesToString(value: Uint8Array): string {
|
||||
let encoded = ''
|
||||
for (const byte of value) {
|
||||
encoded += `%${byte.toString(16).padStart(2, '0')}`
|
||||
}
|
||||
return decodeURIComponent(encoded)
|
||||
}
|
||||
|
||||
function parseEncryptedFile(content: string): {
|
||||
magic: XuqmEncryptedFileMagic
|
||||
salt: Uint8Array
|
||||
iv: Uint8Array
|
||||
ciphertext: Uint8Array
|
||||
} {
|
||||
const parts = content.trim().split('.')
|
||||
const magic = parts[0] as XuqmEncryptedFileMagic
|
||||
if (parts.length !== 4 || !(magic in PASSPHRASES)) {
|
||||
throw new Error('[XuqmSDK] Invalid config file format')
|
||||
}
|
||||
|
||||
const salt = base64UrlDecode(parts[1])
|
||||
const iv = base64UrlDecode(parts[2])
|
||||
const ciphertext = base64UrlDecode(parts[3])
|
||||
if (salt.length === 0 || iv.length !== 12 || ciphertext.length <= 16) {
|
||||
throw new Error('[XuqmSDK] Invalid config cryptographic parameters')
|
||||
}
|
||||
|
||||
return { magic, salt, iv, ciphertext }
|
||||
}
|
||||
|
||||
export function hmacSha256Base64Url(secret: string, data: string): string {
|
||||
return base64UrlEncode(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(data)))
|
||||
}
|
||||
@ -88,30 +39,3 @@ export function hmacSha256Base64Url(secret: string, data: string): string {
|
||||
export function sha256Hex(value: string | Uint8Array): string {
|
||||
return bytesToHex(sha256(typeof value === 'string' ? utf8ToBytes(value) : value))
|
||||
}
|
||||
|
||||
export async function decryptXuqmFile<T extends object = DecryptedConfig>(
|
||||
content: string,
|
||||
): Promise<T> {
|
||||
const { magic, salt, iv, ciphertext } = parseEncryptedFile(content)
|
||||
const key = await pbkdf2Async(sha256, PASSPHRASES[magic], salt, {
|
||||
c: PBKDF2_ITERATIONS,
|
||||
dkLen: AES_KEY_BYTES,
|
||||
})
|
||||
|
||||
let plaintext: Uint8Array
|
||||
try {
|
||||
plaintext = gcm(key, iv).decrypt(ciphertext)
|
||||
} catch {
|
||||
throw new Error('[XuqmSDK] Config decryption failed')
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(utf8BytesToString(plaintext)) as T
|
||||
} catch {
|
||||
throw new Error('[XuqmSDK] Config content is not valid JSON')
|
||||
}
|
||||
}
|
||||
|
||||
export function decryptConfigFile(content: string): Promise<DecryptedConfig> {
|
||||
return decryptXuqmFile<DecryptedConfig>(content)
|
||||
}
|
||||
|
||||
20
packages/common/src/errors.ts
普通文件
20
packages/common/src/errors.ts
普通文件
@ -0,0 +1,20 @@
|
||||
export type XuqmErrorCode =
|
||||
| 'XUQM_NOT_READY'
|
||||
| 'XUQM_NOT_LOGGED_IN'
|
||||
| 'XUQM_CONFIG_INVALID'
|
||||
| 'XUQM_CONFIG_EXPIRED'
|
||||
| 'XUQM_CONFIG_REVOKED'
|
||||
| 'XUQM_SERVICE_DISABLED'
|
||||
|
||||
/** SDK 公共结构化错误。宿主可以按 code 决定提示方式,无需解析错误文案。 */
|
||||
export class XuqmError extends Error {
|
||||
readonly name = 'XuqmError'
|
||||
|
||||
constructor(
|
||||
public readonly code: XuqmErrorCode,
|
||||
message: string,
|
||||
public readonly cause?: unknown,
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
@ -2,6 +2,7 @@ import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
|
||||
import { getOptionalConfig, getUserId } from './config'
|
||||
import { hmacSha256Base64Url } from './crypto'
|
||||
import { safeRequestPath } from './api/diagnostics'
|
||||
|
||||
const TOKEN_KEY = '@xuqm:token'
|
||||
let _baseUrl = DEFAULT_TENANT_PLATFORM_URL
|
||||
@ -83,7 +84,7 @@ export async function apiRequest<T>(
|
||||
if (_debugHttp) {
|
||||
console.debug('[XuqmSDK][HTTP] request', {
|
||||
method: options.method ?? 'GET',
|
||||
url,
|
||||
path: safeRequestPath(url),
|
||||
hasBody: Boolean(options.body),
|
||||
})
|
||||
}
|
||||
@ -118,10 +119,9 @@ export async function apiRequest<T>(
|
||||
const err = await res.json().catch(() => ({ message: res.statusText }))
|
||||
if (_debugHttp) {
|
||||
console.debug('[XuqmSDK][HTTP] response', {
|
||||
url,
|
||||
path: safeRequestPath(url),
|
||||
status: res.status,
|
||||
ok: false,
|
||||
message: (err as { message?: string }).message ?? res.statusText,
|
||||
})
|
||||
}
|
||||
throw new Error((err as { message?: string }).message ?? `HTTP ${res.status}`)
|
||||
@ -130,7 +130,7 @@ export async function apiRequest<T>(
|
||||
const json = await res.json()
|
||||
if (_debugHttp) {
|
||||
console.debug('[XuqmSDK][HTTP] response', {
|
||||
url,
|
||||
path: safeRequestPath(url),
|
||||
status: res.status,
|
||||
ok: true,
|
||||
})
|
||||
|
||||
@ -1,16 +1,27 @@
|
||||
export { XuqmSDK } from './sdk'
|
||||
export type { XuqmInitOptions, XuqmConfig, XuqmLoginOptions, XuqmUserInfo } from './config'
|
||||
export { getConfig, isInitialized, getUserId, getUserInfo } from './config'
|
||||
export type {
|
||||
XuqmConfig,
|
||||
XuqmInitializationSnapshot,
|
||||
XuqmInitializationState,
|
||||
XuqmLoginOptions,
|
||||
XuqmUserInfo,
|
||||
} from './config'
|
||||
export {
|
||||
getConfig,
|
||||
getInitializationSnapshot,
|
||||
hasPrivacyConsent,
|
||||
isInitialized,
|
||||
getUserId,
|
||||
getUserInfo,
|
||||
} from './config'
|
||||
export { XuqmError } from './errors'
|
||||
export type { XuqmErrorCode } from './errors'
|
||||
export { retryWithBackoff } from './retry'
|
||||
export type { RetryOptions } from './retry'
|
||||
export { awaitInitialization } from './sdk'
|
||||
export { apiRequest, configureHttp } from './http'
|
||||
export {
|
||||
bytesToBase64,
|
||||
decryptConfigFile,
|
||||
decryptXuqmFile,
|
||||
hmacSha256Base64Url,
|
||||
sha256Hex,
|
||||
} from './crypto'
|
||||
export type { DecryptedConfig, XuqmEncryptedFileMagic } from './crypto'
|
||||
export { bytesToBase64, hmacSha256Base64Url, sha256Hex } from './crypto'
|
||||
export type { DecryptedConfig } from './crypto'
|
||||
export { DEFAULT_TENANT_PLATFORM_URL, DEFAULT_IM_WS_URL } from './constants'
|
||||
export { getDeviceId, getDeviceInfo, detectPushVendor } from './device'
|
||||
export type { DeviceInfo, PushVendor } from './device'
|
||||
|
||||
@ -6,5 +6,11 @@
|
||||
*/
|
||||
import './autoInit'
|
||||
|
||||
export { _registerInitializationHandler, _registerUserInfoHandler } from './config'
|
||||
export { _registerBundleIdentityProvider, _resolveBundleIdentity } from './bundleIdentity'
|
||||
export {
|
||||
_registerInitializationHandler,
|
||||
_registerPrivacyConsentHandler,
|
||||
_registerUserInfoHandler,
|
||||
} from './config'
|
||||
export type { XuqmBundleIdentity, XuqmBundleIdentityProvider } from './bundleIdentity'
|
||||
export type { XuqmInitializationHandler } from './config'
|
||||
|
||||
57
packages/common/src/retry.ts
普通文件
57
packages/common/src/retry.ts
普通文件
@ -0,0 +1,57 @@
|
||||
export interface RetryOptions {
|
||||
maxAttempts?: number
|
||||
baseDelayMs?: number
|
||||
maxDelayMs?: number
|
||||
jitterRatio?: number
|
||||
signal?: AbortSignal
|
||||
shouldRetry?: (error: unknown) => boolean
|
||||
}
|
||||
|
||||
function abortError(): Error {
|
||||
const error = new Error('Operation aborted')
|
||||
error.name = 'AbortError'
|
||||
return error
|
||||
}
|
||||
|
||||
function wait(delayMs: number, signal?: AbortSignal): Promise<void> {
|
||||
if (signal?.aborted) return Promise.reject(abortError())
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(resolve, delayMs)
|
||||
signal?.addEventListener(
|
||||
'abort',
|
||||
() => {
|
||||
clearTimeout(timer)
|
||||
reject(abortError())
|
||||
},
|
||||
{ once: true },
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/** 后台网络任务统一使用三次指数退避并加入抖动,禁止各扩展自行无限重试。 */
|
||||
export async function retryWithBackoff<T>(
|
||||
action: (attempt: number) => Promise<T>,
|
||||
options: RetryOptions = {},
|
||||
): Promise<T> {
|
||||
const {
|
||||
maxAttempts = 3,
|
||||
baseDelayMs = 500,
|
||||
maxDelayMs = 8_000,
|
||||
jitterRatio = 0.2,
|
||||
signal,
|
||||
shouldRetry = () => true,
|
||||
} = options
|
||||
let lastError: unknown
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
|
||||
try {
|
||||
return await action(attempt)
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
if (attempt >= maxAttempts || !shouldRetry(error)) throw error
|
||||
const base = Math.min(maxDelayMs, baseDelayMs * 2 ** (attempt - 1))
|
||||
const jitter = base * jitterRatio * (Math.random() * 2 - 1)
|
||||
await wait(Math.max(0, Math.round(base + jitter)), signal)
|
||||
}
|
||||
}
|
||||
throw lastError
|
||||
}
|
||||
@ -1,33 +1,152 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
_notifyInitialized,
|
||||
_setInitializationSnapshot,
|
||||
_setPrivacyConsent,
|
||||
_setUserInfo,
|
||||
getInitializationSnapshot,
|
||||
getUserId as getCommonUserId,
|
||||
getUserInfo as getCommonUserInfo,
|
||||
initConfigFromRemote,
|
||||
isInitialized,
|
||||
type XuqmInitOptions,
|
||||
type InternalInitOptions,
|
||||
type XuqmLoginOptions,
|
||||
type XuqmUserInfo,
|
||||
} from './config'
|
||||
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
|
||||
import { sha256Hex } from './crypto'
|
||||
import { XuqmError } from './errors'
|
||||
import { _clearAccessToken, _saveAccessToken, configureHttp } from './http'
|
||||
import { retryWithBackoff } from './retry'
|
||||
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from './secureCache'
|
||||
import type { DecryptedConfig } from './crypto'
|
||||
|
||||
let _initPromise: Promise<void> | null = null
|
||||
let _resolvedConfigInitPromise: Promise<void> | null = null
|
||||
let _resolvedConfigRequest: {
|
||||
config: DecryptedConfig
|
||||
options?: { debug?: boolean }
|
||||
options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' }
|
||||
} | null = null
|
||||
let _initializationKey: string | null = null
|
||||
let _sessionTransition: Promise<void> = Promise.resolve()
|
||||
let _activeSessionKey: string | null = null
|
||||
const REMOTE_CONFIG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000
|
||||
const REMOTE_CONFIG_CACHE_PREFIX = '@xuqm_common:remote_config:v1:'
|
||||
const flatRemoteConfigSchema = z.object({
|
||||
apiUrl: z.string().optional(),
|
||||
imApiUrl: z.string().optional(),
|
||||
imWsUrl: z.string().optional(),
|
||||
fileServiceUrl: z.string().optional(),
|
||||
imEnabled: z.boolean().optional(),
|
||||
pushEnabled: z.boolean().optional(),
|
||||
bugCollectApiUrl: z.string().optional(),
|
||||
bugCollectEnabled: z.boolean().optional(),
|
||||
updateRequiresLogin: z.boolean().optional(),
|
||||
})
|
||||
const remoteConfigResponseSchema = z
|
||||
.object({
|
||||
apiUrl: z.string().optional(),
|
||||
imApiUrl: z.string().optional(),
|
||||
imWsUrl: z.string().optional(),
|
||||
fileServiceUrl: z.string().optional(),
|
||||
imEnabled: z.boolean().optional(),
|
||||
pushEnabled: z.boolean().optional(),
|
||||
bugCollectApiUrl: z.string().optional(),
|
||||
bugCollectEnabled: z.boolean().optional(),
|
||||
updateRequiresLogin: z.boolean().optional(),
|
||||
allowAnonymousUpdateCheck: z.boolean().optional(),
|
||||
features: z
|
||||
.object({
|
||||
im: z.boolean().optional(),
|
||||
push: z.boolean().optional(),
|
||||
bugCollect: z.boolean().optional(),
|
||||
})
|
||||
.optional(),
|
||||
})
|
||||
.loose()
|
||||
|
||||
function initializationKey(options: XuqmInitOptions): string {
|
||||
return `${options.appKey.trim()}\n${(options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')}`
|
||||
interface CachedRemoteConfig {
|
||||
cachedAt: number
|
||||
remote: {
|
||||
apiUrl?: string
|
||||
imApiUrl?: string
|
||||
imWsUrl?: string
|
||||
fileServiceUrl?: string
|
||||
imEnabled?: boolean
|
||||
pushEnabled?: boolean
|
||||
bugCollectApiUrl?: string
|
||||
bugCollectEnabled?: boolean
|
||||
updateRequiresLogin?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
function startInitialization(options: XuqmInitOptions, signingKey?: string): Promise<void> {
|
||||
function runtimePlatform(): 'ANDROID' | 'IOS' {
|
||||
try {
|
||||
// 延迟加载避免 common 的纯 Node 工具与测试被 React Native 的 Flow 源码绑定。
|
||||
// Metro 仍能静态发现该依赖,移动端运行时以 React Native Platform 为唯一事实。
|
||||
const reactNative = require('react-native') as {
|
||||
Platform?: { OS?: string }
|
||||
}
|
||||
return reactNative.Platform?.OS === 'ios' ? 'IOS' : 'ANDROID'
|
||||
} catch {
|
||||
return 'ANDROID'
|
||||
}
|
||||
}
|
||||
|
||||
function initializationKey(options: InternalInitOptions): string {
|
||||
return `${options.appKey.trim()}\n${(options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')}\n${options.packageName ?? ''}`
|
||||
}
|
||||
|
||||
function storageKey(options: InternalInitOptions): string {
|
||||
return `${REMOTE_CONFIG_CACHE_PREFIX}${sha256Hex(initializationKey(options))}`
|
||||
}
|
||||
|
||||
async function readCachedRemoteConfig(
|
||||
options: InternalInitOptions,
|
||||
signingKey?: string,
|
||||
): Promise<CachedRemoteConfig | null> {
|
||||
if (!signingKey) return null
|
||||
try {
|
||||
const encrypted = await AsyncStorage.getItem(storageKey(options))
|
||||
if (!encrypted) return null
|
||||
const raw = openLocalCache(encrypted, signingKey, initializationKey(options))
|
||||
const cached = JSON.parse(raw) as CachedRemoteConfig
|
||||
const parsedRemote = flatRemoteConfigSchema.safeParse(cached?.remote)
|
||||
if (
|
||||
!cached ||
|
||||
!isCacheRecordFresh(cached.cachedAt, Date.now(), REMOTE_CONFIG_CACHE_TTL_MS) ||
|
||||
!parsedRemote.success
|
||||
) {
|
||||
await AsyncStorage.removeItem(storageKey(options))
|
||||
return null
|
||||
}
|
||||
return { cachedAt: cached.cachedAt, remote: parsedRemote.data }
|
||||
} catch {
|
||||
await AsyncStorage.removeItem(storageKey(options)).catch(() => undefined)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCachedRemoteConfig(
|
||||
options: InternalInitOptions,
|
||||
remote: CachedRemoteConfig['remote'],
|
||||
signingKey?: string,
|
||||
): Promise<void> {
|
||||
if (!signingKey) return
|
||||
try {
|
||||
const encrypted = sealLocalCache(
|
||||
JSON.stringify({ cachedAt: Date.now(), remote } satisfies CachedRemoteConfig),
|
||||
signingKey,
|
||||
initializationKey(options),
|
||||
)
|
||||
await AsyncStorage.setItem(storageKey(options), encrypted)
|
||||
} catch {
|
||||
// 安全随机数或沙箱存储不可用时跳过缓存,不能降级为明文,也不能阻断初始化。
|
||||
}
|
||||
}
|
||||
|
||||
function startInitialization(options: InternalInitOptions, signingKey?: string): Promise<void> {
|
||||
const nextKey = initializationKey(options)
|
||||
if (isInitialized()) {
|
||||
if (_initializationKey !== nextKey) {
|
||||
@ -46,27 +165,66 @@ function startInitialization(options: XuqmInitOptions, signingKey?: string): Pro
|
||||
|
||||
_initializationKey = nextKey
|
||||
_initPromise = (async () => {
|
||||
_setInitializationSnapshot({ state: 'initializing' })
|
||||
const platformUrl = (options.platformUrl ?? DEFAULT_TENANT_PLATFORM_URL).replace(/\/$/, '')
|
||||
const configUrl = `${platformUrl}/api/sdk/config?appKey=${encodeURIComponent(options.appKey)}`
|
||||
const res = await fetch(configUrl)
|
||||
if (!res.ok) {
|
||||
throw new Error(`[XuqmSDK] Platform config request failed: ${res.status}`)
|
||||
const configParams = new URLSearchParams({
|
||||
appKey: options.appKey,
|
||||
platform: runtimePlatform(),
|
||||
})
|
||||
if (options.packageName) configParams.set('packageName', options.packageName)
|
||||
const configUrl = `${platformUrl}/api/sdk/config?${configParams.toString()}`
|
||||
let remote: CachedRemoteConfig['remote']
|
||||
let source: 'remote' | 'cache' = 'remote'
|
||||
try {
|
||||
const res = await retryWithBackoff(
|
||||
async () => {
|
||||
const response = await fetch(configUrl)
|
||||
if (response.ok) return response
|
||||
if (response.status >= 500) {
|
||||
throw new Error(`[XuqmSDK] Platform config request failed: ${response.status}`)
|
||||
}
|
||||
throw new XuqmError(
|
||||
'XUQM_NOT_READY',
|
||||
`[XuqmSDK] Platform config request failed: ${response.status}`,
|
||||
)
|
||||
},
|
||||
{
|
||||
maxAttempts: 3,
|
||||
shouldRetry: error => !(error instanceof XuqmError),
|
||||
},
|
||||
)
|
||||
const json = (await res.json()) as Record<string, unknown>
|
||||
const raw = remoteConfigResponseSchema.parse(json.data ?? json)
|
||||
const features = raw.features ?? {}
|
||||
remote = {
|
||||
apiUrl: raw.apiUrl,
|
||||
imApiUrl: raw.imApiUrl,
|
||||
imWsUrl: raw.imWsUrl,
|
||||
fileServiceUrl: raw.fileServiceUrl,
|
||||
imEnabled: features.im ?? raw.imEnabled,
|
||||
pushEnabled: features.push ?? raw.pushEnabled,
|
||||
bugCollectApiUrl: raw.bugCollectApiUrl,
|
||||
bugCollectEnabled: features.bugCollect ?? raw.bugCollectEnabled,
|
||||
updateRequiresLogin:
|
||||
raw.updateRequiresLogin ??
|
||||
(raw.allowAnonymousUpdateCheck === undefined
|
||||
? undefined
|
||||
: !raw.allowAnonymousUpdateCheck),
|
||||
}
|
||||
await writeCachedRemoteConfig(options, remote, signingKey)
|
||||
} catch (error) {
|
||||
const cached = await readCachedRemoteConfig(options, signingKey)
|
||||
if (!cached) {
|
||||
throw new XuqmError(
|
||||
'XUQM_NOT_READY',
|
||||
'[XuqmSDK] Tenant configuration is unavailable and no valid cache exists.',
|
||||
error,
|
||||
)
|
||||
}
|
||||
remote = cached.remote
|
||||
source = 'cache'
|
||||
}
|
||||
const json = (await res.json()) as Record<string, unknown>
|
||||
const remote = (json.data ?? json) as Record<string, unknown>
|
||||
initConfigFromRemote(
|
||||
{ ...options, platformUrl },
|
||||
{
|
||||
apiUrl: remote.apiUrl as string | undefined,
|
||||
imWsUrl: remote.imWsUrl as string | undefined,
|
||||
fileServiceUrl: remote.fileServiceUrl as string | undefined,
|
||||
imEnabled: remote.imEnabled as boolean | undefined,
|
||||
pushEnabled: remote.pushEnabled as boolean | undefined,
|
||||
bugCollectApiUrl: remote.bugCollectApiUrl as string | undefined,
|
||||
bugCollectEnabled: remote.bugCollectEnabled as boolean | undefined,
|
||||
},
|
||||
signingKey,
|
||||
)
|
||||
initConfigFromRemote({ ...options, platformUrl, configSource: source }, remote, signingKey)
|
||||
// Xuqm 平台接口始终回到租户平台。remote.apiUrl 是业务扩展服务地址,
|
||||
// 二者职责不同;混用会把版本检查错误地发送到 App 自身的业务服务。
|
||||
configureHttp({
|
||||
@ -74,10 +232,22 @@ function startInitialization(options: XuqmInitOptions, signingKey?: string): Pro
|
||||
debug: options.debug,
|
||||
})
|
||||
await _notifyInitialized()
|
||||
_setInitializationSnapshot({
|
||||
state: source === 'remote' ? 'ready' : 'degraded',
|
||||
source,
|
||||
})
|
||||
})().catch(error => {
|
||||
_initPromise = null
|
||||
_initializationKey = null
|
||||
throw error
|
||||
const structured =
|
||||
error instanceof XuqmError
|
||||
? error
|
||||
: new XuqmError('XUQM_NOT_READY', '[XuqmSDK] Initialization failed.', error)
|
||||
_setInitializationSnapshot({
|
||||
state: 'failed',
|
||||
error: { code: structured.code, message: structured.message },
|
||||
})
|
||||
throw structured
|
||||
})
|
||||
return _initPromise
|
||||
}
|
||||
@ -89,23 +259,6 @@ function enqueueSessionTransition(action: () => Promise<void>): Promise<void> {
|
||||
}
|
||||
|
||||
export const XuqmSDK = {
|
||||
/**
|
||||
* 方式 B:手动初始化。
|
||||
*
|
||||
* 请求平台获取该 appKey 的完整服务配置(IM、Push、文件服务等 URL 及开通状态)。
|
||||
* 失败时直接抛出,不降级。
|
||||
*
|
||||
* @param options.appKey 应用标识
|
||||
* @param options.platformUrl 平台地址;不传则使用内置默认公有平台地址
|
||||
* @param options.debug 是否开启调试日志
|
||||
*/
|
||||
async initialize(options: XuqmInitOptions): Promise<void> {
|
||||
if (!options.appKey.trim()) {
|
||||
throw new Error('[XuqmSDK] appKey is required.')
|
||||
}
|
||||
await startInitialization(options)
|
||||
},
|
||||
|
||||
/**
|
||||
* 等待初始化完成。在任意子 SDK 内部调用以确保 XuqmSDK 已就绪。
|
||||
*/
|
||||
@ -113,6 +266,17 @@ export const XuqmSDK = {
|
||||
await awaitInitialization()
|
||||
},
|
||||
|
||||
getInitializationState() {
|
||||
return getInitializationSnapshot()
|
||||
},
|
||||
|
||||
/**
|
||||
* 宿主隐私声明的唯一同步入口。SDK 不弹隐私提示,也不自行推断用户是否同意。
|
||||
*/
|
||||
async setPrivacyConsent(consented: boolean): Promise<void> {
|
||||
await _setPrivacyConsent(consented)
|
||||
},
|
||||
|
||||
getUserId(): string | null {
|
||||
return getCommonUserId()
|
||||
},
|
||||
@ -160,8 +324,9 @@ export async function awaitInitialization(): Promise<void> {
|
||||
? _initializeFromResolvedConfig(_resolvedConfigRequest.config, _resolvedConfigRequest.options)
|
||||
: null)
|
||||
if (!pendingInitialization) {
|
||||
throw new Error(
|
||||
'[XuqmSDK] Automatic initialization did not start. Place config.xuqmconfig in src/assets/config and wrap Metro with withXuqmConfig(), or call XuqmSDK.initialize().',
|
||||
throw new XuqmError(
|
||||
'XUQM_NOT_READY',
|
||||
'[XuqmSDK] Automatic initialization did not start. Place exactly one .xuqmconfig file in src/assets/config and wrap Metro with withXuqmConfig().',
|
||||
)
|
||||
}
|
||||
await pendingInitialization
|
||||
@ -170,15 +335,24 @@ export async function awaitInitialization(): Promise<void> {
|
||||
/** Metro 自动初始化专用入口;配置已在可信构建进程中完成解密和格式校验。 */
|
||||
export function _initializeFromResolvedConfig(
|
||||
config: DecryptedConfig,
|
||||
options?: { debug?: boolean },
|
||||
options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' },
|
||||
): Promise<void> {
|
||||
_resolvedConfigRequest = { config, options }
|
||||
if (_resolvedConfigInitPromise) return _resolvedConfigInitPromise
|
||||
|
||||
_resolvedConfigInitPromise = (async () => {
|
||||
const platformUrl = config.serverUrl ?? config.baseUrl ?? undefined
|
||||
const platformUrl = config.serverUrl
|
||||
await startInitialization(
|
||||
{ appKey: config.appKey, platformUrl, debug: options?.debug },
|
||||
{
|
||||
appKey: config.appKey,
|
||||
platformUrl,
|
||||
debug: options?.debug,
|
||||
bugCollectMode: options?.bugCollectMode,
|
||||
packageName:
|
||||
runtimePlatform() === 'IOS'
|
||||
? (config.iosBundleId ?? config.packageName)
|
||||
: config.packageName,
|
||||
},
|
||||
config.signingKey,
|
||||
)
|
||||
})().catch(error => {
|
||||
|
||||
@ -0,0 +1,70 @@
|
||||
import { gcm } from '@noble/ciphers/aes.js'
|
||||
import { sha256 } from '@noble/hashes/sha2.js'
|
||||
import { utf8ToBytes } from '@noble/hashes/utils.js'
|
||||
|
||||
const CACHE_FORMAT = 'XUQM-CACHE-V1'
|
||||
const NONCE_BYTES = 12
|
||||
|
||||
function bytesToBase64Url(value: Uint8Array): string {
|
||||
let binary = ''
|
||||
for (let offset = 0; offset < value.length; offset += 0x8000) {
|
||||
binary += String.fromCharCode(...value.subarray(offset, offset + 0x8000))
|
||||
}
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
|
||||
}
|
||||
|
||||
function base64UrlToBytes(value: string): Uint8Array {
|
||||
const padded =
|
||||
value.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (value.length % 4)) % 4)
|
||||
const binary = atob(padded)
|
||||
return Uint8Array.from(binary, character => character.charCodeAt(0))
|
||||
}
|
||||
|
||||
function cacheKey(secret: string, context: string): Uint8Array {
|
||||
return sha256(utf8ToBytes(`xuqm-lkg-cache\0${context}\0${secret}`))
|
||||
}
|
||||
|
||||
function utf8BytesToString(value: Uint8Array): string {
|
||||
let encoded = ''
|
||||
for (const byte of value) encoded += `%${byte.toString(16).padStart(2, '0')}`
|
||||
return decodeURIComponent(encoded)
|
||||
}
|
||||
|
||||
function randomNonce(): Uint8Array {
|
||||
const nonce = new Uint8Array(NONCE_BYTES)
|
||||
const cryptoApi = globalThis.crypto
|
||||
if (!cryptoApi?.getRandomValues) {
|
||||
throw new Error('[XuqmSDK] Secure random source is unavailable; LKG cache was not written.')
|
||||
}
|
||||
cryptoApi.getRandomValues(nonce)
|
||||
return nonce
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用沙箱内的带完整性加密。密钥由平台签发配置中的 signingKey 派生但不落缓存;
|
||||
* 这是防止缓存明文与离线篡改的边界,不宣称具备硬件 Keystore 保密性。
|
||||
*/
|
||||
export function sealLocalCache(plaintext: string, secret: string, context: string): string {
|
||||
const nonce = randomNonce()
|
||||
const ciphertext = gcm(cacheKey(secret, context), nonce, utf8ToBytes(context)).encrypt(
|
||||
utf8ToBytes(plaintext),
|
||||
)
|
||||
return `${CACHE_FORMAT}.${bytesToBase64Url(nonce)}.${bytesToBase64Url(ciphertext)}`
|
||||
}
|
||||
|
||||
export function openLocalCache(payload: string, secret: string, context: string): string {
|
||||
const parts = payload.split('.')
|
||||
if (parts.length !== 3 || parts[0] !== CACHE_FORMAT) {
|
||||
throw new Error('[XuqmSDK] LKG cache format is invalid.')
|
||||
}
|
||||
const nonce = base64UrlToBytes(parts[1])
|
||||
if (nonce.length !== NONCE_BYTES) throw new Error('[XuqmSDK] LKG cache nonce is invalid.')
|
||||
const plaintext = gcm(cacheKey(secret, context), nonce, utf8ToBytes(context)).decrypt(
|
||||
base64UrlToBytes(parts[2]),
|
||||
)
|
||||
return utf8BytesToString(plaintext)
|
||||
}
|
||||
|
||||
export function isCacheRecordFresh(cachedAt: number, now: number, ttlMs: number): boolean {
|
||||
return Number.isFinite(cachedAt) && cachedAt <= now && ttlMs > 0 && now - cachedAt <= ttlMs
|
||||
}
|
||||
@ -6,6 +6,7 @@ import {
|
||||
createResponseDiagnostic,
|
||||
createSafeApiErrorReport,
|
||||
createValidationDiagnostic,
|
||||
safeRequestPath,
|
||||
} from '../src/api/diagnostics'
|
||||
import { RequestError } from '../src/api/errors'
|
||||
|
||||
@ -41,6 +42,14 @@ test('API diagnostics only retain non-sensitive request shape', () => {
|
||||
assert.doesNotMatch(serialized, /secret|phone|token|authorization|example\.com/)
|
||||
})
|
||||
|
||||
test('request paths remove hosts, query parameters and fragments', () => {
|
||||
assert.equal(
|
||||
safeRequestPath('https://api.example.com/am/example?phone=13800000000#secret'),
|
||||
'/am/example',
|
||||
)
|
||||
assert.equal(safeRequestPath('/am/example?token=secret'), '/am/example')
|
||||
})
|
||||
|
||||
test('validation diagnostics retain issue paths without response data', () => {
|
||||
const diagnostic = createValidationDiagnostic(sensitiveResponse(), [
|
||||
{
|
||||
|
||||
@ -1,55 +1,15 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
import { bytesToBase64, decryptXuqmFile, hmacSha256Base64Url } from '../src/crypto'
|
||||
|
||||
const CONFIG_VECTOR =
|
||||
'XUQM-CONFIG-V1.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.vhdKh3AhEdeftXCdXp9KusVySkrBNAzmdaA747uf1tVWGFnAsJeCLe2mHKk0dxxez8SRTknIbk1UuRibnXY0Gbot1fmkR-jSN2ssMJO0_zQ8dUu8'
|
||||
import { hmacSha256Base64Url, sha256Hex } from '../src/crypto'
|
||||
|
||||
test('hmacSha256Base64Url matches the standard HMAC-SHA256 vector', () => {
|
||||
test('sha256Hex uses the canonical lowercase digest', () => {
|
||||
assert.equal(
|
||||
hmacSha256Base64Url('key', 'The quick brown fox jumps over the lazy dog'),
|
||||
'97yD9DBThCSxMpjmqm-xQ-9NWaFJRhdZl0edvC0aPNg',
|
||||
sha256Hex('xuqm'),
|
||||
'8d7c5b94129df39d6bec13e9fed29bb15fd1715f2e75a506436d9464bd38250c',
|
||||
)
|
||||
})
|
||||
|
||||
test('bytesToBase64 supports plugin-sized byte arrays', () => {
|
||||
const bytes = Uint8Array.from({ length: 100_000 }, (_, index) => index % 251)
|
||||
assert.equal(Buffer.from(bytes).toString('base64'), bytesToBase64(bytes))
|
||||
})
|
||||
|
||||
test('decryptXuqmFile remains compatible with existing PBKDF2 + AES-256-GCM files', async () => {
|
||||
await assert.doesNotReject(async () => {
|
||||
const result = await decryptXuqmFile(CONFIG_VECTOR)
|
||||
assert.deepEqual(result, {
|
||||
appKey: 'test-app',
|
||||
appName: '兼容向量',
|
||||
signingKey: 'secret',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
test('decryptXuqmFile does not depend on a React Native TextDecoder polyfill', async () => {
|
||||
const original = globalThis.TextDecoder
|
||||
Object.defineProperty(globalThis, 'TextDecoder', {
|
||||
configurable: true,
|
||||
value: class BrokenTextDecoder {
|
||||
decode(): string {
|
||||
return '[object Uint8Array]'
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await decryptXuqmFile(CONFIG_VECTOR)
|
||||
assert.equal(result.appName, '兼容向量')
|
||||
} finally {
|
||||
Object.defineProperty(globalThis, 'TextDecoder', {
|
||||
configurable: true,
|
||||
value: original,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('decryptXuqmFile rejects invalid authentication tags', async () => {
|
||||
await assert.rejects(() => decryptXuqmFile(`${CONFIG_VECTOR.slice(0, -1)}A`), /decryption failed/)
|
||||
test('hmacSha256Base64Url returns an unpadded URL-safe value', () => {
|
||||
assert.match(hmacSha256Base64Url('secret', 'payload'), /^[A-Za-z0-9_-]+$/)
|
||||
})
|
||||
|
||||
4
packages/common/tests/fixtures/common-only.js
vendored
普通文件
4
packages/common/tests/fixtures/common-only.js
vendored
普通文件
@ -0,0 +1,4 @@
|
||||
// 真实 Metro fixture:不使用 withXuqmConfig,也不提供 .xuqmconfig。
|
||||
const config = require('@xuqm/rn-common/auto-init-config')
|
||||
|
||||
module.exports = config.RESOLVED_CONFIG === undefined
|
||||
6
packages/common/tests/fixtures/public-api.ts
vendored
普通文件
6
packages/common/tests/fixtures/public-api.ts
vendored
普通文件
@ -0,0 +1,6 @@
|
||||
import { XuqmSDK } from '@xuqm/rn-common'
|
||||
|
||||
void XuqmSDK.awaitInitialization()
|
||||
|
||||
// @ts-expect-error 平台签发配置 + Metro 是唯一初始化入口。
|
||||
void XuqmSDK.initialize({ appKey: 'manual-init-is-not-public' })
|
||||
7
packages/common/tests/fixtures/tsconfig.json
vendored
普通文件
7
packages/common/tests/fixtures/tsconfig.json
vendored
普通文件
@ -0,0 +1,7 @@
|
||||
{
|
||||
"extends": "../../../../tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["./public-api.ts"]
|
||||
}
|
||||
@ -0,0 +1,44 @@
|
||||
const assert = require('node:assert/strict')
|
||||
const { execFileSync } = require('node:child_process')
|
||||
const { createRequire } = require('node:module')
|
||||
const fs = require('node:fs')
|
||||
const os = require('node:os')
|
||||
const path = require('node:path')
|
||||
const test = require('node:test')
|
||||
|
||||
test('common-only host bundles the default auto-init module with real Metro', async () => {
|
||||
const workspaceRoot = path.resolve(__dirname, '../../..')
|
||||
const outputDirectory = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-common-only-'))
|
||||
const output = path.join(outputDirectory, 'common-only.bundle.js')
|
||||
try {
|
||||
const reactNativeRequire = createRequire(
|
||||
require.resolve('react-native/package.json', { paths: [workspaceRoot] }),
|
||||
)
|
||||
const metro = reactNativeRequire('metro')
|
||||
const metroConfig = reactNativeRequire('metro-config')
|
||||
const config = await metroConfig.getDefaultConfig(workspaceRoot)
|
||||
config.watchFolders = [workspaceRoot]
|
||||
config.resolver.extraNodeModules = {
|
||||
'metro-runtime': path.dirname(reactNativeRequire.resolve('metro-runtime/package.json')),
|
||||
}
|
||||
await metro.runBuild(config, {
|
||||
dev: false,
|
||||
entry: 'packages/common/tests/fixtures/common-only.js',
|
||||
minify: false,
|
||||
out: output,
|
||||
platform: 'android',
|
||||
})
|
||||
assert.equal(fs.statSync(output).size > 0, true)
|
||||
} finally {
|
||||
fs.rmSync(outputDirectory, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('public types expose no manual initialization entry', () => {
|
||||
const workspaceRoot = path.resolve(__dirname, '../../..')
|
||||
execFileSync(
|
||||
path.join(workspaceRoot, 'node_modules/.bin/tsc'),
|
||||
['-p', path.join(__dirname, 'fixtures/tsconfig.json')],
|
||||
{ cwd: workspaceRoot, stdio: 'pipe' },
|
||||
)
|
||||
})
|
||||
@ -7,7 +7,7 @@ const test = require('node:test')
|
||||
const { withXuqmConfig } = require('../metro')
|
||||
|
||||
const CONFIG_VECTOR =
|
||||
'XUQM-CONFIG-V1.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.vhdKh3AhEdeftXCdXp9KusVySkrBNAzmdaA747uf1tVWGFnAsJeCLe2mHKk0dxxez8SRTknIbk1UuRibnXY0Gbot1fmkR-jSN2ssMJO0_zQ8dUu8'
|
||||
'XUQM-CONFIG-V2.xuqm-config-dev-2026-07.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.MYRqE2BtQG0PvSnG3TLrr4PXd10C1cHhNA2iJLfy60XmQ5ixQBTCpn4e2g2YN0QQbZY0LVnou1rGONIzMrT7VidOMi_lHSfUf2BXnqlCIRm4yKcYCvALvrIF-Llv2xQWla0BTpKNq2Yuejpsw0oGTZGjNkvmlbBu8JJHAckHi-wP1sAgZuKVjjtJnjc7venWzaa75KwsqOYiNOkhIjWqTLNMG1siCMbX96d2A15KJYRUpwd-rBSpgjmOFLdH6DDaj2ZqCqQg6NRJprjsEorrCAnDjRjwoMKqHiIwHErfihw3v0AO19MJUzCeYzCzU2WfFw.Z-NMbyec47P4K_lfx3l7s3jZJVpd7ru8ySj2eBvXfteU4tVBoJ_3N7IRPMeiv1lUxMchsvNg8okxYpcBs4_2CQ'
|
||||
|
||||
test('withXuqmConfig schedules automatic initialization before the app entry', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
||||
@ -32,14 +32,52 @@ test('withXuqmConfig schedules automatic initialization before the app entry', (
|
||||
|
||||
const virtualModule = config.resolver.resolveRequest(
|
||||
{ resolveRequest: () => assert.fail('fallback resolver should not run') },
|
||||
'@xuqm/autoinit-config',
|
||||
'@xuqm/rn-common/auto-init-config',
|
||||
'android',
|
||||
)
|
||||
assert.equal(virtualModule.type, 'sourceFile')
|
||||
const generatedSource = fs.readFileSync(virtualModule.filePath, 'utf8')
|
||||
assert.match(generatedSource, /RESOLVED_CONFIG/)
|
||||
assert.match(generatedSource, /"appKey":"test-app"/)
|
||||
assert.equal(generatedSource.includes('XUQM-CONFIG-V1'), false)
|
||||
assert.equal(generatedSource.includes('XUQM-CONFIG-V2'), false)
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('withXuqmConfig rejects legacy V1 files with a migration instruction', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
||||
const configDir = path.join(projectRoot, 'src/assets/config')
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
fs.writeFileSync(
|
||||
path.join(configDir, 'config.xuqmconfig'),
|
||||
'XUQM-CONFIG-V1.invalid.invalid.invalid',
|
||||
'utf8',
|
||||
)
|
||||
assert.throws(
|
||||
() => withXuqmConfig({ projectRoot }),
|
||||
/仅支持 XUQM-CONFIG-V2;请从租户平台重新下载配置文件/,
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('withXuqmConfig rejects a modified V2 signature before decrypting content', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
||||
const configDir = path.join(projectRoot, 'src/assets/config')
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
const parts = CONFIG_VECTOR.split('.')
|
||||
parts[5] = `${parts[5][0] === 'A' ? 'B' : 'A'}${parts[5].slice(1)}`
|
||||
const tampered = parts.join('.')
|
||||
fs.writeFileSync(path.join(configDir, 'config.xuqmconfig'), tampered, 'utf8')
|
||||
assert.throws(() => withXuqmConfig({ projectRoot }), /配置文件签名验证失败/)
|
||||
assert.equal(
|
||||
fs.existsSync(path.join(projectRoot, 'android/app/src/main/assets/config/config.xuqmconfig')),
|
||||
false,
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
@ -60,3 +98,19 @@ test('withXuqmConfig leaves common-only hosts unchanged without a config file',
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('withXuqmConfig rejects ambiguous config directories instead of choosing a file', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
||||
const configDir = path.join(projectRoot, 'src/assets/config')
|
||||
try {
|
||||
fs.mkdirSync(configDir, { recursive: true })
|
||||
fs.writeFileSync(path.join(configDir, 'app.xuqmconfig'), CONFIG_VECTOR, 'utf8')
|
||||
fs.writeFileSync(path.join(configDir, 'tenant.xuqmconfig'), CONFIG_VECTOR, 'utf8')
|
||||
assert.throws(
|
||||
() => withXuqmConfig({ projectRoot }),
|
||||
/只能存在一个 \.xuqmconfig 文件,当前发现 2 个/,
|
||||
)
|
||||
} finally {
|
||||
fs.rmSync(projectRoot, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
@ -0,0 +1,34 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { retryWithBackoff } from '../src/retry'
|
||||
|
||||
test('retryWithBackoff succeeds within the bounded three-attempt policy', async () => {
|
||||
let attempts = 0
|
||||
const value = await retryWithBackoff(
|
||||
async () => {
|
||||
attempts += 1
|
||||
if (attempts < 3) throw new Error('temporary')
|
||||
return 'ok'
|
||||
},
|
||||
{ baseDelayMs: 0, jitterRatio: 0 },
|
||||
)
|
||||
assert.equal(value, 'ok')
|
||||
assert.equal(attempts, 3)
|
||||
})
|
||||
|
||||
test('retryWithBackoff stops immediately for non-retryable errors', async () => {
|
||||
let attempts = 0
|
||||
await assert.rejects(
|
||||
() =>
|
||||
retryWithBackoff(
|
||||
async () => {
|
||||
attempts += 1
|
||||
throw new Error('disabled')
|
||||
},
|
||||
{ baseDelayMs: 0, shouldRetry: () => false },
|
||||
),
|
||||
/disabled/,
|
||||
)
|
||||
assert.equal(attempts, 1)
|
||||
})
|
||||
@ -2,9 +2,14 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { apiRequest } from '../src/http'
|
||||
import { _initializeFromResolvedConfig, awaitInitialization } from '../src/sdk'
|
||||
import { getConfig, getInitializationSnapshot } from '../src/config'
|
||||
import { _initializeFromResolvedConfig, awaitInitialization, XuqmSDK } from '../src/sdk'
|
||||
|
||||
test('awaitInitialization observes in-flight work and retries a transient config request failure', async () => {
|
||||
test('public SDK has no second manual initialization entry', () => {
|
||||
assert.equal('initialize' in XuqmSDK, false)
|
||||
})
|
||||
|
||||
test('awaitInitialization retries a transient config request without leaking failure to the host', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let requestCount = 0
|
||||
const requestedUrls: string[] = []
|
||||
@ -38,17 +43,20 @@ test('awaitInitialization observes in-flight work and retries a transient config
|
||||
{
|
||||
appKey: 'test-app',
|
||||
appName: '测试应用',
|
||||
configId: '123e4567-e89b-42d3-a456-426614174000',
|
||||
issuedAt: '2026-07-26T00:00:00Z',
|
||||
revision: 1,
|
||||
schemaVersion: 2,
|
||||
serverUrl: 'https://dev.xuqinmin.com',
|
||||
signingKey: 'secret',
|
||||
},
|
||||
{ debug: true },
|
||||
)
|
||||
await assert.rejects(() => awaitInitialization(), /Network request failed/)
|
||||
await assert.rejects(() => autoInitialization, /Network request failed/)
|
||||
|
||||
// 配置内容由 common 保存,扩展包再次等待时可以自行重试,无需宿主传递配置。
|
||||
await assert.doesNotReject(() => autoInitialization)
|
||||
await assert.doesNotReject(() => awaitInitialization())
|
||||
assert.equal(requestCount, 2)
|
||||
assert.deepEqual(getInitializationSnapshot(), { state: 'ready', source: 'remote' })
|
||||
assert.equal(getConfig().updateRequiresLogin, true)
|
||||
|
||||
const result = await apiRequest<{ available: boolean }>('/api/v1/rn/release-set/check', {
|
||||
skipAuth: true,
|
||||
|
||||
@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from '../src/secureCache'
|
||||
|
||||
test('LKG cache round-trips with context-bound authenticated encryption', () => {
|
||||
const encrypted = sealLocalCache('{"enabled":true}', 'signed-secret', 'app|package|environment')
|
||||
assert.equal(
|
||||
openLocalCache(encrypted, 'signed-secret', 'app|package|environment'),
|
||||
'{"enabled":true}',
|
||||
)
|
||||
assert.equal(encrypted.includes('enabled'), false)
|
||||
})
|
||||
|
||||
test('LKG cache rejects tampering and a different package/environment context', () => {
|
||||
const encrypted = sealLocalCache('payload', 'signed-secret', 'app|package|environment')
|
||||
const parts = encrypted.split('.')
|
||||
parts[2] = `${parts[2][0] === 'A' ? 'B' : 'A'}${parts[2].slice(1)}`
|
||||
assert.throws(() => openLocalCache(parts.join('.'), 'signed-secret', 'app|package|environment'))
|
||||
assert.throws(() => openLocalCache(encrypted, 'signed-secret', 'app|other|environment'))
|
||||
})
|
||||
|
||||
test('LKG freshness rejects future timestamps and expired records', () => {
|
||||
const now = 1_000_000
|
||||
assert.equal(isCacheRecordFresh(now - 1_000, now, 2_000), true)
|
||||
assert.equal(isCacheRecordFresh(now - 2_001, now, 2_000), false)
|
||||
assert.equal(isCacheRecordFresh(now + 1, now, 2_000), false)
|
||||
})
|
||||
@ -13,10 +13,20 @@ pnpm exec xuqm-rn init
|
||||
pnpm run xuqm:doctor
|
||||
```
|
||||
|
||||
`xuqm-rn init` 生成 schema v3 `xuqm.config.json` 并补充最少脚本。版本只有两条明确来源:
|
||||
`xuqm-rn init` 生成 schema v3 `xuqm.modules.json` 并补充最少脚本。版本只有两条明确来源:
|
||||
|
||||
- 完整 App 版本来自宿主 `package.json.version`,医网信新包从 `8.0.0` 开始。
|
||||
- 插件默认版本来自 `xuqm.config.json.pluginVersion`,从 `1.0.0` 开始;仅独立发布的模块覆盖 `moduleVersion`。
|
||||
- 插件默认版本来自 `xuqm.modules.json.pluginVersion`,从 `1.0.0` 开始;仅独立发布的模块覆盖 `moduleVersion`。
|
||||
|
||||
Debug 启动只注册并校验插件,不访问远程更新服务。开发页如需验证平台能力,显式调用
|
||||
`UpdateSDK.developer.checkStartupOnce()` 或
|
||||
`UpdateSDK.developer.checkAndInstallPluginOnce(moduleId)`;该状态不会跨重启保留。
|
||||
|
||||
`xuqm-rn package android` 会为每次 Release 自动生成递增 `versionCode` 与不可变
|
||||
`buildId`,并同时注入 APK、插件 manifest 和 Source Map 身份。宿主不得手工维护
|
||||
versionCode;Jenkins 可通过 `XUQM_VERSION_CODE`、`XUQM_BUILD_ID` 固定本次流水线身份。
|
||||
同一 `versionName` 的重复测试包仍会获得新的 versionCode/buildId,从而正常覆盖安装并
|
||||
加载本次 Bundle。
|
||||
|
||||
app/buz 使用 `commonVersionRange` 声明 SemVer 兼容范围,并使用 `minNativeApiLevel` 声明最低原生能力。Bundle 只接受 SHA-256,不使用 MD5。
|
||||
|
||||
@ -38,6 +48,10 @@ module.exports = withXuqmModuleConfig(getDefaultConfig(__dirname))
|
||||
- `pnpm release:android -- --apk`:生成包含同一批插件的 Release APK。
|
||||
- `pnpm publish:android`:上传插件产物;SDK npm 制品发布只能通过 Jenkins。
|
||||
|
||||
发布使用的 `XUQM_API_TOKEN` 只能由 Jenkins Credentials 注入环境变量,不属于
|
||||
`xuqm.modules.json` 或 `config.xuqmconfig`。CLI 不读取项目文件中的 Token,避免可用凭据
|
||||
进入源码、插件包或构建日志。
|
||||
|
||||
Android 宿主只应用一次 SDK 脚本:
|
||||
|
||||
```groovy
|
||||
@ -71,7 +85,7 @@ import '@xuqm/rn-update/plugin-registry'
|
||||
插件产物的引擎只有一个事实来源:Android 宿主的 `android/gradle.properties`。当
|
||||
`hermesEnabled=true` 时,CLI 自动使用与宿主 React Native 匹配的 `hermes-compiler` 将每个
|
||||
startup/common/app/buz 编译成 Hermes bytecode,并组合 Metro/Hermes source map;无需也不允许在
|
||||
`xuqm.config.json` 再声明一份引擎。manifest 和发布请求会记录 `bundleFormat`。
|
||||
`xuqm.modules.json` 再声明一份引擎。manifest 和发布请求会记录 `bundleFormat`。
|
||||
|
||||
## 运行时规则
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
package com.xuqm.update;
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.SharedPreferences;
|
||||
import android.util.Base64;
|
||||
|
||||
import androidx.annotation.NonNull;
|
||||
@ -54,6 +55,9 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
private static final String STAGED_ASSETS_DIR = "staged-assets";
|
||||
private static final String ROLLBACK_ASSETS_DIR = "rollback-assets";
|
||||
private static final String PACKAGE_TEMP_DIR = "package-temp";
|
||||
private static final String RESET_PREFERENCES = "xuqm-update-reset";
|
||||
private static final String RESET_GENERATION = "generation";
|
||||
private static final String CONFIRMED_RESET_GENERATION = "confirmed-generation";
|
||||
|
||||
/** APK 内嵌 manifest 很小且在进程生命周期内不可变,只解析一次。 */
|
||||
private JSONObject embeddedManifestCache;
|
||||
@ -73,6 +77,52 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
promise.resolve(NATIVE_API_LEVEL);
|
||||
}
|
||||
|
||||
/**
|
||||
* 原生恢复页的唯一清理入口。只删除 Update SDK 私有 rn-bundles 目录;
|
||||
* 宿主账号、业务缓存和其它 SDK 数据均不在该目录中。
|
||||
*/
|
||||
@ReactMethod
|
||||
public void resetToEmbedded(Promise promise) {
|
||||
try {
|
||||
deleteRecursively(getBundleDir());
|
||||
embeddedManifestCache = null;
|
||||
SharedPreferences preferences = getReactApplicationContext()
|
||||
.getSharedPreferences(RESET_PREFERENCES, Context.MODE_PRIVATE);
|
||||
int generation = preferences.getInt(RESET_GENERATION, 0) + 1;
|
||||
preferences.edit().putInt(RESET_GENERATION, generation).apply();
|
||||
promise.resolve(true);
|
||||
} catch (Exception error) {
|
||||
promise.reject("BUNDLE_RESET_ERROR", error.getMessage(), error);
|
||||
}
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void getResetGeneration(Promise promise) {
|
||||
SharedPreferences preferences = getReactApplicationContext()
|
||||
.getSharedPreferences(RESET_PREFERENCES, Context.MODE_PRIVATE);
|
||||
promise.resolve(preferences.getInt(RESET_GENERATION, 0));
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void getConfirmedResetGeneration(Promise promise) {
|
||||
SharedPreferences preferences = getReactApplicationContext()
|
||||
.getSharedPreferences(RESET_PREFERENCES, Context.MODE_PRIVATE);
|
||||
promise.resolve(preferences.getInt(CONFIRMED_RESET_GENERATION, 0));
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void confirmResetGeneration(int generation, Promise promise) {
|
||||
SharedPreferences preferences = getReactApplicationContext()
|
||||
.getSharedPreferences(RESET_PREFERENCES, Context.MODE_PRIVATE);
|
||||
int current = preferences.getInt(RESET_GENERATION, 0);
|
||||
if (generation != current) {
|
||||
promise.reject("BUNDLE_RESET_STALE", "Reset generation changed before confirmation");
|
||||
return;
|
||||
}
|
||||
preferences.edit().putInt(CONFIRMED_RESET_GENERATION, generation).apply();
|
||||
promise.resolve(true);
|
||||
}
|
||||
|
||||
@ReactMethod
|
||||
public void bundleExists(String moduleId, Promise promise) {
|
||||
try {
|
||||
@ -229,6 +279,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
JSONObject state = readState(moduleId);
|
||||
state.put("moduleId", moduleId);
|
||||
state.put("pendingVersion", version);
|
||||
state.put("pendingBuildId", packageManifest.getString("buildId"));
|
||||
state.put("pendingHash", packageManifest.getString("bundleSha256").toLowerCase(Locale.ROOT));
|
||||
state.put("pendingPackageHash", sha256.toLowerCase(Locale.ROOT));
|
||||
state.put("pendingByteLength", packageManifest.getLong("bundleByteLength"));
|
||||
@ -332,8 +383,8 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
JSONObject release = readReleaseStateOrNull();
|
||||
if (release != null) {
|
||||
if ("pending_confirmation".equals(release.optString("status"))
|
||||
&& release.optInt("launchAttempts", 0) == 0) {
|
||||
release.put("launchAttempts", 1);
|
||||
&& release.optInt("launchAttempts", 0) < 2) {
|
||||
release.put("launchAttempts", release.optInt("launchAttempts", 0) + 1);
|
||||
writeReleaseState(release);
|
||||
} else {
|
||||
JSONArray modules = release.getJSONArray("modules");
|
||||
@ -453,7 +504,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
File activeAssets = new File(moduleDirectory, ACTIVE_ASSETS_DIR);
|
||||
deleteRecursively(activeAssets);
|
||||
restoreEmbeddedAssets(activeAssets, entry.optJSONArray("assetFiles"));
|
||||
writeEmbeddedState(moduleId, entry, embeddedHash, bytes.length);
|
||||
writeEmbeddedState(moduleId, manifest, entry, embeddedHash, bytes.length);
|
||||
return active;
|
||||
}
|
||||
|
||||
@ -488,14 +539,26 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
if (embeddedLength >= 0L && active.length() != embeddedLength) return false;
|
||||
if (!isEmbeddedStateCurrent(state, manifest, entry, embeddedHash, active.length())) {
|
||||
// 新版 state 字段或兼容元数据变化时只刷新小状态文件,不读取/复制 Bundle。
|
||||
writeEmbeddedState(moduleId, entry, embeddedHash, active.length());
|
||||
writeEmbeddedState(moduleId, manifest, entry, embeddedHash, active.length());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String installedVersion = state.optString(metadataPrefix + "Version", "");
|
||||
String embeddedVersion = entry.optString("moduleVersion", "0.0.0");
|
||||
if (!XuqmSemanticVersion.isAtLeast(installedVersion, embeddedVersion)) return false;
|
||||
final int versionComparison;
|
||||
try {
|
||||
versionComparison = XuqmSemanticVersion.compare(installedVersion, embeddedVersion);
|
||||
} catch (IllegalArgumentException invalidVersion) {
|
||||
return false;
|
||||
}
|
||||
if (versionComparison < 0) return false;
|
||||
if (versionComparison == 0
|
||||
&& !embeddedHash.equalsIgnoreCase(state.optString(metadataPrefix + "Hash", ""))) {
|
||||
// 相同展示版本的新版 APK 以摘要识别真实构建,覆盖旧本地副本;
|
||||
// 真正更高且兼容当前原生基线的远程插件仍保留。
|
||||
return false;
|
||||
}
|
||||
|
||||
String currentBaseline = manifest.optString("nativeBaselineId", "");
|
||||
String installedBaseline = state.optString(metadataPrefix + "BuiltAgainstNativeBaselineId", "");
|
||||
@ -517,6 +580,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
String embeddedHash,
|
||||
long byteLength) {
|
||||
return entry.optString("moduleVersion", "0.0.0").equals(state.optString("activeVersion", ""))
|
||||
&& manifest.optString("buildId", "").equals(state.optString("activeBuildId", ""))
|
||||
&& embeddedHash.equalsIgnoreCase(state.optString("activeHash", ""))
|
||||
&& byteLength == state.optLong("activeByteLength", -1L)
|
||||
&& entry.optString("type", "buz").equals(state.optString("activeType", ""))
|
||||
@ -530,16 +594,23 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
|
||||
private void writeEmbeddedState(
|
||||
String moduleId,
|
||||
JSONObject manifest,
|
||||
JSONObject entry,
|
||||
String embeddedHash,
|
||||
long byteLength) throws Exception {
|
||||
writeState(
|
||||
moduleId,
|
||||
createEmbeddedState(moduleId, entry, embeddedHash, byteLength));
|
||||
createEmbeddedState(
|
||||
moduleId,
|
||||
manifest.optString("buildId", ""),
|
||||
entry,
|
||||
embeddedHash,
|
||||
byteLength));
|
||||
}
|
||||
|
||||
private JSONObject createEmbeddedState(
|
||||
String moduleId,
|
||||
String buildId,
|
||||
JSONObject entry,
|
||||
String embeddedHash,
|
||||
long byteLength) throws Exception {
|
||||
@ -547,6 +618,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
state.put("moduleId", moduleId);
|
||||
state.put("status", "embedded");
|
||||
state.put("activeVersion", entry.optString("moduleVersion", "0.0.0"));
|
||||
if (!buildId.isEmpty()) state.put("activeBuildId", buildId);
|
||||
if (!embeddedHash.isEmpty()) {
|
||||
state.put("activeHash", embeddedHash.toLowerCase(Locale.ROOT));
|
||||
}
|
||||
@ -721,6 +793,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
}
|
||||
return createEmbeddedState(
|
||||
moduleId,
|
||||
manifest.optString("buildId", ""),
|
||||
entry,
|
||||
entry.optString("sha256", ""),
|
||||
entry.optLong("byteLength", -1L));
|
||||
@ -774,6 +847,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
|
||||
private void copyActiveToRollbackMetadata(JSONObject state) throws Exception {
|
||||
copyJsonValue(state, "activeVersion", state, "rollbackVersion");
|
||||
copyJsonValue(state, "activeBuildId", state, "rollbackBuildId");
|
||||
copyJsonValue(state, "activeHash", state, "rollbackHash");
|
||||
copyJsonValue(state, "activeByteLength", state, "rollbackByteLength");
|
||||
copyJsonValue(state, "activeType", state, "rollbackType");
|
||||
@ -787,6 +861,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
|
||||
private void movePendingToActiveMetadata(JSONObject state) throws Exception {
|
||||
moveJsonValue(state, "pendingVersion", "activeVersion");
|
||||
moveJsonValue(state, "pendingBuildId", "activeBuildId");
|
||||
moveJsonValue(state, "pendingHash", "activeHash");
|
||||
moveJsonValue(state, "pendingByteLength", "activeByteLength");
|
||||
moveJsonValue(state, "pendingType", "activeType");
|
||||
@ -800,6 +875,7 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
|
||||
private void moveRollbackToActiveMetadata(JSONObject state) throws Exception {
|
||||
moveJsonValue(state, "rollbackVersion", "activeVersion");
|
||||
moveJsonValue(state, "rollbackBuildId", "activeBuildId");
|
||||
moveJsonValue(state, "rollbackHash", "activeHash");
|
||||
moveJsonValue(state, "rollbackByteLength", "activeByteLength");
|
||||
moveJsonValue(state, "rollbackType", "activeType");
|
||||
@ -817,15 +893,15 @@ public final class XuqmBundleModule extends ReactContextBaseJavaModule {
|
||||
}
|
||||
|
||||
private void clearActiveMetadata(JSONObject state) {
|
||||
for (String key : new String[]{"activeVersion", "activeHash", "activeByteLength", "activeType", "activeAppVersionRange", "activeBuiltAgainstNativeBaselineId", "activeCommonVersionRange", "activeMinNativeApiLevel", "activePackageHash", "activeAssetFiles"}) state.remove(key);
|
||||
for (String key : new String[]{"activeVersion", "activeBuildId", "activeHash", "activeByteLength", "activeType", "activeAppVersionRange", "activeBuiltAgainstNativeBaselineId", "activeCommonVersionRange", "activeMinNativeApiLevel", "activePackageHash", "activeAssetFiles"}) state.remove(key);
|
||||
}
|
||||
|
||||
private void clearPendingMetadata(JSONObject state) {
|
||||
for (String key : new String[]{"pendingVersion", "pendingHash", "pendingByteLength", "pendingType", "pendingAppVersionRange", "pendingBuiltAgainstNativeBaselineId", "pendingCommonVersionRange", "pendingMinNativeApiLevel", "pendingPackageHash", "pendingAssetFiles"}) state.remove(key);
|
||||
for (String key : new String[]{"pendingVersion", "pendingBuildId", "pendingHash", "pendingByteLength", "pendingType", "pendingAppVersionRange", "pendingBuiltAgainstNativeBaselineId", "pendingCommonVersionRange", "pendingMinNativeApiLevel", "pendingPackageHash", "pendingAssetFiles"}) state.remove(key);
|
||||
}
|
||||
|
||||
private void clearRollbackMetadata(JSONObject state) {
|
||||
for (String key : new String[]{"rollbackVersion", "rollbackHash", "rollbackByteLength", "rollbackType", "rollbackAppVersionRange", "rollbackBuiltAgainstNativeBaselineId", "rollbackCommonVersionRange", "rollbackMinNativeApiLevel", "rollbackPackageHash", "rollbackAssetFiles"}) state.remove(key);
|
||||
for (String key : new String[]{"rollbackVersion", "rollbackBuildId", "rollbackHash", "rollbackByteLength", "rollbackType", "rollbackAppVersionRange", "rollbackBuiltAgainstNativeBaselineId", "rollbackCommonVersionRange", "rollbackMinNativeApiLevel", "rollbackPackageHash", "rollbackAssetFiles"}) state.remove(key);
|
||||
}
|
||||
|
||||
private void validateModuleId(String moduleId) {
|
||||
|
||||
@ -137,6 +137,9 @@ final class XuqmPluginPackageStager {
|
||||
|| !"android".equals(manifest.optString("platform"))) {
|
||||
throw new IOException("Plugin package identity does not match " + moduleId + "/android");
|
||||
}
|
||||
if (manifest.optString("buildId").trim().isEmpty()) {
|
||||
throw new IOException("Plugin package buildId is missing");
|
||||
}
|
||||
validateCandidate(candidate, manifest);
|
||||
return manifest;
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package com.xuqm.update;
|
||||
|
||||
import static org.junit.Assert.assertEquals;
|
||||
import static org.junit.Assert.assertFalse;
|
||||
import static org.junit.Assert.assertTrue;
|
||||
import static org.junit.Assert.assertThrows;
|
||||
|
||||
import org.junit.Test;
|
||||
@ -23,4 +25,25 @@ public final class XuqmBundleStorageTest {
|
||||
File root = new File(System.getProperty("java.io.tmpdir"), "xuqm-storage-test");
|
||||
assertThrows(IOException.class, () -> XuqmBundleStorage.safeFile(root, "../outside.bundle"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void recursiveDeleteRemovesOnlyTheSelectedSdkDirectory() throws Exception {
|
||||
File root = new File(System.getProperty("java.io.tmpdir"), "xuqm-reset-test-" + System.nanoTime());
|
||||
File updateDirectory = new File(root, "rn-bundles/common");
|
||||
File hostDirectory = new File(root, "host-business");
|
||||
assertTrue(updateDirectory.mkdirs());
|
||||
assertTrue(hostDirectory.mkdirs());
|
||||
XuqmBundleStorage.atomicWrite(
|
||||
new File(updateDirectory, "active.bundle"),
|
||||
"bundle".getBytes(StandardCharsets.UTF_8));
|
||||
XuqmBundleStorage.atomicWrite(
|
||||
new File(hostDirectory, "account.json"),
|
||||
"account".getBytes(StandardCharsets.UTF_8));
|
||||
|
||||
XuqmBundleStorage.deleteRecursively(new File(root, "rn-bundles"));
|
||||
|
||||
assertFalse(new File(root, "rn-bundles").exists());
|
||||
assertTrue(new File(hostDirectory, "account.json").exists());
|
||||
XuqmBundleStorage.deleteRecursively(root);
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,6 +40,10 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask {
|
||||
@PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract RegularFileProperty getPackageFile()
|
||||
|
||||
@InputFile
|
||||
@PathSensitive(PathSensitivity.RELATIVE)
|
||||
abstract RegularFileProperty getSignedConfigFile()
|
||||
|
||||
/**
|
||||
* RN/原生源码与打包配置共同决定 bundle、资源和 native baseline。
|
||||
* 显式建模后 Gradle 才能安全复用上次输出,禁止用永久 out-of-date 代替依赖声明。
|
||||
@ -54,6 +58,12 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask {
|
||||
@Input
|
||||
abstract Property<String> getAppVersion()
|
||||
|
||||
@Input
|
||||
abstract Property<String> getBuildId()
|
||||
|
||||
@Input
|
||||
abstract Property<String> getBugCollectMode()
|
||||
|
||||
@OutputDirectory
|
||||
abstract DirectoryProperty getAssetsOutputDirectory()
|
||||
|
||||
@ -69,6 +79,8 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask {
|
||||
def resources = resourcesOutputDirectory.get().asFile
|
||||
execOperations.exec {
|
||||
workingDir(projectRootDirectory.get().asFile)
|
||||
environment("XUQM_BUILD_ID", buildId.get())
|
||||
environment("XUQM_BUGCOLLECT_MODE", bugCollectMode.get())
|
||||
commandLine(
|
||||
nodeExecutable.get(),
|
||||
cliFile.get().asFile.absolutePath,
|
||||
@ -80,6 +92,11 @@ abstract class GenerateXuqmBundlesTask extends DefaultTask {
|
||||
appVersion.get(),
|
||||
)
|
||||
}.assertNormalExitValue()
|
||||
project.copy {
|
||||
from(signedConfigFile.get().asFile)
|
||||
into(assetsOutputDirectory.get().dir("config"))
|
||||
rename { "config.xuqmconfig" }
|
||||
}
|
||||
|
||||
// Android 内嵌 Bundle 通过 AssetManager 读取,但 require() 图片必须同时进入
|
||||
// aapt2 的 drawable 资源表。只把 drawable-* 放进 assets 会导致图片静默为空。
|
||||
@ -108,6 +125,35 @@ def xuqmUseMetro = providers.gradleProperty("USE_METRO")
|
||||
.orElse(providers.environmentVariable("USE_METRO"))
|
||||
.map { it.toBoolean() }
|
||||
.orElse(false)
|
||||
def xuqmReleaseVersionCode = providers.gradleProperty("XUQM_VERSION_CODE")
|
||||
.orElse(providers.environmentVariable("XUQM_VERSION_CODE"))
|
||||
if (xuqmReleaseVersionCode.isPresent()) {
|
||||
def parsedVersionCode = Integer.parseInt(xuqmReleaseVersionCode.get())
|
||||
if (parsedVersionCode <= 0) {
|
||||
throw new GradleException("XUQM_VERSION_CODE must be a positive integer")
|
||||
}
|
||||
android.defaultConfig.versionCode = parsedVersionCode
|
||||
}
|
||||
def xuqmBuildId = providers.gradleProperty("XUQM_BUILD_ID")
|
||||
.orElse(providers.environmentVariable("XUQM_BUILD_ID"))
|
||||
.orElse("development")
|
||||
def xuqmBugCollectMode = providers.gradleProperty("XUQM_BUGCOLLECT_MODE")
|
||||
.orElse(providers.environmentVariable("XUQM_BUGCOLLECT_MODE"))
|
||||
.orElse("platform")
|
||||
if (!["platform", "disabled"].contains(xuqmBugCollectMode.get())) {
|
||||
throw new GradleException("XUQM_BUGCOLLECT_MODE must be platform or disabled")
|
||||
}
|
||||
def xuqmSignedConfigDirectory = new File(xuqmProjectRoot, "src/assets/config")
|
||||
def xuqmSignedConfigCandidates = xuqmSignedConfigDirectory.isDirectory()
|
||||
? (xuqmSignedConfigDirectory.listFiles()?.findAll {
|
||||
it.isFile() && it.name.toLowerCase(Locale.ROOT).endsWith(".xuqmconfig")
|
||||
} ?: []).sort { left, right -> left.name <=> right.name }
|
||||
: []
|
||||
if (xuqmSignedConfigCandidates.size() > 1) {
|
||||
throw new GradleException(
|
||||
"src/assets/config must contain exactly one .xuqmconfig file; found ${xuqmSignedConfigCandidates.size()}"
|
||||
)
|
||||
}
|
||||
|
||||
def prepareXuqmBundles = tasks.register(
|
||||
"prepareXuqmEmbeddedBundles",
|
||||
@ -117,8 +163,13 @@ def prepareXuqmBundles = tasks.register(
|
||||
description = "Builds and embeds all configured Xuqm RN plugin bundles."
|
||||
projectRootDirectory.set(xuqmProjectRoot)
|
||||
cliFile.set(new File(xuqmProjectRoot, "node_modules/@xuqm/rn-update/scripts/xuqm-rn.mjs"))
|
||||
configFile.set(new File(xuqmProjectRoot, "xuqm.config.json"))
|
||||
configFile.set(new File(xuqmProjectRoot, "xuqm.modules.json"))
|
||||
packageFile.set(new File(xuqmProjectRoot, "package.json"))
|
||||
signedConfigFile.set(
|
||||
xuqmSignedConfigCandidates
|
||||
? xuqmSignedConfigCandidates.first()
|
||||
: new File(xuqmSignedConfigDirectory, "config.xuqmconfig")
|
||||
)
|
||||
sourceFiles.from(project.fileTree(xuqmProjectRoot) {
|
||||
include("src/**")
|
||||
include("assets/**")
|
||||
@ -166,6 +217,8 @@ def prepareXuqmBundles = tasks.register(
|
||||
}
|
||||
version.toString()
|
||||
})
|
||||
buildId.set(xuqmBuildId)
|
||||
bugCollectMode.set(xuqmBugCollectMode)
|
||||
assetsOutputDirectory.set(layout.buildDirectory.dir("generated/xuqm/assets"))
|
||||
resourcesOutputDirectory.set(layout.buildDirectory.dir("generated/xuqm/res"))
|
||||
}
|
||||
|
||||
@ -0,0 +1,38 @@
|
||||
export const BUGCOLLECT_ARTIFACT_UPLOAD_PATH = '/bugcollect/v1/artifacts/upload'
|
||||
|
||||
/** Source Map 与插件发布使用同一流水线 Bearer,不允许匿名上传构建制品。 */
|
||||
export function createArtifactUploadRequest(apiToken, form) {
|
||||
if (typeof apiToken !== 'string' || apiToken.trim() === '') {
|
||||
throw new Error('artifact upload requires an API token')
|
||||
}
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { Authorization: `Bearer ${apiToken}` },
|
||||
body: form,
|
||||
}
|
||||
}
|
||||
|
||||
/** RN Source Map 与 Android R8 Mapping 共用 artifacts 入口,但类型和身份字段明确分离。 */
|
||||
export function createRnSourceMapForm({
|
||||
appKey,
|
||||
platform,
|
||||
appVersion,
|
||||
buildId,
|
||||
moduleId,
|
||||
moduleVersion,
|
||||
bundleHash,
|
||||
fileBytes,
|
||||
fileName,
|
||||
}) {
|
||||
const form = new FormData()
|
||||
form.append('artifactType', 'RN_SOURCEMAP')
|
||||
form.append('appKey', appKey)
|
||||
form.append('platform', platform)
|
||||
form.append('appVersion', appVersion)
|
||||
form.append('buildId', buildId)
|
||||
form.append('moduleId', moduleId)
|
||||
form.append('moduleVersion', moduleVersion)
|
||||
form.append('bundleHash', bundleHash)
|
||||
form.append('file', new Blob([fileBytes]), fileName)
|
||||
return form
|
||||
}
|
||||
@ -0,0 +1,45 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
BUGCOLLECT_ARTIFACT_UPLOAD_PATH,
|
||||
createArtifactUploadRequest,
|
||||
createRnSourceMapForm,
|
||||
} from './artifact-upload.mjs'
|
||||
|
||||
test('RN Source Map uses the frozen artifact upload contract', () => {
|
||||
const form = createRnSourceMapForm({
|
||||
appKey: 'app',
|
||||
platform: 'android',
|
||||
appVersion: '8.0.0',
|
||||
buildId: 'build-1',
|
||||
moduleId: 'orders',
|
||||
moduleVersion: '1.0.2',
|
||||
bundleHash: 'a'.repeat(64),
|
||||
fileBytes: new Uint8Array([1, 2, 3]),
|
||||
fileName: 'orders.android.map',
|
||||
})
|
||||
assert.equal(BUGCOLLECT_ARTIFACT_UPLOAD_PATH, '/bugcollect/v1/artifacts/upload')
|
||||
assert.deepEqual(
|
||||
Object.fromEntries(
|
||||
[...form.entries()]
|
||||
.filter(([, value]) => typeof value === 'string')
|
||||
.map(([key, value]) => [key, value]),
|
||||
),
|
||||
{
|
||||
artifactType: 'RN_SOURCEMAP',
|
||||
appKey: 'app',
|
||||
platform: 'android',
|
||||
appVersion: '8.0.0',
|
||||
buildId: 'build-1',
|
||||
moduleId: 'orders',
|
||||
moduleVersion: '1.0.2',
|
||||
bundleHash: 'a'.repeat(64),
|
||||
},
|
||||
)
|
||||
assert.equal(form.get('file') instanceof Blob, true)
|
||||
const request = createArtifactUploadRequest('release-token', form)
|
||||
assert.deepEqual(request.headers, { Authorization: 'Bearer release-token' })
|
||||
assert.equal(request.body, form)
|
||||
assert.throws(() => createArtifactUploadRequest('', form), /requires an API token/)
|
||||
})
|
||||
@ -0,0 +1,48 @@
|
||||
const BUGCOLLECT_MODES = new Set(['platform', 'disabled'])
|
||||
|
||||
/**
|
||||
* Release 打包参数的唯一解析入口。CLI 同时接收标准的 `--bugcollect=disabled`
|
||||
* 和命令行工具常见的空格写法,但后续流程只消费归一化结果。
|
||||
*/
|
||||
export function parsePackageOptions(optionArgs) {
|
||||
const remaining = [...optionArgs]
|
||||
const apkIndex = remaining.indexOf('--apk')
|
||||
const apk = apkIndex >= 0
|
||||
if (apk) remaining.splice(apkIndex, 1)
|
||||
|
||||
const parsedBugCollect = parseBugCollectOption(remaining)
|
||||
if (parsedBugCollect.remaining.length) {
|
||||
throw new Error(`unknown option(s): ${parsedBugCollect.remaining.join(' ')}`)
|
||||
}
|
||||
return { apk, bugCollectMode: parsedBugCollect.bugCollectMode }
|
||||
}
|
||||
|
||||
export function parseBugCollectOption(optionArgs, defaultMode = 'platform') {
|
||||
const remaining = [...optionArgs]
|
||||
const equalsOptions = remaining.filter(option => option.startsWith('--bugcollect='))
|
||||
const spaceIndexes = remaining
|
||||
.map((option, index) => (option === '--bugcollect' ? index : -1))
|
||||
.filter(index => index >= 0)
|
||||
if (equalsOptions.length + spaceIndexes.length > 1) {
|
||||
throw new Error('--bugcollect must be specified only once')
|
||||
}
|
||||
|
||||
let bugCollectMode = defaultMode
|
||||
if (equalsOptions.length === 1) {
|
||||
const index = remaining.indexOf(equalsOptions[0])
|
||||
bugCollectMode = equalsOptions[0].slice('--bugcollect='.length)
|
||||
remaining.splice(index, 1)
|
||||
} else if (spaceIndexes.length === 1) {
|
||||
const index = spaceIndexes[0]
|
||||
if (remaining[index + 1] === undefined) {
|
||||
throw new Error('--bugcollect requires a value')
|
||||
}
|
||||
bugCollectMode = remaining[index + 1]
|
||||
remaining.splice(index, 2)
|
||||
}
|
||||
|
||||
if (!BUGCOLLECT_MODES.has(bugCollectMode)) {
|
||||
throw new Error('--bugcollect must be platform or disabled')
|
||||
}
|
||||
return { bugCollectMode, remaining }
|
||||
}
|
||||
@ -0,0 +1,40 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { parseBugCollectOption, parsePackageOptions } from './package-options.mjs'
|
||||
|
||||
test('package options normalize equals and space bugcollect syntax', () => {
|
||||
assert.deepEqual(parsePackageOptions(['--bugcollect=disabled']), {
|
||||
apk: false,
|
||||
bugCollectMode: 'disabled',
|
||||
})
|
||||
assert.deepEqual(parsePackageOptions(['--apk', '--bugcollect', 'disabled']), {
|
||||
apk: true,
|
||||
bugCollectMode: 'disabled',
|
||||
})
|
||||
assert.deepEqual(parsePackageOptions([]), {
|
||||
apk: false,
|
||||
bugCollectMode: 'platform',
|
||||
})
|
||||
})
|
||||
|
||||
test('publish and package share one BugCollect option parser', () => {
|
||||
assert.deepEqual(parseBugCollectOption(['--module', 'app', '--bugcollect=disabled']), {
|
||||
bugCollectMode: 'disabled',
|
||||
remaining: ['--module', 'app'],
|
||||
})
|
||||
assert.deepEqual(parseBugCollectOption(['--note', 'release'], 'disabled'), {
|
||||
bugCollectMode: 'disabled',
|
||||
remaining: ['--note', 'release'],
|
||||
})
|
||||
})
|
||||
|
||||
test('package options reject duplicate, missing and invalid values', () => {
|
||||
assert.throws(
|
||||
() => parsePackageOptions(['--bugcollect=disabled', '--bugcollect', 'platform']),
|
||||
/only once/,
|
||||
)
|
||||
assert.throws(() => parsePackageOptions(['--bugcollect']), /requires a value/)
|
||||
assert.throws(() => parsePackageOptions(['--bugcollect=unknown']), /platform or disabled/)
|
||||
assert.throws(() => parsePackageOptions(['--unknown']), /unknown option/)
|
||||
})
|
||||
@ -0,0 +1,40 @@
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { createRequire } from 'node:module'
|
||||
import path from 'node:path'
|
||||
|
||||
/**
|
||||
* 整包与插件发布共用的配置验证门禁。只阻断新产物;已安装 App 不在线查询吊销状态。
|
||||
*/
|
||||
export async function validateReleaseConfig({ projectRoot, packageName, platform }) {
|
||||
const hostRequire = createRequire(path.join(projectRoot, 'package.json'))
|
||||
const commonMetro = hostRequire('@xuqm/rn-common/metro')
|
||||
const configFile = commonMetro.findConfigFile(projectRoot)
|
||||
if (!configFile) {
|
||||
throw new Error('exactly one .xuqmconfig file is required in src/assets/config for Release')
|
||||
}
|
||||
|
||||
const content = readFileSync(configFile, 'utf8').trim()
|
||||
const resolved = commonMetro.decryptConfigAtBuildTime(content)
|
||||
const targetPackageName =
|
||||
packageName ??
|
||||
(platform === 'ios' ? (resolved.iosBundleId ?? resolved.packageName) : resolved.packageName)
|
||||
if (!targetPackageName) throw new Error('A package identity is required for Release validation')
|
||||
const response = await fetch(
|
||||
`${String(resolved.serverUrl).replace(/\/$/, '')}/api/sdk/build/config/validate`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ content, packageName: targetPackageName }),
|
||||
},
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Release config validation service failed: HTTP ${response.status}`)
|
||||
}
|
||||
const payload = await response.json()
|
||||
const result = payload.data ?? payload
|
||||
if (result.valid !== true) {
|
||||
throw new Error(
|
||||
`Release config rejected (${result.errorCode ?? 'CONFIG_INVALID'}): ${result.message ?? 'unknown error'}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,66 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
const MAX_ANDROID_VERSION_CODE = 2_100_000_000
|
||||
|
||||
function readPreviousVersionCode(cacheFile) {
|
||||
try {
|
||||
return Number(JSON.parse(readFileSync(cacheFile, 'utf8')).versionCode) || 0
|
||||
} catch {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 为同一 versionName 的连续测试包生成可安装的新构建身份。
|
||||
*
|
||||
* 本地缓存只负责同一工作区内的单调递增;CI 若需要跨工作区全局唯一,必须通过
|
||||
* XUQM_VERSION_CODE 注入流水线唯一编号。
|
||||
*/
|
||||
export function createAndroidReleaseIdentity({
|
||||
projectRoot,
|
||||
environment = process.env,
|
||||
now = Date.now(),
|
||||
entropy = `${process.pid}:${Math.random()}`,
|
||||
}) {
|
||||
const cacheFile = path.join(projectRoot, '.xuqm-cache', 'release-build.json')
|
||||
const previousVersionCode = readPreviousVersionCode(cacheFile)
|
||||
const requestedText = environment.XUQM_VERSION_CODE
|
||||
const requested = requestedText === undefined ? undefined : Number(requestedText)
|
||||
if (
|
||||
requestedText !== undefined &&
|
||||
(!Number.isInteger(requested) || requested <= 0 || requested > MAX_ANDROID_VERSION_CODE)
|
||||
) {
|
||||
throw new Error(
|
||||
`XUQM_VERSION_CODE must be an integer between 1 and ${MAX_ANDROID_VERSION_CODE}`,
|
||||
)
|
||||
}
|
||||
const epochSeconds = Math.floor(now / 1_000)
|
||||
const versionCode = requested ?? Math.max(epochSeconds, previousVersionCode + 1)
|
||||
if (versionCode > MAX_ANDROID_VERSION_CODE) {
|
||||
throw new Error('Generated Android versionCode exceeds limit')
|
||||
}
|
||||
|
||||
const buildId =
|
||||
environment.XUQM_BUILD_ID ??
|
||||
createHash('sha256').update(`${versionCode}:${now}:${entropy}`).digest('hex').slice(0, 32)
|
||||
if (!/^[A-Za-z0-9._-]{1,128}$/.test(buildId)) {
|
||||
throw new Error('XUQM_BUILD_ID must contain only letters, numbers, dot, underscore or hyphen')
|
||||
}
|
||||
|
||||
mkdirSync(path.dirname(cacheFile), { recursive: true })
|
||||
writeFileSync(
|
||||
cacheFile,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
versionCode,
|
||||
buildId,
|
||||
generatedAt: new Date(now).toISOString(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
)
|
||||
return { versionCode, buildId }
|
||||
}
|
||||
@ -0,0 +1,68 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
|
||||
import { createAndroidReleaseIdentity } from './release-identity.mjs'
|
||||
|
||||
test('release identity increases for repeated builds with the same app version', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-identity-'))
|
||||
try {
|
||||
const first = createAndroidReleaseIdentity({
|
||||
projectRoot: root,
|
||||
environment: {},
|
||||
now: 1_800_000_000_000,
|
||||
entropy: 'first',
|
||||
})
|
||||
const second = createAndroidReleaseIdentity({
|
||||
projectRoot: root,
|
||||
environment: {},
|
||||
now: 1_800_000_000_000,
|
||||
entropy: 'second',
|
||||
})
|
||||
assert.equal(second.versionCode, first.versionCode + 1)
|
||||
assert.notEqual(second.buildId, first.buildId)
|
||||
const cached = JSON.parse(
|
||||
readFileSync(path.join(root, '.xuqm-cache', 'release-build.json'), 'utf8'),
|
||||
)
|
||||
assert.deepEqual({ versionCode: cached.versionCode, buildId: cached.buildId }, second)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('release identity accepts explicit CI identity and validates its contract', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-identity-ci-'))
|
||||
try {
|
||||
assert.deepEqual(
|
||||
createAndroidReleaseIdentity({
|
||||
projectRoot: root,
|
||||
environment: {
|
||||
XUQM_VERSION_CODE: '123456',
|
||||
XUQM_BUILD_ID: 'jenkins-123456',
|
||||
},
|
||||
now: 1_800_000_000_000,
|
||||
}),
|
||||
{ versionCode: 123456, buildId: 'jenkins-123456' },
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
createAndroidReleaseIdentity({
|
||||
projectRoot: root,
|
||||
environment: { XUQM_VERSION_CODE: 'invalid' },
|
||||
}),
|
||||
/XUQM_VERSION_CODE/,
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
createAndroidReleaseIdentity({
|
||||
projectRoot: root,
|
||||
environment: { XUQM_BUILD_ID: 'contains whitespace' },
|
||||
}),
|
||||
/XUQM_BUILD_ID/,
|
||||
)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,98 @@
|
||||
import { createHash } from 'node:crypto'
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { strFromU8, unzipSync } from 'fflate'
|
||||
|
||||
function requiredString(manifest, key) {
|
||||
const value = manifest?.[key]
|
||||
if (typeof value !== 'string' || value.trim() === '') {
|
||||
throw new Error(`plugin package manifest ${key} is missing`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 发布身份只能来自即将上传的 ZIP,不能根据环境变量或缓存再次推导。这样 Bundle、
|
||||
* Source Map 和服务端登记始终使用同一个构建产生的精确身份。
|
||||
*/
|
||||
export function readReleasePackageIdentity(packageFile, expected) {
|
||||
const archive = unzipSync(new Uint8Array(readFileSync(packageFile)))
|
||||
const manifestBytes = archive['rn-manifest.json']
|
||||
if (!manifestBytes) throw new Error('plugin package manifest is missing')
|
||||
|
||||
let manifest
|
||||
try {
|
||||
manifest = JSON.parse(strFromU8(manifestBytes))
|
||||
} catch {
|
||||
throw new Error('plugin package manifest is invalid JSON')
|
||||
}
|
||||
|
||||
const moduleId = requiredString(manifest, 'moduleId')
|
||||
const platform = requiredString(manifest, 'platform')
|
||||
const moduleVersion = requiredString(manifest, 'version')
|
||||
const appVersion = requiredString(manifest, 'appVersion')
|
||||
const buildId = requiredString(manifest, 'buildId')
|
||||
const bundleFile = requiredString(manifest, 'bundleFile')
|
||||
const bundleHash = requiredString(manifest, 'bundleSha256')
|
||||
const bundleFormat = requiredString(manifest, 'bundleFormat')
|
||||
const nativeBaselineId = requiredString(manifest, 'builtAgainstNativeBaselineId')
|
||||
const appVersionRange = requiredString(manifest, 'appVersionRange')
|
||||
const minNativeApiLevel = manifest.minNativeApiLevel
|
||||
|
||||
if (buildId === 'development') {
|
||||
throw new Error('plugin package was built without a release buildId; rebuild before publishing')
|
||||
}
|
||||
if (expected.moduleId !== moduleId) {
|
||||
throw new Error(
|
||||
`plugin package moduleId mismatch: expected ${expected.moduleId}, got ${moduleId}`,
|
||||
)
|
||||
}
|
||||
if (expected.platform !== platform) {
|
||||
throw new Error(
|
||||
`plugin package platform mismatch: expected ${expected.platform}, got ${platform}`,
|
||||
)
|
||||
}
|
||||
if (expected.moduleVersion !== moduleVersion) {
|
||||
throw new Error(
|
||||
`plugin package version mismatch: expected ${expected.moduleVersion}, got ${moduleVersion}`,
|
||||
)
|
||||
}
|
||||
if (expected.appVersion !== appVersion) {
|
||||
throw new Error(
|
||||
`plugin package appVersion mismatch: expected ${expected.appVersion}, got ${appVersion}`,
|
||||
)
|
||||
}
|
||||
if (!/^[a-f0-9]{64}$/i.test(bundleHash)) {
|
||||
throw new Error('plugin package bundleSha256 is invalid')
|
||||
}
|
||||
if (!Number.isInteger(minNativeApiLevel) || minNativeApiLevel < 1) {
|
||||
throw new Error('plugin package minNativeApiLevel is invalid')
|
||||
}
|
||||
if (
|
||||
manifest.commonVersionRange !== undefined &&
|
||||
(typeof manifest.commonVersionRange !== 'string' || !manifest.commonVersionRange.trim())
|
||||
) {
|
||||
throw new Error('plugin package commonVersionRange is invalid')
|
||||
}
|
||||
|
||||
const bundleBytes = archive[bundleFile]
|
||||
if (!bundleBytes) throw new Error(`plugin package bundle is missing: ${bundleFile}`)
|
||||
const actualBundleHash = createHash('sha256').update(bundleBytes).digest('hex')
|
||||
if (actualBundleHash !== bundleHash.toLowerCase()) {
|
||||
throw new Error('plugin package bundleSha256 does not match bundle content')
|
||||
}
|
||||
|
||||
return {
|
||||
manifest,
|
||||
moduleId,
|
||||
platform,
|
||||
moduleVersion,
|
||||
appVersion,
|
||||
buildId,
|
||||
bundleHash: actualBundleHash,
|
||||
bundleFormat,
|
||||
nativeBaselineId,
|
||||
appVersionRange,
|
||||
commonVersionRange: manifest.commonVersionRange,
|
||||
minNativeApiLevel,
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,76 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import test from 'node:test'
|
||||
import { strToU8, zipSync } from 'fflate'
|
||||
import { readReleasePackageIdentity } from './release-package.mjs'
|
||||
|
||||
function createPackage(root, overrides = {}, bundle = 'bundle') {
|
||||
const bundleBytes = strToU8(bundle)
|
||||
const manifest = {
|
||||
schemaVersion: 1,
|
||||
moduleId: 'app',
|
||||
platform: 'android',
|
||||
version: '1.0.1',
|
||||
appVersion: '8.0.0',
|
||||
buildId: 'build-20260726',
|
||||
bundleFile: 'app.android.bundle',
|
||||
bundleSha256: createHash('sha256').update(bundleBytes).digest('hex'),
|
||||
bundleFormat: 'hermes-bytecode',
|
||||
builtAgainstNativeBaselineId: 'native-1',
|
||||
appVersionRange: '>=8.0.0 <9.0.0',
|
||||
commonVersionRange: '>=1.0.0 <2.0.0',
|
||||
minNativeApiLevel: 2,
|
||||
...overrides,
|
||||
}
|
||||
const file = path.join(root, 'app.android.xuqm.zip')
|
||||
writeFileSync(
|
||||
file,
|
||||
zipSync({
|
||||
'rn-manifest.json': strToU8(JSON.stringify(manifest)),
|
||||
'app.android.bundle': bundleBytes,
|
||||
}),
|
||||
)
|
||||
return file
|
||||
}
|
||||
|
||||
const expected = {
|
||||
moduleId: 'app',
|
||||
platform: 'android',
|
||||
moduleVersion: '1.0.1',
|
||||
appVersion: '8.0.0',
|
||||
}
|
||||
|
||||
test('release identity is read from and verified against the packaged Bundle', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-'))
|
||||
try {
|
||||
const identity = readReleasePackageIdentity(createPackage(root), expected)
|
||||
assert.equal(identity.buildId, 'build-20260726')
|
||||
assert.equal(identity.bundleFormat, 'hermes-bytecode')
|
||||
assert.equal(identity.nativeBaselineId, 'native-1')
|
||||
assert.equal(identity.appVersionRange, '>=8.0.0 <9.0.0')
|
||||
assert.equal(identity.commonVersionRange, '>=1.0.0 <2.0.0')
|
||||
assert.equal(identity.minNativeApiLevel, 2)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
test('publishing rejects development identity and mismatched packaged Bundle hash', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-release-package-invalid-'))
|
||||
try {
|
||||
assert.throws(
|
||||
() => readReleasePackageIdentity(createPackage(root, { buildId: 'development' }), expected),
|
||||
/without a release buildId/,
|
||||
)
|
||||
assert.throws(
|
||||
() =>
|
||||
readReleasePackageIdentity(createPackage(root, { bundleSha256: 'a'.repeat(64) }), expected),
|
||||
/does not match bundle content/,
|
||||
)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
@ -0,0 +1,45 @@
|
||||
import { readFileSync, writeFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
function normalize(value) {
|
||||
return value.replaceAll('\\', '/')
|
||||
}
|
||||
|
||||
function externalSourceName(normalized) {
|
||||
const nodeModulesMarker = '/node_modules/'
|
||||
const markerIndex = normalized.lastIndexOf(nodeModulesMarker)
|
||||
if (markerIndex >= 0) {
|
||||
return `external/node_modules/${normalized.slice(markerIndex + nodeModulesMarker.length)}`
|
||||
}
|
||||
return `external/${normalized.split('/').filter(Boolean).at(-1) ?? 'unknown-source'}`
|
||||
}
|
||||
|
||||
/**
|
||||
* root 内绝对路径变成项目相对路径;root 外绝对路径只保留稳定的外部标识,
|
||||
* 绝不把 `../` 剥掉后伪装成项目源码,也不泄露开发机用户名或目录。
|
||||
*/
|
||||
export function sanitizeSourcePath(source, projectRoot) {
|
||||
if (typeof source !== 'string') return source
|
||||
const normalizedSource = normalize(source)
|
||||
const normalizedRoot = normalize(projectRoot).replace(/\/+$/, '')
|
||||
const windowsAbsolute = path.win32.isAbsolute(source)
|
||||
const posixAbsolute = path.posix.isAbsolute(normalizedSource)
|
||||
if (!windowsAbsolute && !posixAbsolute) return normalizedSource
|
||||
|
||||
const pathApi = windowsAbsolute ? path.win32 : path.posix
|
||||
const comparableSource = windowsAbsolute ? source : normalizedSource
|
||||
const comparableRoot = windowsAbsolute ? projectRoot : normalizedRoot
|
||||
const relative = pathApi.relative(comparableRoot, comparableSource)
|
||||
const outside =
|
||||
relative === '..' || relative.startsWith(`..${pathApi.sep}`) || pathApi.isAbsolute(relative)
|
||||
return outside ? externalSourceName(normalizedSource) : normalize(relative || '.')
|
||||
}
|
||||
|
||||
export function sanitizeSourceMapFile(sourceMapFile, projectRoot) {
|
||||
const sourceMap = JSON.parse(readFileSync(sourceMapFile, 'utf8'))
|
||||
delete sourceMap.sourcesContent
|
||||
if (Array.isArray(sourceMap.sources)) {
|
||||
sourceMap.sources = sourceMap.sources.map(source => sanitizeSourcePath(source, projectRoot))
|
||||
}
|
||||
writeFileSync(sourceMapFile, JSON.stringify(sourceMap))
|
||||
}
|
||||
@ -0,0 +1,31 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { sanitizeSourcePath } from './source-map.mjs'
|
||||
|
||||
test('Unix sources distinguish project files from external absolute paths', () => {
|
||||
assert.equal(sanitizeSourcePath('/workspace/app/src/Home.tsx', '/workspace/app'), 'src/Home.tsx')
|
||||
assert.equal(
|
||||
sanitizeSourcePath('/Users/alice/shared/secret.ts', '/workspace/app'),
|
||||
'external/secret.ts',
|
||||
)
|
||||
assert.equal(
|
||||
sanitizeSourcePath('/Users/alice/app/node_modules/react/index.js', '/workspace/app'),
|
||||
'external/node_modules/react/index.js',
|
||||
)
|
||||
})
|
||||
|
||||
test('Windows sources distinguish project files without leaking host directories', () => {
|
||||
assert.equal(
|
||||
sanitizeSourcePath('C:\\workspace\\app\\src\\Home.tsx', 'C:\\workspace\\app'),
|
||||
'src/Home.tsx',
|
||||
)
|
||||
assert.equal(
|
||||
sanitizeSourcePath('D:\\Users\\alice\\private\\secret.ts', 'C:\\workspace\\app'),
|
||||
'external/secret.ts',
|
||||
)
|
||||
assert.equal(
|
||||
sanitizeSourcePath('D:\\cache\\node_modules\\react\\index.js', 'C:\\workspace\\app'),
|
||||
'external/node_modules/react/index.js',
|
||||
)
|
||||
})
|
||||
@ -2,7 +2,8 @@ import { existsSync, readFileSync } from 'node:fs'
|
||||
import path from 'node:path'
|
||||
import semver from 'semver'
|
||||
|
||||
export const CONFIG_FILE_NAME = 'xuqm.config.json'
|
||||
/** 插件构建清单;与平台签发的 config.xuqmconfig 初始化文件没有关系。 */
|
||||
export const CONFIG_FILE_NAME = 'xuqm.modules.json'
|
||||
export const SUPPORTED_PLATFORMS = new Set(['android', 'ios'])
|
||||
export const SUPPORTED_MODULE_TYPES = new Set(['startup', 'common', 'app', 'buz'])
|
||||
|
||||
|
||||
@ -29,6 +29,10 @@ import {
|
||||
validateConfig,
|
||||
} from './xuqm-config.mjs'
|
||||
import { computeNativeBaseline } from './native-baseline.mjs'
|
||||
import { parseBugCollectOption, parsePackageOptions } from './package-options.mjs'
|
||||
import { validateReleaseConfig } from './release-config.mjs'
|
||||
import { createAndroidReleaseIdentity } from './release-identity.mjs'
|
||||
import { sanitizeSourceMapFile } from './source-map.mjs'
|
||||
|
||||
const root = process.cwd()
|
||||
const args = process.argv.slice(2)
|
||||
@ -43,6 +47,26 @@ function writeJson(file, value) {
|
||||
writeFileSync(file, `${JSON.stringify(value, null, 2)}\n`)
|
||||
}
|
||||
|
||||
function androidApplicationId() {
|
||||
const gradleFile = ['android/app/build.gradle', 'android/app/build.gradle.kts']
|
||||
.map(file => path.resolve(root, file))
|
||||
.find(existsSync)
|
||||
if (!gradleFile) throw new Error('Android app Gradle file is missing')
|
||||
const match = readFileSync(gradleFile, 'utf8').match(
|
||||
/\bapplicationId\s*(?:=|\s)\s*["']([^"']+)["']/,
|
||||
)
|
||||
if (!match?.[1]) throw new Error('Android applicationId could not be resolved')
|
||||
return match[1]
|
||||
}
|
||||
|
||||
async function validateReleaseConfigOnline(platform) {
|
||||
await validateReleaseConfig({
|
||||
projectRoot: root,
|
||||
packageName: platform === 'android' ? androidApplicationId() : undefined,
|
||||
platform,
|
||||
})
|
||||
}
|
||||
|
||||
function childEnvironment(overrides = {}) {
|
||||
const environment = { ...process.env, ...overrides }
|
||||
// Metro may set FORCE_COLOR in its transformer workers. Forward a single
|
||||
@ -337,9 +361,8 @@ function build(platform, optionArgs = []) {
|
||||
]
|
||||
const metroConfig = module.metroConfig ?? config.metroConfig
|
||||
if (metroConfig) cliArgs.push('--config', metroConfig)
|
||||
if (module.sourceMap === true) {
|
||||
cliArgs.push('--sourcemap-output', `${bundleFile(root, config, platform, module.id)}.map`)
|
||||
}
|
||||
// 每个 Release 模块始终生成独立 Source Map;是否上传由发布阶段决定。
|
||||
cliArgs.push('--sourcemap-output', `${bundleFile(root, config, platform, module.id)}.map`)
|
||||
console.log(`xuqm-rn: building ${module.id}/${platform}`)
|
||||
runReactNative(cliArgs.slice(1), {
|
||||
XUQM_MODULE_CACHE_FILE: path.join(root, '.xuqm-cache', 'common-module-ids.json'),
|
||||
@ -348,8 +371,8 @@ function build(platform, optionArgs = []) {
|
||||
XUQM_MODULE_TYPE: module.type,
|
||||
XUQM_RESET_MODULE_CACHE: buildIndex === 0 ? 'true' : 'false',
|
||||
})
|
||||
if (useHermes)
|
||||
compileHermesBundle(bundleFile(root, config, platform, module.id), module.sourceMap === true)
|
||||
if (useHermes) compileHermesBundle(bundleFile(root, config, platform, module.id), true)
|
||||
sanitizeSourceMapFile(`${bundleFile(root, config, platform, module.id)}.map`, root)
|
||||
// startup 是随宿主 APK 固化的最小引导代码,不属于可在线发布插件。为它生成
|
||||
// 发布包既没有安装入口,也容易让发布人员误认为它可以脱离整包升级。
|
||||
if (module.type !== 'startup') {
|
||||
@ -421,6 +444,8 @@ function createModulePackage(config, platform, module, appVersion, nativeBaselin
|
||||
appVersionRange: module.appVersionRange,
|
||||
builtAgainstNativeBaselineId: nativeBaselineId,
|
||||
bundleFormat,
|
||||
buildId: process.env.XUQM_BUILD_ID ?? 'development',
|
||||
bugCollectMode: process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
|
||||
bundleFile: bundleName,
|
||||
bundleByteLength: bundleBytes.byteLength,
|
||||
bundleSha256: createHash('sha256').update(bundleBytes).digest('hex'),
|
||||
@ -487,6 +512,8 @@ function createManifest(config, platform, modules, appVersion, nativeBaselineId)
|
||||
bundleFormat: platform === 'android' && androidUsesHermes() ? 'hermes-bytecode' : 'javascript',
|
||||
version: config.manifestVersion ?? 1,
|
||||
nativeBaselineId,
|
||||
buildId: process.env.XUQM_BUILD_ID ?? 'development',
|
||||
bugCollectMode: process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
|
||||
modules: entries,
|
||||
}
|
||||
}
|
||||
@ -563,33 +590,81 @@ function embed(platform, optionArgs = []) {
|
||||
}
|
||||
|
||||
function publish(platform, optionArgs) {
|
||||
const { moduleIds } = parseModuleOptions(optionArgs)
|
||||
const { bugCollectMode, remaining } = parseBugCollectOption(
|
||||
optionArgs,
|
||||
process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
|
||||
)
|
||||
const { moduleIds } = parseModuleOptions(remaining)
|
||||
const buildOptions = moduleIds.flatMap(moduleId => ['--module', moduleId])
|
||||
build(platform, buildOptions)
|
||||
const releaseScript = new URL('./xuqm_release.mjs', import.meta.url)
|
||||
execFileSync(process.execPath, [releaseScript.pathname, '--platform', platform, ...optionArgs], {
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
const previousBuildId = process.env.XUQM_BUILD_ID
|
||||
const previousVersionCode = process.env.XUQM_VERSION_CODE
|
||||
const previousBugCollectMode = process.env.XUQM_BUGCOLLECT_MODE
|
||||
if (platform === 'android' && !previousBuildId) {
|
||||
const identity = createAndroidReleaseIdentity({ projectRoot: root })
|
||||
process.env.XUQM_BUILD_ID = identity.buildId
|
||||
process.env.XUQM_VERSION_CODE = String(identity.versionCode)
|
||||
}
|
||||
process.env.XUQM_BUGCOLLECT_MODE = bugCollectMode
|
||||
try {
|
||||
build(platform, buildOptions)
|
||||
const releaseScript = new URL('./xuqm_release.mjs', import.meta.url)
|
||||
execFileSync(
|
||||
process.execPath,
|
||||
[releaseScript.pathname, '--platform', platform, ...optionArgs],
|
||||
{
|
||||
cwd: root,
|
||||
stdio: 'inherit',
|
||||
},
|
||||
)
|
||||
} finally {
|
||||
if (previousBuildId === undefined) delete process.env.XUQM_BUILD_ID
|
||||
else process.env.XUQM_BUILD_ID = previousBuildId
|
||||
if (previousVersionCode === undefined) delete process.env.XUQM_VERSION_CODE
|
||||
else process.env.XUQM_VERSION_CODE = previousVersionCode
|
||||
if (previousBugCollectMode === undefined) delete process.env.XUQM_BUGCOLLECT_MODE
|
||||
else process.env.XUQM_BUGCOLLECT_MODE = previousBugCollectMode
|
||||
}
|
||||
}
|
||||
|
||||
function packageApp(platform, optionArgs) {
|
||||
async function packageApp(platform, optionArgs) {
|
||||
assertPlatform(platform)
|
||||
if (platform !== 'android') fail('native package command currently supports android only')
|
||||
const apkIndex = optionArgs.indexOf('--apk')
|
||||
const apk = apkIndex >= 0
|
||||
if (apk) optionArgs.splice(apkIndex, 1)
|
||||
if (optionArgs.length) fail(`unknown option(s): ${optionArgs.join(' ')}`)
|
||||
let packageOptions
|
||||
try {
|
||||
packageOptions = parsePackageOptions(optionArgs)
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
const { apk, bugCollectMode } = packageOptions
|
||||
await validateReleaseConfigOnline(platform)
|
||||
const buildIdentity = createAndroidReleaseIdentity({ projectRoot: root })
|
||||
const androidDirectory = path.join(root, 'android')
|
||||
const wrapper = path.join(
|
||||
androidDirectory,
|
||||
process.platform === 'win32' ? 'gradlew.bat' : 'gradlew',
|
||||
)
|
||||
if (!existsSync(wrapper)) fail('Android Gradle wrapper is missing')
|
||||
execFileSync(wrapper, [apk ? 'assembleRelease' : 'bundleRelease'], {
|
||||
cwd: androidDirectory,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
execFileSync(
|
||||
wrapper,
|
||||
[
|
||||
apk ? 'assembleRelease' : 'bundleRelease',
|
||||
`-PXUQM_VERSION_CODE=${buildIdentity.versionCode}`,
|
||||
`-PXUQM_BUILD_ID=${buildIdentity.buildId}`,
|
||||
`-PXUQM_BUGCOLLECT_MODE=${bugCollectMode}`,
|
||||
],
|
||||
{
|
||||
cwd: androidDirectory,
|
||||
stdio: 'inherit',
|
||||
env: childEnvironment({
|
||||
XUQM_BUGCOLLECT_MODE: bugCollectMode,
|
||||
XUQM_BUILD_ID: buildIdentity.buildId,
|
||||
XUQM_VERSION_CODE: String(buildIdentity.versionCode),
|
||||
}),
|
||||
},
|
||||
)
|
||||
console.log(
|
||||
`xuqm-rn: release identity versionCode=${buildIdentity.versionCode} buildId=${buildIdentity.buildId}`,
|
||||
)
|
||||
}
|
||||
|
||||
function startMetro(optionArgs) {
|
||||
@ -650,7 +725,7 @@ try {
|
||||
publish(args.shift() ?? 'android', args)
|
||||
break
|
||||
case 'package':
|
||||
packageApp(args.shift() ?? 'android', args)
|
||||
await packageApp(args.shift() ?? 'android', args)
|
||||
break
|
||||
default:
|
||||
printHelp()
|
||||
|
||||
@ -21,6 +21,17 @@ import { strFromU8, unzipSync } from 'fflate'
|
||||
const cli = fileURLToPath(new URL('./xuqm-rn.mjs', import.meta.url))
|
||||
const workspaceRequire = createRequire(import.meta.url)
|
||||
|
||||
test('Gradle models and forwards the exact embedded release identity', () => {
|
||||
const gradle = readFileSync(
|
||||
fileURLToPath(new URL('../android/xuqm-bundles.gradle', import.meta.url)),
|
||||
'utf8',
|
||||
)
|
||||
assert.match(gradle, /@Input\s+abstract Property<String> getBuildId\(\)/)
|
||||
assert.match(gradle, /@Input\s+abstract Property<String> getBugCollectMode\(\)/)
|
||||
assert.match(gradle, /environment\("XUQM_BUILD_ID", buildId\.get\(\)\)/)
|
||||
assert.match(gradle, /environment\("XUQM_BUGCOLLECT_MODE", bugCollectMode\.get\(\)\)/)
|
||||
})
|
||||
|
||||
test('embed creates a complete versioned manifest from a host fixture', () => {
|
||||
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-'))
|
||||
const output = path.join(root, 'generated', 'rn-bundles')
|
||||
@ -37,7 +48,7 @@ test('embed creates a complete versioned manifest from a host fixture', () => {
|
||||
writeFileSync(path.join(root, 'app.ts'), '')
|
||||
writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
path.join(root, 'xuqm.config.json'),
|
||||
path.join(root, 'xuqm.modules.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
appId: 'fixture.app',
|
||||
@ -104,7 +115,7 @@ test('Android Hermes hosts compile plugin bundles to bytecode', () => {
|
||||
mkdirSync(path.join(root, 'android'), { recursive: true })
|
||||
writeFileSync(path.join(root, 'android', 'gradle.properties'), 'hermesEnabled=true\n')
|
||||
writeFileSync(
|
||||
path.join(root, 'xuqm.config.json'),
|
||||
path.join(root, 'xuqm.modules.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
appId: 'fixture.app',
|
||||
@ -147,8 +158,24 @@ test('Android Hermes hosts compile plugin bundles to bytecode', () => {
|
||||
const path = require('node:path');
|
||||
const args = process.argv.slice(2);
|
||||
const output = args[args.indexOf('--bundle-output') + 1];
|
||||
const sourceMap = args[args.indexOf('--sourcemap-output') + 1];
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, 'javascript');
|
||||
fs.writeFileSync(sourceMap, JSON.stringify({
|
||||
version: 3,
|
||||
sources: ['/fixture/common.ts'],
|
||||
names: [],
|
||||
mappings: '',
|
||||
}));
|
||||
`,
|
||||
)
|
||||
writeFileSync(
|
||||
path.join(reactNativeDirectory, 'scripts', 'compose-source-maps.js'),
|
||||
`
|
||||
const fs = require('node:fs');
|
||||
const args = process.argv.slice(2);
|
||||
const output = args[args.indexOf('-o') + 1];
|
||||
fs.copyFileSync(args[0], output);
|
||||
`,
|
||||
)
|
||||
writeFileSync(
|
||||
@ -188,6 +215,11 @@ test('Android Hermes hosts compile plugin bundles to bytecode', () => {
|
||||
execFileSync(process.execPath, [cli, 'embed', 'android'], {
|
||||
cwd: root,
|
||||
stdio: 'pipe',
|
||||
env: {
|
||||
...process.env,
|
||||
XUQM_BUILD_ID: 'release-fixture-1',
|
||||
XUQM_BUGCOLLECT_MODE: 'disabled',
|
||||
},
|
||||
})
|
||||
|
||||
const bytecode = readFileSync(path.join(root, 'generated', 'common.android.bundle'))
|
||||
@ -195,6 +227,16 @@ test('Android Hermes hosts compile plugin bundles to bytecode', () => {
|
||||
assert.notEqual(bytecode.toString('utf8'), 'javascript')
|
||||
const manifest = JSON.parse(readFileSync(path.join(root, 'generated', 'manifest.json'), 'utf8'))
|
||||
assert.equal(manifest.bundleFormat, 'hermes-bytecode')
|
||||
assert.equal(manifest.buildId, 'release-fixture-1')
|
||||
assert.equal(manifest.bugCollectMode, 'disabled')
|
||||
const packaged = unzipSync(
|
||||
new Uint8Array(
|
||||
readFileSync(path.join(root, 'bundle/android/common/common.android.xuqm.zip')),
|
||||
),
|
||||
)
|
||||
const packagedManifest = JSON.parse(strFromU8(packaged['rn-manifest.json']))
|
||||
assert.equal(packagedManifest.buildId, manifest.buildId)
|
||||
assert.equal(packagedManifest.bugCollectMode, manifest.bugCollectMode)
|
||||
} finally {
|
||||
rmSync(root, { recursive: true, force: true })
|
||||
}
|
||||
@ -223,7 +265,7 @@ test('embed rejects assets owned by more than one module', () => {
|
||||
}
|
||||
writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
path.join(root, 'xuqm.config.json'),
|
||||
path.join(root, 'xuqm.modules.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
appId: 'fixture.app',
|
||||
@ -266,7 +308,7 @@ test('build invokes the host-local React Native CLI with a consistent color envi
|
||||
)
|
||||
writeFileSync(path.join(root, 'common.ts'), '')
|
||||
writeFileSync(
|
||||
path.join(root, 'xuqm.config.json'),
|
||||
path.join(root, 'xuqm.modules.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
appId: 'fixture.app',
|
||||
@ -287,8 +329,15 @@ test('build invokes the host-local React Native CLI with a consistent color envi
|
||||
const path = require('node:path');
|
||||
const args = process.argv.slice(2);
|
||||
const output = args[args.indexOf('--bundle-output') + 1];
|
||||
const sourceMap = args[args.indexOf('--sourcemap-output') + 1];
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, 'bundle');
|
||||
fs.writeFileSync(sourceMap, JSON.stringify({
|
||||
version: 3,
|
||||
sources: ['/fixture/common.ts'],
|
||||
names: [],
|
||||
mappings: '',
|
||||
}));
|
||||
fs.writeFileSync(path.join(process.cwd(), 'child.json'), JSON.stringify({
|
||||
args,
|
||||
forceColor: process.env.FORCE_COLOR ?? null,
|
||||
@ -331,7 +380,7 @@ test('building one buz first rebuilds startup and common module maps', () => {
|
||||
writeFileSync(path.join(root, 'shared', 'ignored.test.ts'), 'throw new Error()\n')
|
||||
writeFileSync(path.join(root, 'metro.config.js'), 'module.exports = {}\n')
|
||||
writeFileSync(
|
||||
path.join(root, 'xuqm.config.json'),
|
||||
path.join(root, 'xuqm.modules.json'),
|
||||
JSON.stringify({
|
||||
schemaVersion: 3,
|
||||
appId: 'fixture.app',
|
||||
@ -374,8 +423,15 @@ test('building one buz first rebuilds startup and common module maps', () => {
|
||||
});
|
||||
fs.writeFileSync(callsFile, JSON.stringify(calls));
|
||||
const output = args[args.indexOf('--bundle-output') + 1];
|
||||
const sourceMap = args[args.indexOf('--sourcemap-output') + 1];
|
||||
fs.mkdirSync(path.dirname(output), { recursive: true });
|
||||
fs.writeFileSync(output, 'bundle');
|
||||
fs.writeFileSync(sourceMap, JSON.stringify({
|
||||
version: 3,
|
||||
sources: ['/fixture/' + process.env.XUQM_MODULE_ID + '.ts'],
|
||||
names: [],
|
||||
mappings: '',
|
||||
}));
|
||||
`,
|
||||
)
|
||||
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import { existsSync, readFileSync } from 'node:fs'
|
||||
import { createHash } from 'node:crypto'
|
||||
import path from 'node:path'
|
||||
import { createInterface } from 'node:readline'
|
||||
import process from 'node:process'
|
||||
@ -12,7 +13,14 @@ import {
|
||||
resolveVersionedModules,
|
||||
selectModules,
|
||||
} from './xuqm-config.mjs'
|
||||
import { computeNativeBaseline } from './native-baseline.mjs'
|
||||
import {
|
||||
BUGCOLLECT_ARTIFACT_UPLOAD_PATH,
|
||||
createArtifactUploadRequest,
|
||||
createRnSourceMapForm,
|
||||
} from './artifact-upload.mjs'
|
||||
import { validateReleaseConfig } from './release-config.mjs'
|
||||
import { readReleasePackageIdentity } from './release-package.mjs'
|
||||
import { parseBugCollectOption } from './package-options.mjs'
|
||||
|
||||
const root = process.cwd()
|
||||
const rawArgs = process.argv.slice(2)
|
||||
@ -28,13 +36,21 @@ function takeOption(name) {
|
||||
|
||||
const platform = takeOption('--platform') ?? 'android'
|
||||
const noteOption = takeOption('--note')
|
||||
const minNativeApiLevelOption = takeOption('--min-native-api-level')
|
||||
const parsedBugCollect = parseBugCollectOption(
|
||||
rawArgs,
|
||||
process.env.XUQM_BUGCOLLECT_MODE ?? 'platform',
|
||||
)
|
||||
const bugCollectMode = parsedBugCollect.bugCollectMode
|
||||
rawArgs.splice(0, rawArgs.length, ...parsedBugCollect.remaining)
|
||||
const publishOptionIndex = rawArgs.indexOf('--publish')
|
||||
const publishOption = publishOptionIndex >= 0
|
||||
if (publishOption) rawArgs.splice(publishOptionIndex, 1)
|
||||
const { moduleIds, remaining } = parseModuleOptions(rawArgs)
|
||||
if (remaining.length) throw new Error(`unknown option(s): ${remaining.join(' ')}`)
|
||||
if (!SUPPORTED_PLATFORMS.has(platform)) throw new Error('platform must be android or ios')
|
||||
if (!['platform', 'disabled'].includes(bugCollectMode)) {
|
||||
throw new Error('--bugcollect must be platform or disabled')
|
||||
}
|
||||
|
||||
const config = readConfig(root)
|
||||
const versioned = resolveVersionedModules(config, root, process.env.XUQM_APP_VERSION)
|
||||
@ -44,20 +60,14 @@ if (moduleIds.length > 0 && selectedModules.some(module => module.type === 'star
|
||||
}
|
||||
const modules = selectedModules.filter(module => module.type !== 'startup')
|
||||
if (modules.length === 0) throw new Error('no publishable common/app/buz module selected')
|
||||
const nativeBaselineId = computeNativeBaseline(root, platform)
|
||||
const serverUrl = process.env.XUQM_SERVER_URL ?? config.release?.serverUrl
|
||||
const appKey = process.env.XUQM_APP_KEY ?? config.release?.appKey
|
||||
const apiToken = process.env.XUQM_API_TOKEN ?? config.release?.apiToken
|
||||
const androidPropertiesFile = path.join(root, 'android', 'gradle.properties')
|
||||
const bundleFormat =
|
||||
platform === 'android' &&
|
||||
existsSync(androidPropertiesFile) &&
|
||||
/^\s*hermesEnabled\s*=\s*true\s*$/im.test(readFileSync(androidPropertiesFile, 'utf8'))
|
||||
? 'hermes-bytecode'
|
||||
: 'javascript'
|
||||
// API Token 只允许由 CI/当前进程注入,禁止把可用凭据写进会进入版本库的插件清单。
|
||||
const apiToken = process.env.XUQM_API_TOKEN
|
||||
const bugCollectUrl = process.env.XUQM_BUGCOLLECT_URL
|
||||
if (!serverUrl || !appKey || !apiToken) {
|
||||
throw new Error(
|
||||
'publish requires XUQM_SERVER_URL, XUQM_APP_KEY and XUQM_API_TOKEN (or release.* config)',
|
||||
'publish requires XUQM_API_TOKEN and XUQM_SERVER_URL/XUQM_APP_KEY (environment or non-secret release config)',
|
||||
)
|
||||
}
|
||||
|
||||
@ -69,22 +79,42 @@ function apiHeaders() {
|
||||
return { Authorization: `Bearer ${apiToken}` }
|
||||
}
|
||||
|
||||
async function uploadBundle(module, note, minNativeApiLevel) {
|
||||
async function validateReleaseConfigOnline() {
|
||||
await validateReleaseConfig({
|
||||
projectRoot: root,
|
||||
packageName: config.packageName,
|
||||
platform,
|
||||
})
|
||||
}
|
||||
|
||||
function releasePackage(module) {
|
||||
const file = path.join(
|
||||
bundleOutputDirectory(root, config, platform, module.id),
|
||||
`${module.id}.${platform}.xuqm.zip`,
|
||||
)
|
||||
if (!existsSync(file)) throw new Error(`plugin package is missing: ${file}`)
|
||||
const identity = readReleasePackageIdentity(file, {
|
||||
moduleId: module.id,
|
||||
platform,
|
||||
moduleVersion: module.moduleVersion,
|
||||
appVersion: versioned.appVersion,
|
||||
})
|
||||
return { file, identity }
|
||||
}
|
||||
|
||||
async function uploadBundle(module, release, note) {
|
||||
const { file, identity } = release
|
||||
const form = new FormData()
|
||||
form.append('appKey', appKey)
|
||||
form.append('moduleId', module.id)
|
||||
form.append('platform', platform.toUpperCase())
|
||||
form.append('version', module.moduleVersion)
|
||||
form.append('appVersionRange', module.appVersionRange)
|
||||
form.append('builtAgainstNativeBaselineId', nativeBaselineId)
|
||||
form.append('bundleFormat', bundleFormat)
|
||||
form.append('commonVersionRange', module.commonVersionRange ?? '')
|
||||
form.append('minNativeApiLevel', String(module.minNativeApiLevel ?? minNativeApiLevel))
|
||||
form.append('version', identity.moduleVersion)
|
||||
form.append('appVersionRange', identity.appVersionRange)
|
||||
form.append('builtAgainstNativeBaselineId', identity.nativeBaselineId)
|
||||
form.append('buildId', identity.buildId)
|
||||
form.append('bundleFormat', identity.bundleFormat)
|
||||
form.append('commonVersionRange', identity.commonVersionRange ?? '')
|
||||
form.append('minNativeApiLevel', String(identity.minNativeApiLevel))
|
||||
form.append('note', note)
|
||||
if (config.packageName) form.append('packageName', config.packageName)
|
||||
form.append('bundle', new Blob([readFileSync(file)]), `${module.id}.${platform}.xuqm.zip`)
|
||||
@ -97,14 +127,47 @@ async function uploadBundle(module, note, minNativeApiLevel) {
|
||||
return response.json()
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const note = noteOption ?? (await ask('Release notes: '))
|
||||
const minNativeApiLevel = Number(
|
||||
minNativeApiLevelOption ?? config.release?.minNativeApiLevel ?? 2,
|
||||
)
|
||||
if (!Number.isInteger(minNativeApiLevel) || minNativeApiLevel < 1) {
|
||||
throw new Error('minNativeApiLevel must be a positive integer')
|
||||
async function uploadSourceMap(module, release) {
|
||||
const bundleDirectory = bundleOutputDirectory(root, config, platform, module.id)
|
||||
const bundleFile = path.join(bundleDirectory, `${module.id}.${platform}.bundle`)
|
||||
const sourceMapFile = `${bundleFile}.map`
|
||||
if (!existsSync(sourceMapFile)) throw new Error(`Source Map is missing: ${sourceMapFile}`)
|
||||
const sourceMapBundleHash = createHash('sha256').update(readFileSync(bundleFile)).digest('hex')
|
||||
if (sourceMapBundleHash !== release.identity.bundleHash) {
|
||||
throw new Error(
|
||||
`Source Map Bundle identity mismatch for ${module.id}; rebuild before publishing`,
|
||||
)
|
||||
}
|
||||
if (bugCollectMode === 'disabled') {
|
||||
console.log(` ${module.id}/${platform} Source Map archived locally (BugCollect disabled)`)
|
||||
return
|
||||
}
|
||||
if (!bugCollectUrl) {
|
||||
throw new Error('XUQM_BUGCOLLECT_URL is required when BugCollect Source Map upload is enabled')
|
||||
}
|
||||
const form = createRnSourceMapForm({
|
||||
appKey,
|
||||
platform,
|
||||
appVersion: versioned.appVersion,
|
||||
buildId: release.identity.buildId,
|
||||
moduleId: module.id,
|
||||
moduleVersion: module.moduleVersion,
|
||||
bundleHash: release.identity.bundleHash,
|
||||
fileBytes: readFileSync(sourceMapFile),
|
||||
fileName: `${module.id}.${platform}.map`,
|
||||
})
|
||||
const response = await fetch(
|
||||
`${bugCollectUrl.replace(/\/$/, '')}${BUGCOLLECT_ARTIFACT_UPLOAD_PATH}`,
|
||||
createArtifactUploadRequest(apiToken, form),
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Source Map upload failed for ${module.id}: ${await response.text()}`)
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await validateReleaseConfigOnline()
|
||||
const note = noteOption ?? (await ask('Release notes: '))
|
||||
const publishImmediately = publishOption || config.release?.publishImmediately === true
|
||||
console.log('\n\x1b[36m--- Xuqm RN plugin publish ---\x1b[0m')
|
||||
console.log(` Platform: ${platform}`)
|
||||
@ -112,12 +175,16 @@ async function main() {
|
||||
` Modules: ${modules.map(module => `${module.id}@${module.moduleVersion}`).join(', ')}`,
|
||||
)
|
||||
console.log(` Publish immediately: ${publishImmediately}`)
|
||||
console.log(` BugCollect: ${bugCollectMode}`)
|
||||
if (!(await confirm('Proceed?'))) return
|
||||
|
||||
for (const module of modules) {
|
||||
const result = await uploadBundle(module, note, minNativeApiLevel)
|
||||
const release = releasePackage(module)
|
||||
const result = await uploadBundle(module, release, note)
|
||||
const bundleId = result.data?.id
|
||||
console.log(`\x1b[32m✓ ${module.id}/${platform} uploaded, ID: ${bundleId}\x1b[0m`)
|
||||
// Source Map 是正式发布硬门禁,必须在插件进入 published 状态前完成。
|
||||
await uploadSourceMap(module, release)
|
||||
if (publishImmediately) {
|
||||
const response = await fetch(`${serverUrl}/api/v1/rn/${bundleId}/publish`, {
|
||||
method: 'POST',
|
||||
|
||||
@ -0,0 +1,42 @@
|
||||
import type { XuqmBundleIdentity } from '@xuqm/rn-common/internal'
|
||||
import { NativeBundle } from './NativeBundle'
|
||||
import { selectStackModuleId, type BundleManifestEntry } from './bundleIdentityPolicy'
|
||||
|
||||
interface EmbeddedManifest {
|
||||
buildId?: string
|
||||
modules?: Record<string, BundleManifestEntry>
|
||||
}
|
||||
|
||||
/**
|
||||
* 从原生 manifest/state 读取当前精确身份。stack 不能唯一归属时返回 null;
|
||||
* BugCollect 因而明确跳过符号化,不会把错误错误地匹配到 latest Source Map。
|
||||
*/
|
||||
export async function resolveCurrentBundleIdentity(
|
||||
stacktrace: string,
|
||||
): Promise<XuqmBundleIdentity | null> {
|
||||
let manifest: EmbeddedManifest
|
||||
try {
|
||||
manifest = JSON.parse(await NativeBundle.getManifest()) as EmbeddedManifest
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const modules = manifest.modules ?? {}
|
||||
const moduleId = selectStackModuleId(stacktrace, modules)
|
||||
if (!moduleId) return null
|
||||
const embedded = modules[moduleId]
|
||||
const state = await NativeBundle.getState(moduleId).catch(() => null)
|
||||
const pending = state?.status === 'pending_confirmation'
|
||||
const moduleVersion =
|
||||
(pending ? state.pendingVersion : state?.activeVersion) ?? embedded.moduleVersion
|
||||
const bundleHash = (pending ? state.pendingHash : state?.activeHash) ?? embedded.sha256
|
||||
const buildId = (pending ? state.pendingBuildId : state?.activeBuildId) ?? manifest.buildId
|
||||
if (!buildId || !moduleVersion || !bundleHash || !/^[a-f0-9]{64}$/i.test(bundleHash)) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
buildId,
|
||||
moduleId,
|
||||
moduleVersion,
|
||||
bundleHash: bundleHash.toLowerCase(),
|
||||
}
|
||||
}
|
||||
@ -5,6 +5,7 @@ export interface BundleModuleState {
|
||||
moduleId: string
|
||||
status: 'embedded' | 'active' | 'staged' | 'pending_confirmation'
|
||||
activeVersion?: string
|
||||
activeBuildId?: string
|
||||
activeHash?: string
|
||||
activeByteLength?: number
|
||||
activeType?: PluginModuleType
|
||||
@ -13,6 +14,8 @@ export interface BundleModuleState {
|
||||
activeCommonVersionRange?: string
|
||||
activeMinNativeApiLevel?: number
|
||||
pendingVersion?: string
|
||||
pendingBuildId?: string
|
||||
pendingHash?: string
|
||||
releaseId?: string
|
||||
launchAttempts?: number
|
||||
}
|
||||
@ -54,6 +57,10 @@ interface XuqmBundleModuleInterface {
|
||||
preparePendingRelease(): Promise<string[]>
|
||||
getLaunchBundlePath(moduleId: string): Promise<string>
|
||||
getAssetIndex(): Promise<string>
|
||||
resetToEmbedded(): Promise<boolean>
|
||||
getResetGeneration(): Promise<number>
|
||||
getConfirmedResetGeneration(): Promise<number>
|
||||
confirmResetGeneration(generation: number): Promise<boolean>
|
||||
}
|
||||
|
||||
function getModule(): XuqmBundleModuleInterface {
|
||||
@ -140,4 +147,21 @@ export const NativeBundle = {
|
||||
getFileName(moduleId: string): string {
|
||||
return `${moduleId}.${Platform.OS}.bundle`
|
||||
},
|
||||
|
||||
/** 只清理 Update SDK 私有 Bundle 状态,下一次访问按 APK 内置 manifest 恢复。 */
|
||||
async resetToEmbedded(): Promise<void> {
|
||||
await getModule().resetToEmbedded()
|
||||
},
|
||||
|
||||
getResetGeneration(): Promise<number> {
|
||||
return getModule().getResetGeneration()
|
||||
},
|
||||
|
||||
getConfirmedResetGeneration(): Promise<number> {
|
||||
return getModule().getConfirmedResetGeneration()
|
||||
},
|
||||
|
||||
async confirmResetGeneration(generation: number): Promise<void> {
|
||||
await getModule().confirmResetGeneration(generation)
|
||||
},
|
||||
}
|
||||
|
||||
@ -41,6 +41,11 @@ export const XuqmRuntime = (() => {
|
||||
if (!next.plugins.length) {
|
||||
throw new Error('[XuqmRuntime] At least one plugin registration is required')
|
||||
}
|
||||
const commonCount = next.plugins.filter(plugin => plugin.type === 'common').length
|
||||
const appCount = next.plugins.filter(plugin => plugin.type === 'app').length
|
||||
if (commonCount !== 1 || appCount !== 1) {
|
||||
throw new Error('[XuqmRuntime] Exactly one common and one app plugin must be registered')
|
||||
}
|
||||
options = next
|
||||
UpdateSDK.registerPlugins(next.plugins)
|
||||
},
|
||||
@ -48,7 +53,7 @@ export const XuqmRuntime = (() => {
|
||||
async start() {
|
||||
if (!options) throw new Error('[XuqmRuntime] configure() must be called before start()')
|
||||
await UpdateSDK.preparePendingRelease()
|
||||
if (options.checkUpdatesOnStart !== false) {
|
||||
if (!__DEV__ && options.checkUpdatesOnStart !== false) {
|
||||
return UpdateSDK.checkStartupUpdate()
|
||||
}
|
||||
return { kind: 'none' as const }
|
||||
|
||||
@ -21,6 +21,7 @@ import {
|
||||
type PluginReleaseCandidate,
|
||||
type ReleaseSetPlan,
|
||||
} from './releaseSet'
|
||||
import { shouldSkipUpdateForLogin } from './updateLoginPolicy'
|
||||
|
||||
export interface PluginRegistration {
|
||||
moduleId: string
|
||||
@ -43,6 +44,7 @@ export interface AppUpdateInfo {
|
||||
requiresLogin?: boolean
|
||||
alreadyDownloaded?: boolean
|
||||
sha256?: string
|
||||
skippedReason?: 'LOGIN_REQUIRED'
|
||||
}
|
||||
|
||||
export type UpdateDownloadProgress = DownloadProgress
|
||||
@ -91,7 +93,36 @@ const pluginRegistry = new Map<string, PluginRegistration>()
|
||||
const confirmedReleaseModules = new Set<string>()
|
||||
const UPDATE_APP_CACHE_KEY = 'xuqm_update_app_cache'
|
||||
const UPDATE_IGNORED_VERSION_KEY = 'xuqm_update_ignored_version_code'
|
||||
const QUARANTINED_RELEASES_KEY = 'xuqm_update_quarantined_releases'
|
||||
const UPDATE_CACHE_TTL_MS = 30 * 60 * 1000
|
||||
let sessionGeneration = 0
|
||||
let sessionNonce = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
let automaticLoginCheckGeneration = -1
|
||||
let activeSessionAbort = new AbortController()
|
||||
const automaticResultListeners = new Set<(result: StartupUpdateResult) => void>()
|
||||
|
||||
function sessionCacheKey(base: string): string {
|
||||
const userId = getUserId()?.trim()
|
||||
return userId ? `${base}:user:${userId}:session:${sessionNonce}` : `${base}:anonymous`
|
||||
}
|
||||
|
||||
function currentSessionSignal(signal?: AbortSignal): AbortSignal {
|
||||
return signal ? AbortSignal.any([signal, activeSessionAbort.signal]) : activeSessionAbort.signal
|
||||
}
|
||||
|
||||
function isLoginRequiredWithoutSession(): boolean {
|
||||
return shouldSkipUpdateForLogin(getConfig().updateRequiresLogin, getUserId())
|
||||
}
|
||||
|
||||
async function clearSessionUpdateState(): Promise<void> {
|
||||
const previousCacheKey = sessionCacheKey(UPDATE_APP_CACHE_KEY)
|
||||
activeSessionAbort.abort()
|
||||
activeSessionAbort = new AbortController()
|
||||
sessionGeneration += 1
|
||||
sessionNonce = `${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
automaticLoginCheckGeneration = -1
|
||||
await AsyncStorage.removeItem(previousCacheKey).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function getIgnoredVersionCode(): Promise<number | null> {
|
||||
const raw = await AsyncStorage.getItem(UPDATE_IGNORED_VERSION_KEY).catch(() => null)
|
||||
@ -115,6 +146,29 @@ async function writeUpdateCache<T>(key: string, data: T): Promise<void> {
|
||||
await AsyncStorage.setItem(key, JSON.stringify({ ts: Date.now(), data })).catch(() => undefined)
|
||||
}
|
||||
|
||||
async function readQuarantinedReleases(): Promise<Record<string, string>> {
|
||||
try {
|
||||
const raw = await AsyncStorage.getItem(QUARANTINED_RELEASES_KEY)
|
||||
return raw ? (JSON.parse(raw) as Record<string, string>) : {}
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
async function quarantineRelease(releaseId: string, moduleIds: readonly string[]): Promise<void> {
|
||||
const quarantined = await readQuarantinedReleases()
|
||||
for (const moduleId of moduleIds) quarantined[moduleId] = releaseId
|
||||
await AsyncStorage.setItem(QUARANTINED_RELEASES_KEY, JSON.stringify(quarantined)).catch(
|
||||
() => undefined,
|
||||
)
|
||||
}
|
||||
|
||||
async function clearUpdateStorage(): Promise<void> {
|
||||
const keys = await AsyncStorage.getAllKeys().catch(() => [])
|
||||
const updateKeys = keys.filter(key => key.startsWith('xuqm_update_'))
|
||||
if (updateKeys.length > 0) await AsyncStorage.removeMany(updateKeys)
|
||||
}
|
||||
|
||||
function normalizeDownloadUrl(rawUrl?: string): string | undefined {
|
||||
if (!rawUrl) return rawUrl
|
||||
if (rawUrl.includes('/api/v1/updates/api/v1/rn/files/')) {
|
||||
@ -200,6 +254,35 @@ function assertSha256(bytes: Uint8Array | string, expected: string, label: strin
|
||||
}
|
||||
|
||||
export const UpdateSDK = {
|
||||
developer: {
|
||||
/** Debug 开发页单次检查;不改变后续启动的默认关闭策略。 */
|
||||
async checkStartupOnce(): Promise<StartupUpdateResult> {
|
||||
await awaitInitialization()
|
||||
if (!getConfig().debug) {
|
||||
throw new Error('[UpdateSDK] developer.checkStartupOnce is only available in Debug.')
|
||||
}
|
||||
return UpdateSDK.checkStartupUpdate()
|
||||
},
|
||||
|
||||
/** Debug 开发页单次检查并安装目标插件。 */
|
||||
async checkAndInstallPluginOnce(
|
||||
moduleId: string,
|
||||
options: {
|
||||
signal?: AbortSignal
|
||||
checkTimeoutMs?: number
|
||||
onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void
|
||||
} = {},
|
||||
): Promise<ReleaseSetPlan | null> {
|
||||
await awaitInitialization()
|
||||
if (!getConfig().debug) {
|
||||
throw new Error(
|
||||
'[UpdateSDK] developer.checkAndInstallPluginOnce is only available in Debug.',
|
||||
)
|
||||
}
|
||||
return UpdateSDK.checkAndInstallPlugin(moduleId, options)
|
||||
},
|
||||
},
|
||||
|
||||
registerPlugins(plugins: PluginRegistration[]): void {
|
||||
for (const plugin of plugins) UpdateSDK.registerPlugin(plugin)
|
||||
},
|
||||
@ -227,6 +310,9 @@ export const UpdateSDK = {
|
||||
|
||||
async checkAppUpdate(bypassIgnore = false): Promise<AppUpdateInfo> {
|
||||
await awaitInitialization()
|
||||
if (isLoginRequiredWithoutSession()) {
|
||||
return { needsUpdate: false, requiresLogin: true, skippedReason: 'LOGIN_REQUIRED' }
|
||||
}
|
||||
const applyIgnore = async (info: AppUpdateInfo): Promise<AppUpdateInfo> => {
|
||||
if (bypassIgnore || !info.needsUpdate || info.forceUpdate) return info
|
||||
const ignored = await getIgnoredVersionCode()
|
||||
@ -235,7 +321,7 @@ export const UpdateSDK = {
|
||||
: info
|
||||
}
|
||||
if (!bypassIgnore) {
|
||||
const cached = await readUpdateCache<AppUpdateInfo>(UPDATE_APP_CACHE_KEY)
|
||||
const cached = await readUpdateCache<AppUpdateInfo>(sessionCacheKey(UPDATE_APP_CACHE_KEY))
|
||||
if (cached) return applyIgnore(cached)
|
||||
}
|
||||
|
||||
@ -258,13 +344,14 @@ export const UpdateSDK = {
|
||||
const raw = await apiRequest<RawAppUpdateInfo>('/api/v1/updates/app/check', {
|
||||
skipAuth: true,
|
||||
params,
|
||||
signal: activeSessionAbort.signal,
|
||||
})
|
||||
const normalized: AppUpdateInfo = {
|
||||
...raw,
|
||||
downloadUrl: normalizeDownloadUrl(raw.downloadUrl),
|
||||
sha256: raw.sha256 ?? raw.apkHash ?? undefined,
|
||||
}
|
||||
await writeUpdateCache(UPDATE_APP_CACHE_KEY, normalized)
|
||||
await writeUpdateCache(sessionCacheKey(UPDATE_APP_CACHE_KEY), normalized)
|
||||
return applyIgnore(normalized)
|
||||
},
|
||||
|
||||
@ -292,7 +379,10 @@ export const UpdateSDK = {
|
||||
options: { signal?: AbortSignal; onProgress?: (progress: UpdateDownloadProgress) => void } = {},
|
||||
): Promise<ArrayBuffer> {
|
||||
if (!updateInfo.downloadUrl) throw new Error('[UpdateSDK] App update has no downloadUrl')
|
||||
const bytes = await downloadBytes(updateInfo.downloadUrl, options)
|
||||
const bytes = await downloadBytes(updateInfo.downloadUrl, {
|
||||
...options,
|
||||
signal: currentSessionSignal(options.signal),
|
||||
})
|
||||
if (updateInfo.sha256) assertSha256(bytes, updateInfo.sha256, 'APK')
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
},
|
||||
@ -314,7 +404,7 @@ export const UpdateSDK = {
|
||||
versionCode: updateInfo.versionCode,
|
||||
sha256: updateInfo.sha256,
|
||||
},
|
||||
options,
|
||||
{ ...options, signal: currentSessionSignal(options.signal) },
|
||||
)
|
||||
},
|
||||
|
||||
@ -324,6 +414,7 @@ export const UpdateSDK = {
|
||||
options: { signal?: AbortSignal; timeoutMs?: number } = {},
|
||||
): Promise<ReleaseSetPlan | null> {
|
||||
await awaitInitialization()
|
||||
if (isLoginRequiredWithoutSession()) return null
|
||||
const target = requireRegistration(targetModuleId)
|
||||
if (target.type === 'common') {
|
||||
throw new Error(
|
||||
@ -344,7 +435,7 @@ export const UpdateSDK = {
|
||||
const raw = await apiRequest<RawPluginReleaseSet>('/api/v1/rn/release-set/check', {
|
||||
method: 'POST',
|
||||
skipAuth: true,
|
||||
signal: options.signal,
|
||||
signal: currentSessionSignal(options.signal),
|
||||
timeoutMs: options.timeoutMs,
|
||||
body: {
|
||||
appKey: config.appKey,
|
||||
@ -370,7 +461,7 @@ export const UpdateSDK = {
|
||||
minNativeApiLevel: candidate.minNativeApiLevel ?? registration.minNativeApiLevel,
|
||||
})
|
||||
})
|
||||
return planReleaseSet({
|
||||
const plan = planReleaseSet({
|
||||
installed,
|
||||
candidates,
|
||||
nativeApiLevel: await NativeBundle.getNativeApiLevel(),
|
||||
@ -378,6 +469,10 @@ export const UpdateSDK = {
|
||||
appVersion: getAppVersionName() ?? '0.0.0',
|
||||
releaseId: raw.releaseId,
|
||||
})
|
||||
if (plan && (await readQuarantinedReleases())[targetModuleId] === plan.releaseId) {
|
||||
return null
|
||||
}
|
||||
return plan
|
||||
},
|
||||
|
||||
/** Installs a previously checked plan without performing another network version check. */
|
||||
@ -388,6 +483,7 @@ export const UpdateSDK = {
|
||||
onProgress?: (moduleId: string, progress: UpdateDownloadProgress) => void
|
||||
} = {},
|
||||
): Promise<ReleaseSetPlan | null> {
|
||||
const authorizedGeneration = sessionGeneration
|
||||
const registrations = UpdateSDK.getRegisteredPlugins()
|
||||
const installed = await Promise.all(
|
||||
registrations.map(registration => installedModule(registration)),
|
||||
@ -410,9 +506,12 @@ export const UpdateSDK = {
|
||||
const nativeModules: NativeReleaseModule[] = []
|
||||
for (const module of verifiedPlan.modules) {
|
||||
const archive = await downloadBytes(module.downloadUrl, {
|
||||
signal: options.signal,
|
||||
signal: currentSessionSignal(options.signal),
|
||||
onProgress: progress => options.onProgress?.(module.moduleId, progress),
|
||||
})
|
||||
if (authorizedGeneration !== sessionGeneration) {
|
||||
throw new Error('[UpdateSDK] User session changed while downloading a release.')
|
||||
}
|
||||
assertSha256(archive, module.sha256, `${module.moduleId} plugin package`)
|
||||
nativeModules.push({
|
||||
moduleId: module.moduleId,
|
||||
@ -481,7 +580,10 @@ export const UpdateSDK = {
|
||||
|
||||
async reportPluginLaunchFailure(moduleId: string, reason?: string): Promise<void> {
|
||||
const pending = await NativeBundle.getPendingRelease()
|
||||
if (pending) await NativeBundle.rollbackReleaseSet(pending.releaseId)
|
||||
if (pending) {
|
||||
await quarantineRelease(pending.releaseId, pending.modules)
|
||||
await NativeBundle.rollbackReleaseSet(pending.releaseId)
|
||||
}
|
||||
confirmedReleaseModules.clear()
|
||||
if (reason) console.warn(`[UpdateSDK] Rolled back release after ${moduleId} failed: ${reason}`)
|
||||
},
|
||||
@ -491,10 +593,53 @@ export const UpdateSDK = {
|
||||
return NativeBundle.preparePendingRelease()
|
||||
},
|
||||
|
||||
/**
|
||||
* 最终恢复页专用:清理所有 Update SDK 插件与更新判定状态,随后按 APK 内置版本恢复。
|
||||
* 不触碰宿主登录、业务缓存、BugCollect 或签名 SDK 数据。
|
||||
*/
|
||||
async resetToEmbedded(): Promise<void> {
|
||||
activeSessionAbort.abort()
|
||||
activeSessionAbort = new AbortController()
|
||||
confirmedReleaseModules.clear()
|
||||
await NativeBundle.resetToEmbedded()
|
||||
await UpdateSDK._reconcileNativeReset()
|
||||
},
|
||||
|
||||
/** @internal common/app 初始化后补齐最小原生恢复页留下的 JS 状态清理。 */
|
||||
async _reconcileNativeReset(): Promise<void> {
|
||||
const [generation, confirmed] = await Promise.all([
|
||||
NativeBundle.getResetGeneration(),
|
||||
NativeBundle.getConfirmedResetGeneration(),
|
||||
])
|
||||
if (generation <= confirmed) return
|
||||
await clearUpdateStorage()
|
||||
await NativeBundle.confirmResetGeneration(generation)
|
||||
},
|
||||
|
||||
getAppVersionCode,
|
||||
getAppVersionName,
|
||||
|
||||
_devSetAppVersion(versionCode: number, versionName?: string): void {
|
||||
_devSetAppVersion(versionCode, versionName)
|
||||
},
|
||||
|
||||
/** 订阅登录后自动补检结果;SDK 只返回数据,不展示更新 UI。 */
|
||||
onAutomaticCheck(listener: (result: StartupUpdateResult) => void): () => void {
|
||||
automaticResultListeners.add(listener)
|
||||
return () => automaticResultListeners.delete(listener)
|
||||
},
|
||||
|
||||
/** @internal 共享登录态变化时取消旧灰度授权,并为新用户自动补检一次。 */
|
||||
async _handleSessionChange(loggedIn: boolean): Promise<void> {
|
||||
await clearSessionUpdateState()
|
||||
if (!loggedIn || !getConfig().updateRequiresLogin || getConfig().debug) return
|
||||
if (automaticLoginCheckGeneration === sessionGeneration) return
|
||||
automaticLoginCheckGeneration = sessionGeneration
|
||||
try {
|
||||
const result = await UpdateSDK.checkStartupUpdate()
|
||||
for (const listener of automaticResultListeners) listener(result)
|
||||
} catch {
|
||||
// 后台补检失败必须与宿主登录隔离。
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
@ -0,0 +1,27 @@
|
||||
export interface BundleManifestEntry {
|
||||
moduleVersion?: string
|
||||
sha256?: string
|
||||
bundleFile?: string
|
||||
}
|
||||
|
||||
function stackMatchesModule(stacktrace: string, moduleId: string, bundleFile?: string): boolean {
|
||||
const normalized = stacktrace.replace(/\\/g, '/')
|
||||
return (
|
||||
normalized.includes(`/${moduleId}/active.bundle`) ||
|
||||
normalized.includes(`/${moduleId}/embedded.bundle`) ||
|
||||
normalized.includes(`/${moduleId}.android.bundle`) ||
|
||||
normalized.includes(`/${moduleId}.ios.bundle`) ||
|
||||
Boolean(bundleFile && normalized.includes(`/${bundleFile}`))
|
||||
)
|
||||
}
|
||||
|
||||
/** 只有 stack 唯一命中一个模块时才允许精确符号化。 */
|
||||
export function selectStackModuleId(
|
||||
stacktrace: string,
|
||||
modules: Record<string, BundleManifestEntry>,
|
||||
): string | null {
|
||||
const matches = Object.entries(modules)
|
||||
.filter(([moduleId, module]) => stackMatchesModule(stacktrace, moduleId, module.bundleFile))
|
||||
.map(([moduleId]) => moduleId)
|
||||
return matches.length === 1 ? matches[0] : null
|
||||
}
|
||||
@ -1,4 +1,14 @@
|
||||
import '@xuqm/rn-common/internal'
|
||||
import {
|
||||
_registerBundleIdentityProvider,
|
||||
_registerInitializationHandler,
|
||||
_registerUserInfoHandler,
|
||||
} from '@xuqm/rn-common/internal'
|
||||
import { UpdateSDK } from './UpdateSDK'
|
||||
import { resolveCurrentBundleIdentity } from './BundleIdentity'
|
||||
|
||||
_registerUserInfoHandler('rn-update', info => UpdateSDK._handleSessionChange(Boolean(info)))
|
||||
_registerInitializationHandler('rn-update-reset', () => UpdateSDK._reconcileNativeReset())
|
||||
_registerBundleIdentityProvider('rn-update', resolveCurrentBundleIdentity)
|
||||
|
||||
export { StaleReleaseSetError, UpdateSDK } from './UpdateSDK'
|
||||
export type {
|
||||
|
||||
@ -0,0 +1,7 @@
|
||||
/** 缺省策略由 common 收敛为 requiresLogin=true;这里只判断当前会话是否可检查。 */
|
||||
export function shouldSkipUpdateForLogin(
|
||||
requiresLogin: boolean,
|
||||
userId: string | null | undefined,
|
||||
): boolean {
|
||||
return requiresLogin && !userId?.trim()
|
||||
}
|
||||
@ -0,0 +1,29 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { selectStackModuleId } from '../src/bundleIdentityPolicy'
|
||||
|
||||
const modules = {
|
||||
common: { bundleFile: 'common.android.bundle' },
|
||||
app: { bundleFile: 'app.android.bundle' },
|
||||
orders: { bundleFile: 'orders.android.bundle' },
|
||||
}
|
||||
|
||||
test('stack identity resolves only one exact active or embedded bundle', () => {
|
||||
assert.equal(
|
||||
selectStackModuleId(
|
||||
'at render (/data/user/0/host/files/rn-bundles/orders/active.bundle:10:20)',
|
||||
modules,
|
||||
),
|
||||
'orders',
|
||||
)
|
||||
assert.equal(
|
||||
selectStackModuleId('at boot (/assets/rn-bundles/app.android.bundle:1:2)', modules),
|
||||
'app',
|
||||
)
|
||||
})
|
||||
|
||||
test('stack identity stays unavailable for unknown or ambiguous sources', () => {
|
||||
assert.equal(selectStackModuleId('at render (index.js:10:20)', modules), null)
|
||||
assert.equal(selectStackModuleId('app.android.bundle -> orders.android.bundle', modules), null)
|
||||
})
|
||||
@ -0,0 +1,14 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { shouldSkipUpdateForLogin } from '../src/updateLoginPolicy'
|
||||
|
||||
test('login-required update checks skip anonymous sessions and resume after login', () => {
|
||||
assert.equal(shouldSkipUpdateForLogin(true, null), true)
|
||||
assert.equal(shouldSkipUpdateForLogin(true, ' '), true)
|
||||
assert.equal(shouldSkipUpdateForLogin(true, 'user-1'), false)
|
||||
})
|
||||
|
||||
test('platform can explicitly allow anonymous update checks', () => {
|
||||
assert.equal(shouldSkipUpdateForLogin(false, null), false)
|
||||
})
|
||||
@ -1,7 +1,8 @@
|
||||
export { XuqmSDK } from './sdk'
|
||||
export type {
|
||||
XuqmConfig,
|
||||
XuqmInitOptions,
|
||||
XuqmInitializationSnapshot,
|
||||
XuqmInitializationState,
|
||||
XuqmLoginOptions,
|
||||
XuqmUserInfo,
|
||||
DeviceInfo,
|
||||
@ -24,11 +25,7 @@ export type {
|
||||
UpdateDownloadProgress,
|
||||
} from '@xuqm/rn-update'
|
||||
export { BugCollect } from '@xuqm/rn-bugcollect'
|
||||
export {
|
||||
XWebViewHost,
|
||||
XWebViewView,
|
||||
openWebView,
|
||||
} from '@xuqm/rn-xwebview'
|
||||
export { XWebViewHost, XWebViewView, openWebView } from '@xuqm/rn-xwebview'
|
||||
export type {
|
||||
XWebViewBridge,
|
||||
XWebViewBridgePageContext,
|
||||
|
||||
11
src/types/async-storage.d.ts
vendored
11
src/types/async-storage.d.ts
vendored
@ -1,11 +0,0 @@
|
||||
declare module '@react-native-async-storage/async-storage' {
|
||||
export interface AsyncStorageStatic {
|
||||
getItem(key: string): Promise<string | null>
|
||||
setItem(key: string, value: string): Promise<void>
|
||||
removeItem(key: string): Promise<void>
|
||||
clear(): Promise<void>
|
||||
}
|
||||
|
||||
const AsyncStorage: AsyncStorageStatic
|
||||
export default AsyncStorage
|
||||
}
|
||||
@ -9,6 +9,7 @@
|
||||
"@xuqm/rn-common/download": ["./packages/common/src/download.ts"],
|
||||
"@xuqm/rn-common/file-name": ["./packages/common/src/fileName.ts"],
|
||||
"@xuqm/rn-common/internal": ["./packages/common/src/internal.ts"],
|
||||
"@xuqm/rn-common/secure-cache": ["./packages/common/src/secureCache.ts"],
|
||||
"@xuqm/rn-common/version": ["./packages/common/src/version.ts"],
|
||||
"@xuqm/rn-im": ["./packages/im/src"],
|
||||
"@xuqm/rn-bugcollect": ["./packages/bugcollect/src"],
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户