From 3d2134e08a50034df4bd1225ae1f6a1704fa20d4 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Sun, 26 Jul 2026 23:47:30 +0800 Subject: [PATCH] feat(sdk): harden automatic config and plugin releases --- .gitignore | 1 + README.md | 37 ++- docs/IMPLEMENTATION_HANDOFF.md | 78 ++++- docs/SDK-API参考.md | 18 +- docs/插件脚手架.md | 6 +- docs/架构总览.md | 2 +- docs/配置文件规范.md | 50 ++-- packages/bugcollect/README.md | 12 +- packages/bugcollect/metro/index.js | 220 -------------- packages/bugcollect/package.json | 4 +- packages/bugcollect/src/BugCollect.ts | 161 +++++++++-- .../bugcollect/src/capture/ErrorCapture.ts | 25 +- packages/bugcollect/src/index.ts | 23 +- .../src/interceptor/HttpInterceptor.ts | 11 + packages/bugcollect/src/privacy.ts | 34 +++ packages/bugcollect/src/queue/LogQueue.ts | 131 +++++++-- packages/bugcollect/src/queue/queueStorage.ts | 36 +++ packages/bugcollect/src/types.ts | 6 + .../bugcollect/tests/http-interceptor.test.ts | 20 ++ packages/bugcollect/tests/policy.test.ts | 1 + packages/bugcollect/tests/privacy.test.ts | 22 ++ .../bugcollect/tests/queue-storage.test.ts | 58 ++++ packages/common/README.md | 16 +- packages/common/metro/index.js | 200 +++++++------ .../common/metro/trusted-config-keys.json | 3 + packages/common/package.json | 2 + packages/common/src/api/diagnostics.ts | 13 +- packages/common/src/autoInit.ts | 24 +- packages/common/src/autoInitConfig.ts | 6 + packages/common/src/bundleIdentity.ts | 28 ++ packages/common/src/config.ts | 85 +++++- packages/common/src/crypto.ts | 96 +------ packages/common/src/errors.ts | 20 ++ packages/common/src/http.ts | 8 +- packages/common/src/index.ts | 31 +- packages/common/src/internal.ts | 8 +- packages/common/src/retry.ts | 57 ++++ packages/common/src/sdk.ts | 268 +++++++++++++++--- packages/common/src/secureCache.ts | 70 +++++ packages/common/tests/api-diagnostics.test.ts | 9 + packages/common/tests/crypto.test.ts | 52 +--- packages/common/tests/fixtures/common-only.js | 4 + packages/common/tests/fixtures/public-api.ts | 6 + packages/common/tests/fixtures/tsconfig.json | 7 + packages/common/tests/metro-bundle.test.cjs | 44 +++ packages/common/tests/metro.test.cjs | 60 +++- packages/common/tests/retry.test.ts | 34 +++ packages/common/tests/sdk.test.ts | 20 +- packages/common/tests/secureCache.test.ts | 28 ++ packages/update/README.md | 20 +- .../com/xuqm/update/XuqmBundleModule.java | 94 +++++- .../xuqm/update/XuqmPluginPackageStager.java | 3 + .../xuqm/update/XuqmBundleStorageTest.java | 23 ++ packages/update/android/xuqm-bundles.gradle | 55 +++- packages/update/scripts/artifact-upload.mjs | 38 +++ .../update/scripts/artifact-upload.test.mjs | 45 +++ packages/update/scripts/package-options.mjs | 48 ++++ .../update/scripts/package-options.test.mjs | 40 +++ packages/update/scripts/release-config.mjs | 40 +++ packages/update/scripts/release-identity.mjs | 66 +++++ .../update/scripts/release-identity.test.mjs | 68 +++++ packages/update/scripts/release-package.mjs | 98 +++++++ .../update/scripts/release-package.test.mjs | 76 +++++ packages/update/scripts/source-map.mjs | 45 +++ packages/update/scripts/source-map.test.mjs | 31 ++ packages/update/scripts/xuqm-config.mjs | 3 +- packages/update/scripts/xuqm-rn.mjs | 119 ++++++-- packages/update/scripts/xuqm-rn.test.mjs | 66 ++++- packages/update/scripts/xuqm_release.mjs | 121 ++++++-- packages/update/src/BundleIdentity.ts | 42 +++ packages/update/src/NativeBundle.ts | 24 ++ packages/update/src/PluginRuntime.ts | 7 +- packages/update/src/UpdateSDK.ts | 161 ++++++++++- packages/update/src/bundleIdentityPolicy.ts | 27 ++ packages/update/src/index.ts | 12 +- packages/update/src/updateLoginPolicy.ts | 7 + packages/update/tests/bundle-identity.test.ts | 29 ++ .../update/tests/update-login-policy.test.ts | 14 + src/index.ts | 9 +- src/types/async-storage.d.ts | 11 - tsconfig.json | 1 + 81 files changed, 2845 insertions(+), 753 deletions(-) delete mode 100644 packages/bugcollect/metro/index.js create mode 100644 packages/bugcollect/src/privacy.ts create mode 100644 packages/bugcollect/src/queue/queueStorage.ts create mode 100644 packages/bugcollect/tests/privacy.test.ts create mode 100644 packages/bugcollect/tests/queue-storage.test.ts create mode 100644 packages/common/metro/trusted-config-keys.json create mode 100644 packages/common/src/autoInitConfig.ts create mode 100644 packages/common/src/bundleIdentity.ts create mode 100644 packages/common/src/errors.ts create mode 100644 packages/common/src/retry.ts create mode 100644 packages/common/src/secureCache.ts create mode 100644 packages/common/tests/fixtures/common-only.js create mode 100644 packages/common/tests/fixtures/public-api.ts create mode 100644 packages/common/tests/fixtures/tsconfig.json create mode 100644 packages/common/tests/metro-bundle.test.cjs create mode 100644 packages/common/tests/retry.test.ts create mode 100644 packages/common/tests/secureCache.test.ts create mode 100644 packages/update/scripts/artifact-upload.mjs create mode 100644 packages/update/scripts/artifact-upload.test.mjs create mode 100644 packages/update/scripts/package-options.mjs create mode 100644 packages/update/scripts/package-options.test.mjs create mode 100644 packages/update/scripts/release-config.mjs create mode 100644 packages/update/scripts/release-identity.mjs create mode 100644 packages/update/scripts/release-identity.test.mjs create mode 100644 packages/update/scripts/release-package.mjs create mode 100644 packages/update/scripts/release-package.test.mjs create mode 100644 packages/update/scripts/source-map.mjs create mode 100644 packages/update/scripts/source-map.test.mjs create mode 100644 packages/update/src/BundleIdentity.ts create mode 100644 packages/update/src/bundleIdentityPolicy.ts create mode 100644 packages/update/src/updateLoginPolicy.ts create mode 100644 packages/update/tests/bundle-identity.test.ts create mode 100644 packages/update/tests/update-login-policy.test.ts delete mode 100644 src/types/async-storage.d.ts diff --git a/.gitignore b/.gitignore index 0830108..02ba4c3 100644 --- a/.gitignore +++ b/.gitignore @@ -7,6 +7,7 @@ target/ build/ .gradle/ .native-gradle/ +.xuqm-cache/ *.iml .idea/ *.log diff --git a/README.md b/README.md index 889dc24..a9ecbf2 100644 --- a/README.md +++ b/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 宿主不放配置,也不初始化。 ## 开发与验证 diff --git a/docs/IMPLEMENTATION_HANDOFF.md b/docs/IMPLEMENTATION_HANDOFF.md index e306ebb..2cd455c 100644 --- a/docs/IMPLEMENTATION_HANDOFF.md +++ b/docs/IMPLEMENTATION_HANDOFF.md @@ -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 整包安装桥接 diff --git a/docs/SDK-API参考.md b/docs/SDK-API参考.md index 4afafc6..477a72a 100644 --- a/docs/SDK-API参考.md +++ b/docs/SDK-API参考.md @@ -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 会话变化时同步原生推送绑定。 diff --git a/docs/插件脚手架.md b/docs/插件脚手架.md index 0924ad0..36bbc76 100644 --- a/docs/插件脚手架.md +++ b/docs/插件脚手架.md @@ -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。 ## 插件入口 diff --git a/docs/架构总览.md b/docs/架构总览.md index e376e8b..6dd8f53 100644 --- a/docs/架构总览.md +++ b/docs/架构总览.md @@ -4,7 +4,7 @@ `@xuqm/rn-common` 是唯一可以独立使用的基础包,负责: -- 为官方扩展提供从 `src/assets/config/config.xuqmconfig` 自动初始化的共享上下文;common 单独使用时不启动初始化; +- 为官方扩展提供从 `src/assets/config/` 内唯一 `.xuqmconfig` 自动初始化的共享上下文;common 单独使用时不启动初始化; - 维护唯一的 SDK 配置和用户会话; - 网络、访问令牌、签名、文件、时间、加密、设备信息和通用 UI; - 将一次 `XuqmSDK.login()` / `logout()` 传播给官方扩展包。 diff --git a/docs/配置文件规范.md b/docs/配置文件规范.md index 349cbed..0953d74 100644 --- a/docs/配置文件规范.md +++ b/docs/配置文件规范.md @@ -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//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,并由服务端执行真实授权。 diff --git a/packages/bugcollect/README.md b/packages/bugcollect/README.md index 19dd9c9..f64ac7e 100644 --- a/packages/bugcollect/README.md +++ b/packages/bugcollect/README.md @@ -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。 diff --git a/packages/bugcollect/metro/index.js b/packages/bugcollect/metro/index.js deleted file mode 100644 index 0f6fab6..0000000 --- a/packages/bugcollect/metro/index.js +++ /dev/null @@ -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 } diff --git a/packages/bugcollect/package.json b/packages/bugcollect/package.json index 91d63a7..3c7943f 100644 --- a/packages/bugcollect/package.json +++ b/packages/bugcollect/package.json @@ -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": { diff --git a/packages/bugcollect/src/BugCollect.ts b/packages/bugcollect/src/BugCollect.ts index f48930d..4765186 100644 --- a/packages/bugcollect/src/BugCollect.ts +++ b/packages/bugcollect/src/BugCollect.ts @@ -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 { + 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 { + 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): IssueEvent { @@ -125,6 +189,7 @@ function _buildIssue(level: Level, error: unknown, metadata?: Record): 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): 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 { + 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 { - 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 { + 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 { + _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 { + stopCapture() + await _queue?.clear() + if (isInitialized()) await LogQueue.clearStored(getConfig().appKey) + _queue?.destroy() + _queue = null + }, +} diff --git a/packages/bugcollect/src/capture/ErrorCapture.ts b/packages/bugcollect/src/capture/ErrorCapture.ts index 676a575..df6a5b6 100644 --- a/packages/bugcollect/src/capture/ErrorCapture.ts +++ b/packages/bugcollect/src/capture/ErrorCapture.ts @@ -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) => 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 - 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 + g['onunhandledrejection'] = previousUnhandledHandler ?? null + previousErrorHandler = null + previousUnhandledHandler = null + started = false + }, } diff --git a/packages/bugcollect/src/index.ts b/packages/bugcollect/src/index.ts index 19c5d6e..3226f5d 100644 --- a/packages/bugcollect/src/index.ts +++ b/packages/bugcollect/src/index.ts @@ -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' diff --git a/packages/bugcollect/src/interceptor/HttpInterceptor.ts b/packages/bugcollect/src/interceptor/HttpInterceptor.ts index 215951d..922048f 100644 --- a/packages/bugcollect/src/interceptor/HttpInterceptor.ts +++ b/packages/bugcollect/src/interceptor/HttpInterceptor.ts @@ -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 => { const original = _originalFetch! + if (isBugCollectRequest(input)) return original(input, init) try { const res = await original(input, init) if (!res.ok) { diff --git a/packages/bugcollect/src/privacy.ts b/packages/bugcollect/src/privacy.ts new file mode 100644 index 0000000..6ea0926 --- /dev/null +++ b/packages/bugcollect/src/privacy.ts @@ -0,0 +1,34 @@ +const SENSITIVE_KEY = + /authorization|cookie|token|secret|password|passwd|pin|idcard|identity|phone|mobile/i +const PHONE = /(?(value: T, seen = new WeakSet()): 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 = {} + for (const [key, item] of Object.entries(value)) { + result[key] = SENSITIVE_KEY.test(key) ? '[REDACTED]' : sanitizeDiagnostic(item, seen) + } + return result as T +} diff --git a/packages/bugcollect/src/queue/LogQueue.ts b/packages/bugcollect/src/queue/LogQueue.ts index ef9e93e..b49979a 100644 --- a/packages/bugcollect/src/queue/LogQueue.ts +++ b/packages/bugcollect/src/queue/LogQueue.ts @@ -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 | null = null - private retryCount = 0 + private disabled = false + private flushPromise: Promise | null = null - constructor(private cfg: { bugCollectApiUrl: string; appKey: string }) { + constructor( + private cfg: { + bugCollectApiUrl: string + appKey: string + encryptionSecret?: string + onDisabled?: () => void | Promise + }, + ) { this.flushTimer = setInterval(() => { void this.flush() }, FLUSH_INTERVAL_MS) } + static async clearStored(appKey: string): Promise { + await clearStoredQueue(AsyncStorage, appKey).catch(() => undefined) + } + async push(event: BugCollectEvent): Promise { + 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 { + async flush(throwOnFailure = false): Promise { + if (this.flushPromise) return this.flushPromise + this.flushPromise = this._flushOnce(throwOnFailure).finally(() => { + this.flushPromise = null + }) + return this.flushPromise + } + + private async _flushOnce(throwOnFailure: boolean): Promise { + 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 { + 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 + 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 { - 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 { 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), + ) } } } diff --git a/packages/bugcollect/src/queue/queueStorage.ts b/packages/bugcollect/src/queue/queueStorage.ts new file mode 100644 index 0000000..4791d50 --- /dev/null +++ b/packages/bugcollect/src/queue/queueStorage.ts @@ -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 }, + appKey: string, +): Promise { + 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): boolean { + return [payload.code, payload.errorCode, payload.message] + .map(value => String(value ?? '')) + .includes('BUGCOLLECT_DISABLED') +} diff --git a/packages/bugcollect/src/types.ts b/packages/bugcollect/src/types.ts index b700442..c3b2bd1 100644 --- a/packages/bugcollect/src/types.ts +++ b/packages/bugcollect/src/types.ts @@ -79,6 +79,12 @@ export interface IssueEvent { device?: DeviceInfo tags?: Record sdk?: SdkInfo + /** 四项齐全时服务端才允许精确 Source Map 符号化。 */ + buildId?: string + moduleId?: string + moduleVersion?: string + bundleHash?: string + symbolicationStatus: 'exact' | 'unavailable' } export type BugCollectEvent = LogEvent | IssueEvent diff --git a/packages/bugcollect/tests/http-interceptor.test.ts b/packages/bugcollect/tests/http-interceptor.test.ts index d8a31f1..1a27673 100644 --- a/packages/bugcollect/tests/http-interceptor.test.ts +++ b/packages/bugcollect/tests/http-interceptor.test.ts @@ -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, []) +}) diff --git a/packages/bugcollect/tests/policy.test.ts b/packages/bugcollect/tests/policy.test.ts index 151ab69..7795676 100644 --- a/packages/bugcollect/tests/policy.test.ts +++ b/packages/bugcollect/tests/policy.test.ts @@ -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) diff --git a/packages/bugcollect/tests/privacy.test.ts b/packages/bugcollect/tests/privacy.test.ts new file mode 100644 index 0000000..b1966d8 --- /dev/null +++ b/packages/bugcollect/tests/privacy.test.ts @@ -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)) +}) diff --git a/packages/bugcollect/tests/queue-storage.test.ts b/packages/bugcollect/tests/queue-storage.test.ts new file mode 100644 index 0000000..1e8f3b2 --- /dev/null +++ b/packages/bugcollect/tests/queue-storage.test.ts @@ -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']) +}) diff --git a/packages/common/README.md b/packages/common/README.md index 2f695b1..2de217d 100644 --- a/packages/common/README.md +++ b/packages/common/README.md @@ -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` 扩展包接入公共生命周期。 diff --git a/packages/common/metro/index.js b/packages/common/metro/index.js index a0d6125..f080e0c 100644 --- a/packages/common/metro/index.js +++ b/packages/common/metro/index.js @@ -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 } diff --git a/packages/common/metro/trusted-config-keys.json b/packages/common/metro/trusted-config-keys.json new file mode 100644 index 0000000..1b235da --- /dev/null +++ b/packages/common/metro/trusted-config-keys.json @@ -0,0 +1,3 @@ +{ + "xuqm-config-dev-2026-07": "MCowBQYDK2VwAyEAFt9MBN8jNwCfalex4wg7kuy2DRAbXkGxZyA/Jaz+6YA=" +} diff --git a/packages/common/package.json b/packages/common/package.json index 4c41f4f..9fa6b19 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -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", diff --git a/packages/common/src/api/diagnostics.ts b/packages/common/src/api/diagnostics.ts index 72e1191..fec6d42 100644 --- a/packages/common/src/api/diagnostics.ts +++ b/packages/common/src/api/diagnostics.ts @@ -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) { 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), } } diff --git a/packages/common/src/autoInit.ts b/packages/common/src/autoInit.ts index 5569cc0..1d5edd3 100644 --- a/packages/common/src/autoInit.ts +++ b/packages/common/src/autoInit.ts @@ -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() diff --git a/packages/common/src/autoInitConfig.ts b/packages/common/src/autoInitConfig.ts new file mode 100644 index 0000000..cc2bba8 --- /dev/null +++ b/packages/common/src/autoInitConfig.ts @@ -0,0 +1,6 @@ +/** + * common-only 宿主的真实空实现。withXuqmConfig 会在 Metro 解析阶段把该公开子路径 + * 精确替换为构建期配置模块;未使用 Metro 包装器时仍可正常完成静态依赖解析。 + */ +export const RESOLVED_CONFIG = undefined +export const BUILD_OPTIONS = undefined diff --git a/packages/common/src/bundleIdentity.ts b/packages/common/src/bundleIdentity.ts new file mode 100644 index 0000000..d420f1c --- /dev/null +++ b/packages/common/src/bundleIdentity.ts @@ -0,0 +1,28 @@ +export interface XuqmBundleIdentity { + buildId: string + moduleId: string + moduleVersion: string + bundleHash: string +} + +export type XuqmBundleIdentityProvider = (stacktrace: string) => Promise + +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 { + return provider ? provider.resolve(stacktrace) : null +} diff --git a/packages/common/src/config.ts b/packages/common/src/config.ts index 4bcaaeb..f345e76 100644 --- a/packages/common/src/config.ts +++ b/packages/common/src/config.ts @@ -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 export type XuqmInitializationHandler = (config: Readonly) => void | Promise +export type XuqmPrivacyConsentHandler = (consented: boolean) => void | Promise // ─── Internal state ──────────────────────────────────────────────────────────── let _config: XuqmConfig | null = null let _userInfo: XuqmUserInfo | null = null +let _privacyConsented = false +let _initializationSnapshot: XuqmInitializationSnapshot = { state: 'idle' } const _userInfoHandlers = new Map() const _initializationHandlers = new Map() +const _privacyConsentHandlers = new Map() // ─── 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 { + return _initializationSnapshot +} + +export function _setInitializationSnapshot(snapshot: XuqmInitializationSnapshot): void { + _initializationSnapshot = snapshot +} + +export function hasPrivacyConsent(): boolean { + return _privacyConsented +} + +export async function _setPrivacyConsent(consented: boolean): Promise { + 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 { } }), ) - 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) + } } } diff --git a/packages/common/src/crypto.ts b/packages/common/src/crypto.ts index cc63a03..ef18cc2 100644 --- a/packages/common/src/crypto.ts +++ b/packages/common/src/crypto.ts @@ -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( - content: string, -): Promise { - 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 { - return decryptXuqmFile(content) -} diff --git a/packages/common/src/errors.ts b/packages/common/src/errors.ts new file mode 100644 index 0000000..222a015 --- /dev/null +++ b/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) + } +} diff --git a/packages/common/src/http.ts b/packages/common/src/http.ts index 3c50dbc..eb02325 100644 --- a/packages/common/src/http.ts +++ b/packages/common/src/http.ts @@ -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( 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( 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( const json = await res.json() if (_debugHttp) { console.debug('[XuqmSDK][HTTP] response', { - url, + path: safeRequestPath(url), status: res.status, ok: true, }) diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index ea4b0a4..6906669 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -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' diff --git a/packages/common/src/internal.ts b/packages/common/src/internal.ts index 53eb63b..3498122 100644 --- a/packages/common/src/internal.ts +++ b/packages/common/src/internal.ts @@ -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' diff --git a/packages/common/src/retry.ts b/packages/common/src/retry.ts new file mode 100644 index 0000000..4075e35 --- /dev/null +++ b/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 { + 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( + action: (attempt: number) => Promise, + options: RetryOptions = {}, +): Promise { + 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 +} diff --git a/packages/common/src/sdk.ts b/packages/common/src/sdk.ts index 4735ced..3ef8771 100644 --- a/packages/common/src/sdk.ts +++ b/packages/common/src/sdk.ts @@ -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 | null = null let _resolvedConfigInitPromise: Promise | 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 = 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 { +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 { + 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 { + 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 { 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 + 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 - const remote = (json.data ?? json) as Record - 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): Promise { } export const XuqmSDK = { - /** - * 方式 B:手动初始化。 - * - * 请求平台获取该 appKey 的完整服务配置(IM、Push、文件服务等 URL 及开通状态)。 - * 失败时直接抛出,不降级。 - * - * @param options.appKey 应用标识 - * @param options.platformUrl 平台地址;不传则使用内置默认公有平台地址 - * @param options.debug 是否开启调试日志 - */ - async initialize(options: XuqmInitOptions): Promise { - 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 { + await _setPrivacyConsent(consented) + }, + getUserId(): string | null { return getCommonUserId() }, @@ -160,8 +324,9 @@ export async function awaitInitialization(): Promise { ? _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 { /** Metro 自动初始化专用入口;配置已在可信构建进程中完成解密和格式校验。 */ export function _initializeFromResolvedConfig( config: DecryptedConfig, - options?: { debug?: boolean }, + options?: { debug?: boolean; bugCollectMode?: 'platform' | 'disabled' }, ): Promise { _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 => { diff --git a/packages/common/src/secureCache.ts b/packages/common/src/secureCache.ts new file mode 100644 index 0000000..76310aa --- /dev/null +++ b/packages/common/src/secureCache.ts @@ -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 +} diff --git a/packages/common/tests/api-diagnostics.test.ts b/packages/common/tests/api-diagnostics.test.ts index 3a3a75b..e4e5817 100644 --- a/packages/common/tests/api-diagnostics.test.ts +++ b/packages/common/tests/api-diagnostics.test.ts @@ -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(), [ { diff --git a/packages/common/tests/crypto.test.ts b/packages/common/tests/crypto.test.ts index fb7ea8c..22ea0e1 100644 --- a/packages/common/tests/crypto.test.ts +++ b/packages/common/tests/crypto.test.ts @@ -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_-]+$/) }) diff --git a/packages/common/tests/fixtures/common-only.js b/packages/common/tests/fixtures/common-only.js new file mode 100644 index 0000000..16d8d54 --- /dev/null +++ b/packages/common/tests/fixtures/common-only.js @@ -0,0 +1,4 @@ +// 真实 Metro fixture:不使用 withXuqmConfig,也不提供 .xuqmconfig。 +const config = require('@xuqm/rn-common/auto-init-config') + +module.exports = config.RESOLVED_CONFIG === undefined diff --git a/packages/common/tests/fixtures/public-api.ts b/packages/common/tests/fixtures/public-api.ts new file mode 100644 index 0000000..ed22481 --- /dev/null +++ b/packages/common/tests/fixtures/public-api.ts @@ -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' }) diff --git a/packages/common/tests/fixtures/tsconfig.json b/packages/common/tests/fixtures/tsconfig.json new file mode 100644 index 0000000..eb41fd6 --- /dev/null +++ b/packages/common/tests/fixtures/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../../../tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "include": ["./public-api.ts"] +} diff --git a/packages/common/tests/metro-bundle.test.cjs b/packages/common/tests/metro-bundle.test.cjs new file mode 100644 index 0000000..d92095a --- /dev/null +++ b/packages/common/tests/metro-bundle.test.cjs @@ -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' }, + ) +}) diff --git a/packages/common/tests/metro.test.cjs b/packages/common/tests/metro.test.cjs index 290c7d5..8838e61 100644 --- a/packages/common/tests/metro.test.cjs +++ b/packages/common/tests/metro.test.cjs @@ -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 }) + } +}) diff --git a/packages/common/tests/retry.test.ts b/packages/common/tests/retry.test.ts new file mode 100644 index 0000000..8797f90 --- /dev/null +++ b/packages/common/tests/retry.test.ts @@ -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) +}) diff --git a/packages/common/tests/sdk.test.ts b/packages/common/tests/sdk.test.ts index abfa81c..c2a4e63 100644 --- a/packages/common/tests/sdk.test.ts +++ b/packages/common/tests/sdk.test.ts @@ -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, diff --git a/packages/common/tests/secureCache.test.ts b/packages/common/tests/secureCache.test.ts new file mode 100644 index 0000000..be516ed --- /dev/null +++ b/packages/common/tests/secureCache.test.ts @@ -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) +}) diff --git a/packages/update/README.md b/packages/update/README.md index 7717e6c..9e96aac 100644 --- a/packages/update/README.md +++ b/packages/update/README.md @@ -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`。 ## 运行时规则 diff --git a/packages/update/android/src/main/java/com/xuqm/update/XuqmBundleModule.java b/packages/update/android/src/main/java/com/xuqm/update/XuqmBundleModule.java index 7b53aae..3462058 100644 --- a/packages/update/android/src/main/java/com/xuqm/update/XuqmBundleModule.java +++ b/packages/update/android/src/main/java/com/xuqm/update/XuqmBundleModule.java @@ -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) { diff --git a/packages/update/android/src/main/java/com/xuqm/update/XuqmPluginPackageStager.java b/packages/update/android/src/main/java/com/xuqm/update/XuqmPluginPackageStager.java index 95d6300..07b66a6 100644 --- a/packages/update/android/src/main/java/com/xuqm/update/XuqmPluginPackageStager.java +++ b/packages/update/android/src/main/java/com/xuqm/update/XuqmPluginPackageStager.java @@ -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; } diff --git a/packages/update/android/src/test/java/com/xuqm/update/XuqmBundleStorageTest.java b/packages/update/android/src/test/java/com/xuqm/update/XuqmBundleStorageTest.java index 70203d9..efbf4e2 100644 --- a/packages/update/android/src/test/java/com/xuqm/update/XuqmBundleStorageTest.java +++ b/packages/update/android/src/test/java/com/xuqm/update/XuqmBundleStorageTest.java @@ -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); + } } diff --git a/packages/update/android/xuqm-bundles.gradle b/packages/update/android/xuqm-bundles.gradle index 72686fe..6e3f7ef 100644 --- a/packages/update/android/xuqm-bundles.gradle +++ b/packages/update/android/xuqm-bundles.gradle @@ -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 getAppVersion() + @Input + abstract Property getBuildId() + + @Input + abstract Property 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")) } diff --git a/packages/update/scripts/artifact-upload.mjs b/packages/update/scripts/artifact-upload.mjs new file mode 100644 index 0000000..817a900 --- /dev/null +++ b/packages/update/scripts/artifact-upload.mjs @@ -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 +} diff --git a/packages/update/scripts/artifact-upload.test.mjs b/packages/update/scripts/artifact-upload.test.mjs new file mode 100644 index 0000000..b06a24e --- /dev/null +++ b/packages/update/scripts/artifact-upload.test.mjs @@ -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/) +}) diff --git a/packages/update/scripts/package-options.mjs b/packages/update/scripts/package-options.mjs new file mode 100644 index 0000000..4591e18 --- /dev/null +++ b/packages/update/scripts/package-options.mjs @@ -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 } +} diff --git a/packages/update/scripts/package-options.test.mjs b/packages/update/scripts/package-options.test.mjs new file mode 100644 index 0000000..c7b1181 --- /dev/null +++ b/packages/update/scripts/package-options.test.mjs @@ -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/) +}) diff --git a/packages/update/scripts/release-config.mjs b/packages/update/scripts/release-config.mjs new file mode 100644 index 0000000..e9d2613 --- /dev/null +++ b/packages/update/scripts/release-config.mjs @@ -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'}`, + ) + } +} diff --git a/packages/update/scripts/release-identity.mjs b/packages/update/scripts/release-identity.mjs new file mode 100644 index 0000000..eb0b9e3 --- /dev/null +++ b/packages/update/scripts/release-identity.mjs @@ -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 } +} diff --git a/packages/update/scripts/release-identity.test.mjs b/packages/update/scripts/release-identity.test.mjs new file mode 100644 index 0000000..770fb55 --- /dev/null +++ b/packages/update/scripts/release-identity.test.mjs @@ -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 }) + } +}) diff --git a/packages/update/scripts/release-package.mjs b/packages/update/scripts/release-package.mjs new file mode 100644 index 0000000..d21f657 --- /dev/null +++ b/packages/update/scripts/release-package.mjs @@ -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, + } +} diff --git a/packages/update/scripts/release-package.test.mjs b/packages/update/scripts/release-package.test.mjs new file mode 100644 index 0000000..02a5372 --- /dev/null +++ b/packages/update/scripts/release-package.test.mjs @@ -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 }) + } +}) diff --git a/packages/update/scripts/source-map.mjs b/packages/update/scripts/source-map.mjs new file mode 100644 index 0000000..426dc06 --- /dev/null +++ b/packages/update/scripts/source-map.mjs @@ -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)) +} diff --git a/packages/update/scripts/source-map.test.mjs b/packages/update/scripts/source-map.test.mjs new file mode 100644 index 0000000..221f050 --- /dev/null +++ b/packages/update/scripts/source-map.test.mjs @@ -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', + ) +}) diff --git a/packages/update/scripts/xuqm-config.mjs b/packages/update/scripts/xuqm-config.mjs index 47d5c3a..5b7061a 100644 --- a/packages/update/scripts/xuqm-config.mjs +++ b/packages/update/scripts/xuqm-config.mjs @@ -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']) diff --git a/packages/update/scripts/xuqm-rn.mjs b/packages/update/scripts/xuqm-rn.mjs index 271a1fa..e08b0c3 100755 --- a/packages/update/scripts/xuqm-rn.mjs +++ b/packages/update/scripts/xuqm-rn.mjs @@ -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() diff --git a/packages/update/scripts/xuqm-rn.test.mjs b/packages/update/scripts/xuqm-rn.test.mjs index 841276c..86a3959 100644 --- a/packages/update/scripts/xuqm-rn.test.mjs +++ b/packages/update/scripts/xuqm-rn.test.mjs @@ -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 getBuildId\(\)/) + assert.match(gradle, /@Input\s+abstract Property 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: '', + })); `, ) diff --git a/packages/update/scripts/xuqm_release.mjs b/packages/update/scripts/xuqm_release.mjs index 8a54702..04329ed 100644 --- a/packages/update/scripts/xuqm_release.mjs +++ b/packages/update/scripts/xuqm_release.mjs @@ -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', diff --git a/packages/update/src/BundleIdentity.ts b/packages/update/src/BundleIdentity.ts new file mode 100644 index 0000000..e4dc113 --- /dev/null +++ b/packages/update/src/BundleIdentity.ts @@ -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 +} + +/** + * 从原生 manifest/state 读取当前精确身份。stack 不能唯一归属时返回 null; + * BugCollect 因而明确跳过符号化,不会把错误错误地匹配到 latest Source Map。 + */ +export async function resolveCurrentBundleIdentity( + stacktrace: string, +): Promise { + 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(), + } +} diff --git a/packages/update/src/NativeBundle.ts b/packages/update/src/NativeBundle.ts index 6e7de46..5de62be 100644 --- a/packages/update/src/NativeBundle.ts +++ b/packages/update/src/NativeBundle.ts @@ -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 getLaunchBundlePath(moduleId: string): Promise getAssetIndex(): Promise + resetToEmbedded(): Promise + getResetGeneration(): Promise + getConfirmedResetGeneration(): Promise + confirmResetGeneration(generation: number): Promise } 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 { + await getModule().resetToEmbedded() + }, + + getResetGeneration(): Promise { + return getModule().getResetGeneration() + }, + + getConfirmedResetGeneration(): Promise { + return getModule().getConfirmedResetGeneration() + }, + + async confirmResetGeneration(generation: number): Promise { + await getModule().confirmResetGeneration(generation) + }, } diff --git a/packages/update/src/PluginRuntime.ts b/packages/update/src/PluginRuntime.ts index 7c869f9..e08de3f 100644 --- a/packages/update/src/PluginRuntime.ts +++ b/packages/update/src/PluginRuntime.ts @@ -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 } diff --git a/packages/update/src/UpdateSDK.ts b/packages/update/src/UpdateSDK.ts index 382adde..d739155 100644 --- a/packages/update/src/UpdateSDK.ts +++ b/packages/update/src/UpdateSDK.ts @@ -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() const confirmedReleaseModules = new Set() 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 { + 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 { const raw = await AsyncStorage.getItem(UPDATE_IGNORED_VERSION_KEY).catch(() => null) @@ -115,6 +146,29 @@ async function writeUpdateCache(key: string, data: T): Promise { await AsyncStorage.setItem(key, JSON.stringify({ ts: Date.now(), data })).catch(() => undefined) } +async function readQuarantinedReleases(): Promise> { + try { + const raw = await AsyncStorage.getItem(QUARANTINED_RELEASES_KEY) + return raw ? (JSON.parse(raw) as Record) : {} + } catch { + return {} + } +} + +async function quarantineRelease(releaseId: string, moduleIds: readonly string[]): Promise { + 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 { + 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 { + 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 { + 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 { await awaitInitialization() + if (isLoginRequiredWithoutSession()) { + return { needsUpdate: false, requiresLogin: true, skippedReason: 'LOGIN_REQUIRED' } + } const applyIgnore = async (info: AppUpdateInfo): Promise => { 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(UPDATE_APP_CACHE_KEY) + const cached = await readUpdateCache(sessionCacheKey(UPDATE_APP_CACHE_KEY)) if (cached) return applyIgnore(cached) } @@ -258,13 +344,14 @@ export const UpdateSDK = { const raw = await apiRequest('/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 { 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 { 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('/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 { + 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 { 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 { + activeSessionAbort.abort() + activeSessionAbort = new AbortController() + confirmedReleaseModules.clear() + await NativeBundle.resetToEmbedded() + await UpdateSDK._reconcileNativeReset() + }, + + /** @internal common/app 初始化后补齐最小原生恢复页留下的 JS 状态清理。 */ + async _reconcileNativeReset(): Promise { + 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 { + 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 { + // 后台补检失败必须与宿主登录隔离。 + } + }, } diff --git a/packages/update/src/bundleIdentityPolicy.ts b/packages/update/src/bundleIdentityPolicy.ts new file mode 100644 index 0000000..c08957a --- /dev/null +++ b/packages/update/src/bundleIdentityPolicy.ts @@ -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 | null { + const matches = Object.entries(modules) + .filter(([moduleId, module]) => stackMatchesModule(stacktrace, moduleId, module.bundleFile)) + .map(([moduleId]) => moduleId) + return matches.length === 1 ? matches[0] : null +} diff --git a/packages/update/src/index.ts b/packages/update/src/index.ts index 1680c12..1aa0580 100644 --- a/packages/update/src/index.ts +++ b/packages/update/src/index.ts @@ -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 { diff --git a/packages/update/src/updateLoginPolicy.ts b/packages/update/src/updateLoginPolicy.ts new file mode 100644 index 0000000..1663a1c --- /dev/null +++ b/packages/update/src/updateLoginPolicy.ts @@ -0,0 +1,7 @@ +/** 缺省策略由 common 收敛为 requiresLogin=true;这里只判断当前会话是否可检查。 */ +export function shouldSkipUpdateForLogin( + requiresLogin: boolean, + userId: string | null | undefined, +): boolean { + return requiresLogin && !userId?.trim() +} diff --git a/packages/update/tests/bundle-identity.test.ts b/packages/update/tests/bundle-identity.test.ts new file mode 100644 index 0000000..ff2b6ad --- /dev/null +++ b/packages/update/tests/bundle-identity.test.ts @@ -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) +}) diff --git a/packages/update/tests/update-login-policy.test.ts b/packages/update/tests/update-login-policy.test.ts new file mode 100644 index 0000000..a596fff --- /dev/null +++ b/packages/update/tests/update-login-policy.test.ts @@ -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) +}) diff --git a/src/index.ts b/src/index.ts index 249b38f..5ddef49 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/types/async-storage.d.ts b/src/types/async-storage.d.ts deleted file mode 100644 index f46e579..0000000 --- a/src/types/async-storage.d.ts +++ /dev/null @@ -1,11 +0,0 @@ -declare module '@react-native-async-storage/async-storage' { - export interface AsyncStorageStatic { - getItem(key: string): Promise - setItem(key: string, value: string): Promise - removeItem(key: string): Promise - clear(): Promise - } - - const AsyncStorage: AsyncStorageStatic - export default AsyncStorage -} diff --git a/tsconfig.json b/tsconfig.json index f8884d5..2890999 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -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"],