比较提交

...

2 次代码提交

作者 SHA1 备注 提交日期
XuqmGroup
28a636821a feat(bugcollect): honor server backpressure 2026-07-28 20:41:05 +08:00
XuqmGroup
21ff207ade refactor(sdk): unify native update and secure runtime contracts 2026-07-28 20:29:06 +08:00
共有 42 个文件被更改,包括 1206 次插入302 次删除

查看文件

@ -1,17 +1,39 @@
# RN SDK 重构实施接管文档 # RN SDK 重构实施接管文档
> 状态更新时间2026-07-27 > 状态更新时间2026-07-28
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview` > 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
> 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。 > 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。
## 2026-07-26 / 当前实施状态 ## 2026-07-28 / 当前实施状态
- RN common 与 Android common 已统一删除客户端 `signingKey/appSecret` 和请求 HMAC。
App 运行请求只携带公开 appKey、可选 userId 与登录 Bearer;Jenkins 发布 Bearer、
Webhook HMAC、UserSig/JWT、插件 Ed25519 签名以及厂商签名仍保留在各自正确的服务端
或发布边界,不能与已删除的客户端共享秘密混为一谈。
- Android 整包更新以原生 `sdk-update` 为唯一权威实现;RN 只桥接结构化检查结果、
网络状态、只下载、只安装、下载安装、取消与商店跳转,不再把 APK 读入 JS
`ArrayBuffer`。`LOGIN_REQUIRED`、`SERVICE_DISABLED` 和 `FAILED` 均是非阻断结果,
宿主只在 `UPDATE_AVAILABLE` 时决定是否展示 UI。
- XWebView 左侧返回键仅在当前网页存在历史时显示;系统返回优先返回网页,无历史时
需要一秒内再次返回才关闭。右侧关闭键始终关闭当前容器,默认菜单只保留复制链接、
外部浏览器打开等通用动作。摄像头和麦克风授权必须同时满足当前文档 origin、请求
origin 与宿主白名单精确一致。
- 2026-07-28 本地 `pnpm validate` 已通过:全部六个 workspace 包类型检查和单测
通过;common 44、bugcollect 12、xwebview 12、IM 3、update CLI/Metro 29 与 update
domain 18 项测试通过。common、bugcollect、update、xwebview 的发布内容检查和
`git diff --check` 均通过。RN Android 原生 Bridge 尚未基于本轮 Android
`sdk-update` 制品执行宿主编译,因为本轮 Maven 快照尚未经 Jenkins 发布;不得据此
宣称原生集成已验证。
- BugCollect 上传器已识别 HTTP 429 与 `BUGCOLLECT_RATE_LIMITED`,按
`Retry-After` 冷却且保留队列;非法值默认 60 秒,最长一小时。该机制只处理平台
瞬时背压,不改变 `BUGCOLLECT_DISABLED` 的永久停用与清队列语义。新增两项解析
测试后 bugcollect 独立测试为 14 项。
- common 已增加 `idle / initializing / ready / degraded / failed` 初始化状态、 - common 已增加 `idle / initializing / ready / degraded / failed` 初始化状态、
`XuqmError` 结构化错误、七天最后成功配置缓存,以及最多三次的指数退避抖动。 `XuqmError` 结构化错误、严格 Schema 的非敏感 LKG,以及最多三次的指数退避抖动。
平台暂不可用时优先进入 degraded;没有缓存时扩展返回 `XUQM_NOT_READY`,不向宿主 平台暂不可用时优先进入 degraded;没有缓存时扩展返回 `XUQM_NOT_READY`,不向宿主
制造未处理异常。最后成功配置使用 AES-GCM 完整性加密,密钥由签发配置和 制造未处理异常。LKG 只包含远端服务开关和地址,采用版本化 JSON;未知字段、非法
app/package/environment 上下文派生且不写入缓存;这里是应用沙箱内防明文与防篡改 地址、旧格式或损坏数据直接删除,不再依赖客户端可提取秘密伪装安全缓存。
边界,不宣称具备 Android Keystore/iOS Keychain 的硬件保密性。
- 扩展 SDK 的唯一正式初始化入口是平台签发 `.xuqmconfig` 与 Metro 自动初始化; - 扩展 SDK 的唯一正式初始化入口是平台签发 `.xuqmconfig` 与 Metro 自动初始化;
`XuqmSDK.initialize(options)` 及公开 `XuqmInitOptions` 已删除,不保留兼容壳。 `XuqmSDK.initialize(options)` 及公开 `XuqmInitOptions` 已删除,不保留兼容壳。
common-only 不初始化。未使用 `withXuqmConfig` 时,公开 common-only 不初始化。未使用 `withXuqmConfig` 时,公开
@ -111,9 +133,8 @@ com.xuqm.update.XuqmPluginPackageStagerTest` 共 25 tasks 构建并测试成功
这些结果不得从当前门禁推断。 这些结果不得从当前门禁推断。
- License 离线激活 SDK 已审计RNSDK 不存在对应包、API、依赖、脚本、文档或空目录; - License 离线激活 SDK 已审计RNSDK 不存在对应包、API、依赖、脚本、文档或空目录;
npm 的 `license: UNLICENSED` 元数据和 RN 插件事务 `activation` 不属于离线授权。 npm 的 `license: UNLICENSED` 元数据和 RN 插件事务 `activation` 不属于离线授权。
- 配置中的 `signingKey` 明确是客户端可提取的请求完整性/缓存派生材料,不是 License、 - 新签发配置不再包含 `signingKey/appSecret`;common 请求只携带公开 appKey、可选
用户授权或服务端秘密;敏感 API 仍依赖登录 `accessToken`,构建制品上传依赖独立 userId 与登录 Bearer,构建制品上传继续使用独立 Jenkins Bearer。
Jenkins Bearer,并由服务端执行授权。
## 1. 本轮目标 ## 1. 本轮目标

查看文件

@ -34,9 +34,8 @@ await XuqmSDK.logout()
- `platformUrl`Xuqm 租户平台地址。远程配置、整包更新、插件 release set 检查等平台接口只使用该地址;自动配置未提供时使用 SDK 内置的公有平台地址。 - `platformUrl`Xuqm 租户平台地址。远程配置、整包更新、插件 release set 检查等平台接口只使用该地址;自动配置未提供时使用 SDK 内置的公有平台地址。
- `apiUrl`:租户平台返回的业务扩展服务地址,只供明确声明使用该业务服务的扩展能力读取,不能替代 `platformUrl` - `apiUrl`:租户平台返回的业务扩展服务地址,只供明确声明使用该业务服务的扩展能力读取,不能替代 `platformUrl`
- common 的 `apiRequest()` 默认基础地址在 SDK 初始化后固定为 `platformUrl`;访问其它业务服务时必须传绝对 URL 或在 common-only 模式显式 `configureHttp()`,禁止用远程 `apiUrl` 静默改写平台请求。 - common 的 `apiRequest()` 默认基础地址在 SDK 初始化后固定为 `platformUrl`;访问其它业务服务时必须传绝对 URL 或在 common-only 模式显式 `configureHttp()`,禁止用远程 `apiUrl` 静默改写平台请求。
- 配置中的 `signingKey` 是可被客户端提取的请求完整性材料,不代表用户身份或接口 - 配置只包含公开应用身份与平台地址,不携带 `appSecret` 或其它客户端长期认证秘密。
授权;敏感接口必须继续校验登录 `accessToken`,构建制品上传使用 Jenkins 发布 敏感接口校验登录 `accessToken`,构建制品上传使用 Jenkins 发布 Bearer。
Bearer。
## common 通用能力 ## common 通用能力
@ -45,7 +44,7 @@ await XuqmSDK.logout()
- `apiRequest`、`configureHttp`、`useRequest`、`useApi`、`usePageApi` - `apiRequest`、`configureHttp`、`useRequest`、`useApi`、`usePageApi`
- `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime` - `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`
- `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile` - `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`
- `hmacSha256Base64Url`、`sha256Hex` - `sha256Hex`
- `getDeviceId`、`getDeviceInfo`、`detectPushVendor` - `getDeviceId`、`getDeviceInfo`、`detectPushVendor`
- `showToast`、`showAlert`、`showConfirm`、`ScaledImage` - `showToast`、`showAlert`、`showConfirm`、`ScaledImage`
@ -68,9 +67,11 @@ await XuqmSDK.logout()
## update 插件检查边界 ## update 插件检查边界
Update 服务由平台远程配置 `features.update` 控制,缺失时默认关闭。显式调用 Update 服务由平台远程配置 `features.update` 控制,缺失时默认关闭。显式调用
`checkAppUpdate`、`downloadApk`、`downloadAndInstallApp`、`checkPluginRelease` 或 `checkAppUpdate` 返回结构化
`installPluginRelease` 时,关闭状态统一抛出 `UpdateDisabledError`,错误码为 `UPDATE_AVAILABLE / NO_UPDATE / LOGIN_REQUIRED / SERVICE_DISABLED / FAILED`,同时携带
`UPDATE_DISABLED`,且不会先访问网络或原生安装模块;自动启动检查会正常跳过。 Android 当前网络事实;检查失败或登录门禁不会阻断宿主。下载、安装与插件操作在服务
关闭时仍统一抛出 `UpdateDisabledError`,错误码为 `UPDATE_DISABLED`,且不会先访问网络
或原生安装模块;自动启动检查会正常跳过。
```ts ```ts
const plan = await UpdateSDK.checkPluginRelease('buz1', { const plan = await UpdateSDK.checkPluginRelease('buz1', {

查看文件

@ -82,7 +82,8 @@ XUQM-CONFIG-V2.{keyId}.{salt}.{iv}.{ciphertextAndTag}.{signature}
明文必须是键按字典序排列、无空白、忽略 null 的 canonical JSON。必要字段为 明文必须是键按字典序排列、无空白、忽略 null 的 canonical JSON。必要字段为
`schemaVersion=2`、`configId`、正整数 `revision`、`issuedAt`、`appKey`、`appName`、 `schemaVersion=2`、`configId`、正整数 `revision`、`issuedAt`、`appKey`、`appName`、
`serverUrl``signingKey`;`expiresAt` 省略表示长期有效。平台重新生成配置后, `serverUrl`;`expiresAt` 省略表示长期有效。配置不得包含 `signingKey`、`appSecret`
或其它客户端长期秘密。平台重新生成配置后,
`configId/revision` 只会阻止后续 Release 构建,不会追溯影响已经安装的 App。 `configId/revision` 只会阻止后续 Release 构建,不会追溯影响已经安装的 App。
远程配置响应中的 `apiUrl` 是业务扩展服务地址,与配置文件中的平台地址职责不同: 远程配置响应中的 `apiUrl` 是业务扩展服务地址,与配置文件中的平台地址职责不同:
@ -127,7 +128,7 @@ await XuqmSDK.logout()
- 扩展项目缺少配置:`awaitInitialization()` 明确报错。 - 扩展项目缺少配置:`awaitInitialization()` 明确报错。
- 签名、V2 Schema、canonical JSON 或有效期校验失败:构建立即失败。 - 签名、V2 Schema、canonical JSON 或有效期校验失败:构建立即失败。
- 远程配置失败且存在七天内的最后成功配置:进入 `degraded` 并继续启动。 - 远程配置失败且存在严格 Schema 校验通过的最后成功配置:进入 `degraded` 并继续启动。
- 首次启动没有缓存:扩展能力返回 `XUQM_NOT_READY`,宿主核心业务继续运行。 - 首次启动没有缓存:扩展能力返回 `XUQM_NOT_READY`,宿主核心业务继续运行。
- 不自动切换到硬编码配置,不静默创建默认租户,不提供多级兼容回退。 - 不自动切换到硬编码配置,不静默创建默认租户,不提供多级兼容回退。
- common-only 项目没有配置时不会执行初始化,也不会报错。 - common-only 项目没有配置时不会执行初始化,也不会报错。
@ -135,15 +136,11 @@ await XuqmSDK.logout()
## 安全约束 ## 安全约束
- 不打印密文或解密后的配置。 - 不打印密文或解密后的配置。
- 不提交明文 appKey、signingKey 或租户密钥 - appKey 是公开应用身份但仍不得散落复制;不得提交租户密钥、Token 或服务端秘密
- 不在多个目录手工维护配置副本。 - 不在多个目录手工维护配置副本。
- `.xuqmconfig` 变更必须通过平台重新签发。 - `.xuqmconfig` 变更必须通过平台重新签发。
- 最后成功配置不会明文写入 AsyncStorage。SDK 使用签发配置中的 `signingKey` - 最后成功的远端配置是非敏感 LKG,以带版本、严格 Schema 的 JSON 保存在应用沙箱;
appKey、包名和平台地址派生独立 AES-GCM 缓存密钥,密钥材料不落缓存;篡改、 未知字段、非法地址、旧格式或损坏数据直接删除。它不伪装成依赖客户端共享秘密的
跨包或跨环境读取都会失败。该边界属于应用沙箱内加密,不宣称具备 Android 安全凭据。
Keystore/iOS Keychain 的硬件密钥保密性。
- `signingKey` 是随客户端配置交付、可被终端提取的请求签名材料,只用于请求完整性
标记和本地缓存派生,不是 License、授权凭据或服务端秘密,服务端不得仅凭该签名
授予敏感权限。
- 用户数据、灰度发布、上传构建制品等敏感 API 仍必须使用登录得到的用户 - 用户数据、灰度发布、上传构建制品等敏感 API 仍必须使用登录得到的用户
`accessToken` 或 Jenkins 发布流水线的独立 Bearer,并由服务端执行真实授权。 `accessToken` 或 Jenkins 发布流水线的独立 Bearer,并由服务端执行真实授权。

查看文件

@ -34,8 +34,17 @@ try {
SourceMap 上传使用独立打包后处理流程;不要再叠加基于 Metro `customSerializer` 的旧插件方案,以免破坏新版 Metro 的 exports 与序列化流程。 SourceMap 上传使用独立打包后处理流程;不要再叠加基于 Metro `customSerializer` 的旧插件方案,以免破坏新版 Metro 的 exports 与序列化流程。
fatal/error 持久队列复用 common 的 AES-GCM 沙箱加密能力;没有签发密钥时不降级写入 fatal/error 持久队列复用 common 的 AES-GCM 沙箱加密能力,并在设备首次需要持久化时
包含堆栈和设备信息的明文。Issue 只有在错误堆栈能唯一匹配当前原生 Bundle 使用系统安全随机源生成 256 位本地密钥。该密钥仅保存在当前应用沙箱,不来自
`.xuqmconfig`、不打入 Bundle/安装包,也不进入日志或上报数据;安全随机源或存储不可用
时直接放弃持久化,绝不降级写入包含堆栈和设备信息的明文。密钥丢失、格式损坏或应用
上下文不匹配时清除不可解密队列。
服务端返回 `429`/`BUGCOLLECT_RATE_LIMITED` 时,SDK 读取标准 `Retry-After` 并进入
最长一小时的上传冷却;队列保持不变,冷却期间不重复请求,也不会把限流异常抛给宿主
业务线程。
Issue 只有在错误堆栈能唯一匹配当前原生 Bundle
state/manifest 时才携带 `buildId/moduleId/moduleVersion/bundleHash` 并标记 state/manifest 时才携带 `buildId/moduleId/moduleVersion/bundleHash` 并标记
`symbolicationStatus=exact`;无法归属时明确为 `unavailable`,服务端不得猜测 latest `symbolicationStatus=exact`;无法归属时明确为 `unavailable`,服务端不得猜测 latest
Source Map。 Source Map。

查看文件

@ -14,6 +14,7 @@
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/" "registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
}, },
"scripts": { "scripts": {
"pack:check": "corepack pnpm pack --dry-run",
"test": "node --import tsx --test tests/*.test.ts", "test": "node --import tsx --test tests/*.test.ts",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },

查看文件

@ -107,7 +107,6 @@ function _createQueue(): LogQueue {
return new LogQueue({ return new LogQueue({
bugCollectApiUrl: cfg.bugCollectApiUrl, bugCollectApiUrl: cfg.bugCollectApiUrl,
appKey: cfg.appKey, appKey: cfg.appKey,
encryptionSecret: cfg.signingKey,
onDisabled: () => { onDisabled: () => {
_platformDisabled = true _platformDisabled = true
void AsyncStorage.setItem(disabledKey(), 'true').catch(() => undefined) void AsyncStorage.setItem(disabledKey(), 'true').catch(() => undefined)

查看文件

@ -5,11 +5,13 @@ import { shouldPersist } from './queuePolicy'
import { retryWithBackoff } from '@xuqm/rn-common' import { retryWithBackoff } from '@xuqm/rn-common'
import { import {
clearStoredQueue, clearStoredQueue,
getOrCreateQueueEncryptionKey,
isBugCollectDisabledPayload, isBugCollectDisabledPayload,
openQueue, openQueue,
sealQueue, sealQueue,
storedQueueKey, storedQueueKey,
} from './queueStorage' } from './queueStorage'
import { parseRetryAfterMillis } from './backpressure'
const BATCH_SIZE = 30 const BATCH_SIZE = 30
const FLUSH_INTERVAL_MS = 10_000 // 10 seconds const FLUSH_INTERVAL_MS = 10_000 // 10 seconds
const MAX_QUEUE_SIZE = 500 const MAX_QUEUE_SIZE = 500
@ -21,17 +23,25 @@ export class BugCollectDisabledError extends Error {
readonly code = DISABLED_ERROR_CODE readonly code = DISABLED_ERROR_CODE
} }
class BugCollectRateLimitedError extends Error {
readonly name = 'BugCollectRateLimitedError'
constructor(readonly retryAfterMs: number) {
super('BUGCOLLECT_RATE_LIMITED')
}
}
export class LogQueue { export class LogQueue {
private _memory: BugCollectEvent[] = [] private _memory: BugCollectEvent[] = []
private flushTimer: ReturnType<typeof setInterval> | null = null private flushTimer: ReturnType<typeof setInterval> | null = null
private disabled = false private disabled = false
private flushPromise: Promise<void> | null = null private flushPromise: Promise<void> | null = null
private cooldownUntil = 0
constructor( constructor(
private cfg: { private cfg: {
bugCollectApiUrl: string bugCollectApiUrl: string
appKey: string appKey: string
encryptionSecret?: string
onDisabled?: () => void | Promise<void> onDisabled?: () => void | Promise<void>
}, },
) { ) {
@ -67,6 +77,7 @@ export class LogQueue {
private async _flushOnce(throwOnFailure: boolean): Promise<void> { private async _flushOnce(throwOnFailure: boolean): Promise<void> {
if (this.disabled) return if (this.disabled) return
if (Date.now() < this.cooldownUntil) return
const stored = await this._readStored() const stored = await this._readStored()
const combined = [...stored, ...this._memory] const combined = [...stored, ...this._memory]
if (combined.length === 0) return if (combined.length === 0) return
@ -86,7 +97,9 @@ export class LogQueue {
}, },
{ {
maxAttempts: 3, maxAttempts: 3,
shouldRetry: error => !(error instanceof BugCollectDisabledError), shouldRetry: error =>
!(error instanceof BugCollectDisabledError) &&
!(error instanceof BugCollectRateLimitedError),
}, },
) )
await this._writeStored(stored.slice(storedUsed)) await this._writeStored(stored.slice(storedUsed))
@ -96,6 +109,9 @@ export class LogQueue {
this.disabled = true this.disabled = true
await this.clear() await this.clear()
await this.cfg.onDisabled?.() await this.cfg.onDisabled?.()
} else if (error instanceof BugCollectRateLimitedError) {
// 服务端过载时保留原队列,并在冷却期内停止定时器制造额外请求。
this.cooldownUntil = Date.now() + error.retryAfterMs
} }
// 后台上报失败由队列吸收;保留原队列,等待网络恢复或下次进程启动。 // 后台上报失败由队列吸收;保留原队列,等待网络恢复或下次进程启动。
if (throwOnFailure) throw error if (throwOnFailure) throw error
@ -141,6 +157,9 @@ export class LogQueue {
if (disabledByPlatform || code === DISABLED_ERROR_CODE) { if (disabledByPlatform || code === DISABLED_ERROR_CODE) {
throw new BugCollectDisabledError() throw new BugCollectDisabledError()
} }
if (res.status === 429 || code === 'BUGCOLLECT_RATE_LIMITED') {
throw new BugCollectRateLimitedError(parseRetryAfterMillis(res.headers.get('Retry-After')))
}
throw new Error(`[BugCollect] Upload failed: ${res.status}`) throw new Error(`[BugCollect] Upload failed: ${res.status}`)
} }
} }
@ -156,13 +175,14 @@ export class LogQueue {
private async _readStored(): Promise<BugCollectEvent[]> { private async _readStored(): Promise<BugCollectEvent[]> {
const raw = await AsyncStorage.getItem(this.storageKey) const raw = await AsyncStorage.getItem(this.storageKey)
if (!raw) return [] if (!raw) return []
if (!this.cfg.encryptionSecret) { const encryptionKey = await getOrCreateQueueEncryptionKey(AsyncStorage, this.cfg.appKey)
if (!encryptionKey) {
await AsyncStorage.removeItem(this.storageKey) await AsyncStorage.removeItem(this.storageKey)
return [] return []
} }
try { try {
const cutoff = Date.now() - MAX_STORED_AGE_MS const cutoff = Date.now() - MAX_STORED_AGE_MS
return openQueue(raw, this.cfg.encryptionSecret, this.storageContext, cutoff) return openQueue(raw, encryptionKey, this.storageContext, cutoff)
} catch { } catch {
await AsyncStorage.removeItem(this.storageKey) await AsyncStorage.removeItem(this.storageKey)
return [] return []
@ -172,13 +192,16 @@ export class LogQueue {
private async _writeStored(queue: BugCollectEvent[]): Promise<void> { private async _writeStored(queue: BugCollectEvent[]): Promise<void> {
if (queue.length === 0) { if (queue.length === 0) {
await AsyncStorage.removeItem(this.storageKey) await AsyncStorage.removeItem(this.storageKey)
} else if (!this.cfg.encryptionSecret) {
// 没有签发密钥时绝不降级写入含栈和设备信息的明文持久队列。
await AsyncStorage.removeItem(this.storageKey)
} else { } else {
const encryptionKey = await getOrCreateQueueEncryptionKey(AsyncStorage, this.cfg.appKey)
if (!encryptionKey) {
// 安全随机源或本地存储不可用时不持久化,绝不写入含栈和设备信息的明文。
await AsyncStorage.removeItem(this.storageKey)
return
}
await AsyncStorage.setItem( await AsyncStorage.setItem(
this.storageKey, this.storageKey,
sealQueue(queue, this.cfg.encryptionSecret, this.storageContext), sealQueue(queue, encryptionKey, this.storageContext),
) )
} }
} }

查看文件

@ -0,0 +1,11 @@
const DEFAULT_RETRY_AFTER_MS = 60_000
const MAX_RETRY_AFTER_MS = 60 * 60_000
/** 兼容 Retry-After 的秒数与 HTTP-date 两种标准格式,并限制异常服务端值。 */
export function parseRetryAfterMillis(value: string | null, now = Date.now()): number {
if (!value) return DEFAULT_RETRY_AFTER_MS
const seconds = Number(value)
const millis = Number.isFinite(seconds) ? seconds * 1_000 : Date.parse(value) - now
if (!Number.isFinite(millis) || millis <= 0) return DEFAULT_RETRY_AFTER_MS
return Math.min(MAX_RETRY_AFTER_MS, Math.max(1_000, Math.ceil(millis)))
}

查看文件

@ -1,12 +1,71 @@
import { openLocalCache, sealLocalCache } from '@xuqm/rn-common/secure-cache' import { bytesToBase64 } from '@xuqm/rn-common/crypto'
import { openLocalCache, sealLocalCache, secureRandomBytes } from '@xuqm/rn-common/secure-cache'
import type { BugCollectEvent } from '../types' import type { BugCollectEvent } from '../types'
const STORED_QUEUE_KEY_PREFIX = '@xuqm_bugcollect:queue:' const STORED_QUEUE_KEY_PREFIX = '@xuqm_bugcollect:queue:'
const QUEUE_ENCRYPTION_KEY_PREFIX = '@xuqm_bugcollect:queue_key:v1:'
const QUEUE_ENCRYPTION_KEY_FORMAT = 'XUQM-DEVICE-KEY-V1'
const QUEUE_ENCRYPTION_KEY_BYTES = 32
const pendingKeys = new Map<string, Promise<string | null>>()
type QueueKeyStorage = {
getItem(key: string): Promise<string | null>
setItem(key: string, value: string): Promise<unknown>
}
function isQueueEncryptionKey(value: string | null): value is string {
if (!value) return false
const [format, encoded, ...extra] = value.split('.')
return (
format === QUEUE_ENCRYPTION_KEY_FORMAT &&
extra.length === 0 &&
/^[A-Za-z0-9+/]{43}=$/.test(encoded)
)
}
function createQueueEncryptionKey(): string {
const value = secureRandomBytes(QUEUE_ENCRYPTION_KEY_BYTES)
return `${QUEUE_ENCRYPTION_KEY_FORMAT}.${bytesToBase64(value)}`
}
export function storedQueueKey(appKey: string): string { export function storedQueueKey(appKey: string): string {
return `${STORED_QUEUE_KEY_PREFIX}${appKey}` return `${STORED_QUEUE_KEY_PREFIX}${appKey}`
} }
export function queueEncryptionKeyStorageKey(appKey: string): string {
return `${QUEUE_ENCRYPTION_KEY_PREFIX}${appKey}`
}
/**
*
* Bundle
*/
export function getOrCreateQueueEncryptionKey(
storage: QueueKeyStorage,
appKey: string,
): Promise<string | null> {
const storageKey = queueEncryptionKeyStorageKey(appKey)
const running = pendingKeys.get(storageKey)
if (running) return running
const task = (async () => {
try {
const stored = await storage.getItem(storageKey)
if (isQueueEncryptionKey(stored)) return stored
const created = createQueueEncryptionKey()
await storage.setItem(storageKey, created)
return created
} catch {
// 安全随机源或本地存储不可用时禁止持久化诊断数据,不得降级为明文。
return null
}
})().finally(() => {
pendingKeys.delete(storageKey)
})
pendingKeys.set(storageKey, task)
return task
}
export async function clearStoredQueue( export async function clearStoredQueue(
storage: { removeItem(key: string): Promise<unknown> }, storage: { removeItem(key: string): Promise<unknown> },
appKey: string, appKey: string,
@ -14,17 +73,21 @@ export async function clearStoredQueue(
await storage.removeItem(storedQueueKey(appKey)) await storage.removeItem(storedQueueKey(appKey))
} }
export function sealQueue(queue: BugCollectEvent[], secret: string, context: string): string { export function sealQueue(
return sealLocalCache(JSON.stringify(queue), secret, context) queue: BugCollectEvent[],
encryptionKey: string,
context: string,
): string {
return sealLocalCache(JSON.stringify(queue), encryptionKey, context)
} }
export function openQueue( export function openQueue(
payload: string, payload: string,
secret: string, encryptionKey: string,
context: string, context: string,
cutoff: number, cutoff: number,
): BugCollectEvent[] { ): BugCollectEvent[] {
const parsed = JSON.parse(openLocalCache(payload, secret, context)) as BugCollectEvent[] const parsed = JSON.parse(openLocalCache(payload, encryptionKey, context)) as BugCollectEvent[]
if (!Array.isArray(parsed)) throw new Error('BugCollect queue payload is not an array') 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) return parsed.filter(event => Number.isFinite(event.timestamp) && event.timestamp >= cutoff)
} }

查看文件

@ -0,0 +1,16 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { parseRetryAfterMillis } from '../src/queue/backpressure'
test('Retry-After supports seconds and HTTP dates', () => {
const now = Date.parse('2026-07-28T00:00:00Z')
assert.equal(parseRetryAfterMillis('27', now), 27_000)
assert.equal(parseRetryAfterMillis('Tue, 28 Jul 2026 00:00:45 GMT', now), 45_000)
})
test('invalid or extreme Retry-After values stay bounded', () => {
assert.equal(parseRetryAfterMillis(null), 60_000)
assert.equal(parseRetryAfterMillis('invalid'), 60_000)
assert.equal(parseRetryAfterMillis('999999'), 60 * 60_000)
})

查看文件

@ -3,8 +3,10 @@ import test from 'node:test'
import { import {
clearStoredQueue, clearStoredQueue,
getOrCreateQueueEncryptionKey,
isBugCollectDisabledPayload, isBugCollectDisabledPayload,
openQueue, openQueue,
queueEncryptionKeyStorageKey,
sealQueue, sealQueue,
} from '../src/queue/queueStorage' } from '../src/queue/queueStorage'
import type { IssueEvent } from '../src/types' import type { IssueEvent } from '../src/types'
@ -21,14 +23,46 @@ const issue: IssueEvent = {
symbolicationStatus: 'unavailable', symbolicationStatus: 'unavailable',
} }
test('persistent queue is encrypted, context-bound and tamper-evident', () => { function createMemoryStorage() {
const encrypted = sealQueue([issue], 'signing-key', 'bugcollect-queue|app') const values = new Map<string, string>()
return {
values,
async getItem(key: string) {
return values.get(key) ?? null
},
async setItem(key: string, value: string) {
values.set(key, value)
},
}
}
test('persistent queue uses a stable device-generated key and never stores diagnostics as plaintext', async () => {
const storage = createMemoryStorage()
const key = await getOrCreateQueueEncryptionKey(storage, 'app')
const sameKey = await getOrCreateQueueEncryptionKey(storage, 'app')
assert.ok(key)
assert.equal(sameKey, key)
assert.equal(storage.values.get(queueEncryptionKeyStorageKey('app')), key)
assert.equal(key.includes('signing-key'), false)
const encrypted = sealQueue([issue], key, 'bugcollect-queue|app')
assert.equal(encrypted.includes('private stack'), false) assert.equal(encrypted.includes('private stack'), false)
assert.deepEqual(openQueue(encrypted, 'signing-key', 'bugcollect-queue|app', 0), [issue]) assert.deepEqual(openQueue(encrypted, key, 'bugcollect-queue|app', 0), [issue])
assert.throws(() => openQueue(encrypted, 'signing-key', 'bugcollect-queue|other', 0)) assert.throws(() => openQueue(encrypted, key, 'bugcollect-queue|other', 0))
const parts = encrypted.split('.') const parts = encrypted.split('.')
parts[2] = `${parts[2][0] === 'A' ? 'B' : 'A'}${parts[2].slice(1)}` parts[2] = `${parts[2][0] === 'A' ? 'B' : 'A'}${parts[2].slice(1)}`
assert.throws(() => openQueue(parts.join('.'), 'signing-key', 'bugcollect-queue|app', 0)) assert.throws(() => openQueue(parts.join('.'), key, 'bugcollect-queue|app', 0))
})
test('different applications never share the same local queue key', async () => {
const storage = createMemoryStorage()
const [first, second] = await Promise.all([
getOrCreateQueueEncryptionKey(storage, 'first'),
getOrCreateQueueEncryptionKey(storage, 'second'),
])
assert.ok(first)
assert.ok(second)
assert.notEqual(first, second)
}) })
test('platform disabled response recognizes the deployed message contract', () => { test('platform disabled response recognizes the deployed message contract', () => {

查看文件

@ -21,7 +21,7 @@ const result = await apiRequest('/health', { skipAuth: true })
const state = expirationStatus('2027-01-01T00:00:00Z') const state = expirationStatus('2027-01-01T00:00:00Z')
``` ```
没有 SDK 配置时,`apiRequest` 不添加 appKey、用户和签名请求头。 没有 SDK 配置时,`apiRequest` 不添加 appKey 和用户请求头。
## 扩展包自动初始化 ## 扩展包自动初始化
@ -39,7 +39,12 @@ module.exports = withXuqmConfig(
) )
``` ```
使用 update、bugcollect、xwebview 等扩展包时,`withXuqmConfig()` 会在 Node/Metro 构建进程中解密配置,生成只读的虚拟配置模块,并把 common 的初始化入口注册为 Metro pre-main module,确保初始化早于业务入口且不受 `inlineRequires` 影响。Metro 插件还会把同一份加密配置同步到 Android assets;宿主不需要在多个位置维护副本。PBKDF2 与 AES-GCM 解密不在 Hermes 启动线程执行,应用启动时只消费构建已经解析的配置并发起共享初始化。 使用 update、bugcollect、xwebview 等扩展包时,`withXuqmConfig()` 会在 Node/Metro 构建
进程中验签并解析配置,生成只读的虚拟配置模块,并把 common 的初始化入口注册为 Metro
pre-main module,确保初始化早于业务入口且不受 `inlineRequires` 影响。配置只包含
appKey、平台地址、包标识等公开身份信息,不包含请求签名密钥或其它客户端长期秘密。
PBKDF2 与 AES-GCM 解析不在 Hermes 启动线程执行,应用启动时只消费构建已经解析的配置
并发起共享初始化。
common-only 项目不要放置 `.xuqmconfig`,也不要使用 `withXuqmConfig()`,即可保持零初始化。是否初始化只由这一条规则决定,不提供手动初始化、额外 alias 或多套配置约定。 common-only 项目不要放置 `.xuqmconfig`,也不要使用 `withXuqmConfig()`,即可保持零初始化。是否初始化只由这一条规则决定,不提供手动初始化、额外 alias 或多套配置约定。
@ -49,6 +54,10 @@ Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败
`updateEnabled` 只在前者缺失时读取。两者都缺失时安全默认关闭,最后成功配置缓存会 `updateEnabled` 只在前者缺失时读取。两者都缺失时安全默认关闭,最后成功配置缓存会
原样保存开关值。关闭 Update 不影响 common、宿主业务或其它扩展 SDK。 原样保存开关值。关闭 Update 不影响 common、宿主业务或其它扩展 SDK。
最后一次成功的远端配置作为非敏感 LKG 严格解析后保存在应用沙箱。缓存使用带格式版本的
严格 Schema,未知字段、非法地址、旧格式和损坏 JSON 一律删除;它不依赖可从客户端提取
的共享秘密,也不会因固定天数过期而让已安装 App 在平台暂时不可用时失去既有配置。
## 扩展包的唯一登录入口 ## 扩展包的唯一登录入口
应用登录成功后只调用一次: 应用登录成功后只调用一次:
@ -71,13 +80,16 @@ await XuqmSDK.logout()
## 通用能力 ## 通用能力
- `apiRequest`common-only 模式可直接使用;存在 SDK 配置时使用当前会话的 userId、access token 和请求签名。需要配置的扩展包会先调用共享 `awaitInitialization()`,common 不会擅自要求初始化。 - `apiRequest`common-only 模式可直接使用;存在 SDK 配置时只附加公开 appKey、当前
userId 和登录 access token。客户端不再生成 HMAC、时间戳或 nonce;服务端身份鉴权依赖
Bearer Token,appKey 只用于租户路由,不作为秘密。需要配置的扩展包会先调用共享
`awaitInitialization()`,common 不会擅自要求初始化。
- `downloadBytes`、`downloadText`、`downloadFileToPath`:统一小文件流式读取与大文件落盘、进度和取消能力。 - `downloadBytes`、`downloadText`、`downloadFileToPath`:统一小文件流式读取与大文件落盘、进度和取消能力。
- `compareVersions`、`satisfiesVersion`、`compatibleMajorRange`:统一 SemVer 与插件兼容范围判断。 - `compareVersions`、`satisfiesVersion`、`compatibleMajorRange`:统一 SemVer 与插件兼容范围判断。
- `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`:统一时间解析与过期判断。 - `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`:统一时间解析与过期判断。
- `formatNumericDateTime`:为 API 参数和固定数字布局提供不受 locale 标点/顺序影响的本地时间格式,调用方通过选项控制日期、时间、秒和分隔符。 - `formatNumericDateTime`:为 API 参数和固定数字布局提供不受 locale 标点/顺序影响的本地时间格式,调用方通过选项控制日期、时间、秒和分隔符。
- `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`、`registerAndroidDownloadedFile`:统一文件与 Android 下载登记操作。 - `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`、`registerAndroidDownloadedFile`:统一文件与 Android 下载登记操作。
- `hmacSha256Base64Url`、`sha256Hex`签名与文件摘要使用的统一实现。平台签发的 - `sha256Hex`:文件摘要使用的统一实现。平台签发的
`.xuqmconfig` 只允许由 Metro 构建插件验签和解析,不向运行时暴露通用解密 API。 `.xuqmconfig` 只允许由 Metro 构建插件验签和解析,不向运行时暴露通用解密 API。
- `getDeviceInfo`、`getDeviceId`、`useApi`、`usePageApi`、`showToast` 等项目通用能力。 - `getDeviceInfo`、`getDeviceId`、`useApi`、`usePageApi`、`showToast` 等项目通用能力。

查看文件

@ -47,7 +47,6 @@ function assertConfigPayload(config, plaintext) {
'iosBundleId', 'iosBundleId',
'harmonyBundleName', 'harmonyBundleName',
'serverUrl', 'serverUrl',
'signingKey',
]) ])
const unknownField = Object.keys(config).find(field => !allowedFields.has(field)) const unknownField = Object.keys(config).find(field => !allowedFields.has(field))
if (unknownField) throw new Error(`配置包含不受支持字段:${unknownField}`) if (unknownField) throw new Error(`配置包含不受支持字段:${unknownField}`)
@ -63,7 +62,7 @@ function assertConfigPayload(config, plaintext) {
if (!Number.isInteger(config.revision) || config.revision <= 0) { if (!Number.isInteger(config.revision) || config.revision <= 0) {
throw new Error('配置 revision 必须为正整数') throw new Error('配置 revision 必须为正整数')
} }
for (const field of ['issuedAt', 'appKey', 'appName', 'serverUrl', 'signingKey']) { for (const field of ['issuedAt', 'appKey', 'appName', 'serverUrl']) {
if (typeof config[field] !== 'string' || !config[field].trim()) { if (typeof config[field] !== 'string' || !config[field].trim()) {
throw new Error(`配置 ${field} 不能为空`) throw new Error(`配置 ${field} 不能为空`)
} }

查看文件

@ -30,6 +30,7 @@
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/" "registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
}, },
"scripts": { "scripts": {
"pack:check": "corepack pnpm pack --dry-run",
"test": "node --import tsx --test tests/*.test.ts tests/*.test.cjs", "test": "node --import tsx --test tests/*.test.ts tests/*.test.cjs",
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },

查看文件

@ -33,8 +33,6 @@ export interface XuqmConfig {
/** Update 灰度检查是否要求共享登录态;由租户平台动态配置。 */ /** Update 灰度检查是否要求共享登录态;由租户平台动态配置。 */
updateRequiresLogin: boolean updateRequiresLogin: boolean
configurationSource: 'remote' | 'cache' configurationSource: 'remote' | 'cache'
// 请求签名密钥(从配置文件读取)
signingKey?: string
} }
export type XuqmInitializationState = 'idle' | 'initializing' | 'ready' | 'degraded' | 'failed' export type XuqmInitializationState = 'idle' | 'initializing' | 'ready' | 'degraded' | 'failed'
@ -91,11 +89,7 @@ export interface XuqmRemoteConfig {
updateRequiresLogin?: boolean updateRequiresLogin?: boolean
} }
export function initConfigFromRemote( export function initConfigFromRemote(options: InternalInitOptions, remote: XuqmRemoteConfig): void {
options: InternalInitOptions,
remote: XuqmRemoteConfig,
signingKey?: string,
): void {
const platformUrl = (options.platformUrl ?? '').replace(/\/$/, '') const platformUrl = (options.platformUrl ?? '').replace(/\/$/, '')
const apiUrl = remote.apiUrl ?? remote.imApiUrl ?? '' const apiUrl = remote.apiUrl ?? remote.imApiUrl ?? ''
_config = { _config = {
@ -114,7 +108,6 @@ export function initConfigFromRemote(
// 服务端未下发匿名策略时按安全默认禁止匿名更新检查。 // 服务端未下发匿名策略时按安全默认禁止匿名更新检查。
updateRequiresLogin: remote.updateRequiresLogin ?? true, updateRequiresLogin: remote.updateRequiresLogin ?? true,
configurationSource: options.configSource ?? 'remote', configurationSource: options.configSource ?? 'remote',
signingKey,
} }
} }

查看文件

@ -1,4 +1,3 @@
import { hmac } from '@noble/hashes/hmac.js'
import { sha256 } from '@noble/hashes/sha2.js' import { sha256 } from '@noble/hashes/sha2.js'
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js' import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'
import { encodeBase64 } from '../security/base64.js' import { encodeBase64 } from '../security/base64.js'
@ -13,7 +12,6 @@ export interface DecryptedConfig {
iosBundleId?: string iosBundleId?: string
harmonyBundleName?: string harmonyBundleName?: string
serverUrl: string serverUrl: string
signingKey: string
issuedAt: string issuedAt: string
expiresAt?: string expiresAt?: string
} }
@ -23,14 +21,6 @@ export function bytesToBase64(value: Uint8Array): string {
return encodeBase64(value) return encodeBase64(value)
} }
function base64UrlEncode(value: Uint8Array): string {
return encodeBase64(value, { urlSafe: true, padding: false })
}
export function hmacSha256Base64Url(secret: string, data: string): string {
return base64UrlEncode(hmac(sha256, utf8ToBytes(secret), utf8ToBytes(data)))
}
/** Canonical SHA-256 digest used by downloads, bundles, and package verification. */ /** Canonical SHA-256 digest used by downloads, bundles, and package verification. */
export function sha256Hex(value: string | Uint8Array): string { export function sha256Hex(value: string | Uint8Array): string {
return bytesToHex(sha256(typeof value === 'string' ? utf8ToBytes(value) : value)) return bytesToHex(sha256(typeof value === 'string' ? utf8ToBytes(value) : value))

查看文件

@ -1,7 +1,6 @@
import AsyncStorage from '@react-native-async-storage/async-storage' import AsyncStorage from '@react-native-async-storage/async-storage'
import { DEFAULT_TENANT_PLATFORM_URL } from './constants' import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
import { getOptionalConfig, getUserId } from './config' import { getOptionalConfig, getUserId } from './config'
import { hmacSha256Base64Url } from './crypto'
import { safeRequestPath } from './api/diagnostics' import { safeRequestPath } from './api/diagnostics'
const TOKEN_KEY = '@xuqm:token' const TOKEN_KEY = '@xuqm:token'
@ -29,15 +28,6 @@ export async function _clearAccessToken(): Promise<void> {
return AsyncStorage.removeItem(TOKEN_KEY) return AsyncStorage.removeItem(TOKEN_KEY)
} }
let nonceSequence = 0
function generateRequestNonce(secret: string, appKey: string): string {
nonceSequence = (nonceSequence + 1) % Number.MAX_SAFE_INTEGER
const seed = `${Date.now()}\n${nonceSequence}\n${appKey}`
const value = hmacSha256Base64Url(secret, seed)
return `${value.slice(0, 8)}-${value.slice(8, 12)}-4${value.slice(13, 16)}-a${value.slice(17, 20)}-${value.slice(20, 32)}`
}
export async function apiRequest<T>( export async function apiRequest<T>(
path: string, path: string,
options: { options: {
@ -68,16 +58,9 @@ export async function apiRequest<T>(
} }
const config = getOptionalConfig() const config = getOptionalConfig()
const signingKey = config?.signingKey if (config) {
if (config && signingKey) {
const timestamp = Math.floor(Date.now() / 1000).toString()
const nonce = generateRequestNonce(signingKey, config.appKey)
const userId = getUserId() ?? '' const userId = getUserId() ?? ''
const payload = `${config.appKey}\n${userId}\n${timestamp}\n${nonce}`
headers['X-App-Key'] = config.appKey headers['X-App-Key'] = config.appKey
headers['X-Timestamp'] = timestamp
headers['X-Nonce'] = nonce
headers['X-Signature'] = hmacSha256Base64Url(signingKey, payload)
if (userId) headers['X-User-Id'] = userId if (userId) headers['X-User-Id'] = userId
} }

查看文件

@ -20,7 +20,7 @@ export { retryWithBackoff } from './retry'
export type { RetryOptions } from './retry' export type { RetryOptions } from './retry'
export { awaitInitialization } from './sdk' export { awaitInitialization } from './sdk'
export { apiRequest, configureHttp } from './http' export { apiRequest, configureHttp } from './http'
export { bytesToBase64, hmacSha256Base64Url, sha256Hex } from './crypto' export { bytesToBase64, sha256Hex } from './crypto'
export type { DecryptedConfig } from './crypto' export type { DecryptedConfig } from './crypto'
export { DEFAULT_TENANT_PLATFORM_URL, DEFAULT_IM_WS_URL } from './constants' export { DEFAULT_TENANT_PLATFORM_URL, DEFAULT_IM_WS_URL } from './constants'
export { getDeviceId, getDeviceInfo, detectPushVendor } from './device' export { getDeviceId, getDeviceInfo, detectPushVendor } from './device'

查看文件

@ -1,18 +1,44 @@
import { z } from 'zod' import { z } from 'zod'
import type { XuqmRemoteConfig } from './config' import type { XuqmRemoteConfig } from './config'
const endpointSchema = z.string().refine(value => {
if (value === '') return true
try {
const protocol = new URL(value).protocol
return (
protocol === 'http:' || protocol === 'https:' || protocol === 'ws:' || protocol === 'wss:'
)
} catch {
return false
}
}, 'endpoint must be an absolute HTTP(S) or WS(S) URL')
const flatRemoteConfigSchema = z.object({ const flatRemoteConfigSchema = z.object({
apiUrl: z.string().optional(), apiUrl: endpointSchema.optional(),
imApiUrl: z.string().optional(), imApiUrl: endpointSchema.optional(),
imWsUrl: z.string().optional(), imWsUrl: endpointSchema.optional(),
fileServiceUrl: z.string().optional(), fileServiceUrl: endpointSchema.optional(),
imEnabled: z.boolean().optional(), imEnabled: z.boolean().optional(),
pushEnabled: z.boolean().optional(), pushEnabled: z.boolean().optional(),
bugCollectApiUrl: z.string().optional(), bugCollectApiUrl: endpointSchema.optional(),
bugCollectEnabled: z.boolean().optional(), bugCollectEnabled: z.boolean().optional(),
updateEnabled: z.boolean().optional(), updateEnabled: z.boolean().optional(),
updateRequiresLogin: z.boolean().optional(), updateRequiresLogin: z.boolean().optional(),
}) })
const strictCachedRemoteConfigSchema = flatRemoteConfigSchema.strict()
const REMOTE_CONFIG_CACHE_FORMAT = 'XUQM-REMOTE-CONFIG-LKG-V1'
const remoteConfigCacheSchema = z
.object({
cachedAt: z.number().int().nonnegative(),
format: z.literal(REMOTE_CONFIG_CACHE_FORMAT),
remote: strictCachedRemoteConfigSchema,
})
.strict()
export type CachedRemoteConfig = {
cachedAt: number
remote: XuqmRemoteConfig
}
const remoteConfigResponseSchema = flatRemoteConfigSchema const remoteConfigResponseSchema = flatRemoteConfigSchema
.extend({ .extend({
@ -48,8 +74,29 @@ export function normalizeRemoteConfig(value: unknown): XuqmRemoteConfig {
} }
} }
/** 旧缓存允许缺少 updateEnabled,最终由 XuqmConfig 按安全默认 false 收敛。 */ /** 缓存只接受归一化后的非敏感字段,并拒绝所有未知字段。 */
export function parseCachedRemoteConfig(value: unknown): XuqmRemoteConfig | null { export function parseCachedRemoteConfig(value: unknown): XuqmRemoteConfig | null {
const parsed = flatRemoteConfigSchema.safeParse(value) const parsed = strictCachedRemoteConfigSchema.safeParse(value)
return parsed.success ? parsed.data : null return parsed.success ? parsed.data : null
} }
/** 非敏感 LKG 的唯一序列化入口;不加密、不携带任何客户端共享秘密。 */
export function serializeRemoteConfigCache(remote: XuqmRemoteConfig, cachedAt: number): string {
return JSON.stringify(
remoteConfigCacheSchema.parse({
cachedAt,
format: REMOTE_CONFIG_CACHE_FORMAT,
remote,
}),
)
}
/** 损坏、旧格式或含额外字段的缓存全部视为无效。 */
export function parseRemoteConfigCache(value: string): CachedRemoteConfig | null {
try {
const parsed = remoteConfigCacheSchema.safeParse(JSON.parse(value))
return parsed.success ? { cachedAt: parsed.data.cachedAt, remote: parsed.data.remote } : null
} catch {
return null
}
}

查看文件

@ -11,7 +11,6 @@ import {
isInitialized, isInitialized,
type InternalInitOptions, type InternalInitOptions,
type XuqmLoginOptions, type XuqmLoginOptions,
type XuqmRemoteConfig,
type XuqmUserInfo, type XuqmUserInfo,
} from './config' } from './config'
import { DEFAULT_TENANT_PLATFORM_URL } from './constants' import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
@ -19,9 +18,13 @@ import { sha256Hex } from './crypto'
import { XuqmError } from './errors' import { XuqmError } from './errors'
import { _clearAccessToken, _saveAccessToken, configureHttp } from './http' import { _clearAccessToken, _saveAccessToken, configureHttp } from './http'
import { retryWithBackoff } from './retry' import { retryWithBackoff } from './retry'
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from './secureCache'
import type { DecryptedConfig } from './crypto' import type { DecryptedConfig } from './crypto'
import { normalizeRemoteConfig, parseCachedRemoteConfig } from './remoteConfig' import {
normalizeRemoteConfig,
parseRemoteConfigCache,
serializeRemoteConfigCache,
type CachedRemoteConfig,
} from './remoteConfig'
let _initPromise: Promise<void> | null = null let _initPromise: Promise<void> | null = null
let _resolvedConfigInitPromise: Promise<void> | null = null let _resolvedConfigInitPromise: Promise<void> | null = null
@ -32,12 +35,7 @@ let _resolvedConfigRequest: {
let _initializationKey: string | null = null let _initializationKey: string | null = null
let _sessionTransition: Promise<void> = Promise.resolve() let _sessionTransition: Promise<void> = Promise.resolve()
let _activeSessionKey: string | null = null 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 REMOTE_CONFIG_CACHE_PREFIX = '@xuqm_common:remote_config:v1:'
interface CachedRemoteConfig {
cachedAt: number
remote: XuqmRemoteConfig
}
function runtimePlatform(): 'ANDROID' | 'IOS' { function runtimePlatform(): 'ANDROID' | 'IOS' {
try { try {
@ -62,24 +60,16 @@ function storageKey(options: InternalInitOptions): string {
async function readCachedRemoteConfig( async function readCachedRemoteConfig(
options: InternalInitOptions, options: InternalInitOptions,
signingKey?: string,
): Promise<CachedRemoteConfig | null> { ): Promise<CachedRemoteConfig | null> {
if (!signingKey) return null
try { try {
const encrypted = await AsyncStorage.getItem(storageKey(options)) const raw = await AsyncStorage.getItem(storageKey(options))
if (!encrypted) return null if (!raw) return null
const raw = openLocalCache(encrypted, signingKey, initializationKey(options)) const cached = parseRemoteConfigCache(raw)
const cached = JSON.parse(raw) as CachedRemoteConfig if (!cached) {
const parsedRemote = parseCachedRemoteConfig(cached?.remote)
if (
!cached ||
!isCacheRecordFresh(cached.cachedAt, Date.now(), REMOTE_CONFIG_CACHE_TTL_MS) ||
!parsedRemote
) {
await AsyncStorage.removeItem(storageKey(options)) await AsyncStorage.removeItem(storageKey(options))
return null return null
} }
return { cachedAt: cached.cachedAt, remote: parsedRemote } return cached
} catch { } catch {
await AsyncStorage.removeItem(storageKey(options)).catch(() => undefined) await AsyncStorage.removeItem(storageKey(options)).catch(() => undefined)
return null return null
@ -89,22 +79,15 @@ async function readCachedRemoteConfig(
async function writeCachedRemoteConfig( async function writeCachedRemoteConfig(
options: InternalInitOptions, options: InternalInitOptions,
remote: CachedRemoteConfig['remote'], remote: CachedRemoteConfig['remote'],
signingKey?: string,
): Promise<void> { ): Promise<void> {
if (!signingKey) return
try { try {
const encrypted = sealLocalCache( await AsyncStorage.setItem(storageKey(options), serializeRemoteConfigCache(remote, Date.now()))
JSON.stringify({ cachedAt: Date.now(), remote } satisfies CachedRemoteConfig),
signingKey,
initializationKey(options),
)
await AsyncStorage.setItem(storageKey(options), encrypted)
} catch { } catch {
// 安全随机数或沙箱存储不可用时跳过缓存,不能降级为明文,也不能阻断初始化。 // 非敏感 LKG 写入失败不能阻断在线初始化。
} }
} }
function startInitialization(options: InternalInitOptions, signingKey?: string): Promise<void> { function startInitialization(options: InternalInitOptions): Promise<void> {
const nextKey = initializationKey(options) const nextKey = initializationKey(options)
if (isInitialized()) { if (isInitialized()) {
if (_initializationKey !== nextKey) { if (_initializationKey !== nextKey) {
@ -153,9 +136,9 @@ function startInitialization(options: InternalInitOptions, signingKey?: string):
) )
const json = (await res.json()) as Record<string, unknown> const json = (await res.json()) as Record<string, unknown>
remote = normalizeRemoteConfig(json.data ?? json) remote = normalizeRemoteConfig(json.data ?? json)
await writeCachedRemoteConfig(options, remote, signingKey) await writeCachedRemoteConfig(options, remote)
} catch (error) { } catch (error) {
const cached = await readCachedRemoteConfig(options, signingKey) const cached = await readCachedRemoteConfig(options)
if (!cached) { if (!cached) {
throw new XuqmError( throw new XuqmError(
'XUQM_NOT_READY', 'XUQM_NOT_READY',
@ -166,7 +149,7 @@ function startInitialization(options: InternalInitOptions, signingKey?: string):
remote = cached.remote remote = cached.remote
source = 'cache' source = 'cache'
} }
initConfigFromRemote({ ...options, platformUrl, configSource: source }, remote, signingKey) initConfigFromRemote({ ...options, platformUrl, configSource: source }, remote)
// Xuqm 平台接口始终回到租户平台。remote.apiUrl 是业务扩展服务地址, // Xuqm 平台接口始终回到租户平台。remote.apiUrl 是业务扩展服务地址,
// 二者职责不同;混用会把版本检查错误地发送到 App 自身的业务服务。 // 二者职责不同;混用会把版本检查错误地发送到 App 自身的业务服务。
configureHttp({ configureHttp({
@ -284,8 +267,7 @@ export function _initializeFromResolvedConfig(
_resolvedConfigInitPromise = (async () => { _resolvedConfigInitPromise = (async () => {
const platformUrl = config.serverUrl const platformUrl = config.serverUrl
await startInitialization( await startInitialization({
{
appKey: config.appKey, appKey: config.appKey,
platformUrl, platformUrl,
debug: options?.debug, debug: options?.debug,
@ -294,9 +276,7 @@ export function _initializeFromResolvedConfig(
runtimePlatform() === 'IOS' runtimePlatform() === 'IOS'
? (config.iosBundleId ?? config.packageName) ? (config.iosBundleId ?? config.packageName)
: config.packageName, : config.packageName,
}, })
config.signingKey,
)
})().catch(error => { })().catch(error => {
_resolvedConfigInitPromise = null _resolvedConfigInitPromise = null
throw error throw error

查看文件

@ -20,41 +20,40 @@ function utf8BytesToString(value: Uint8Array): string {
return decodeURIComponent(encoded) return decodeURIComponent(encoded)
} }
function randomNonce(): Uint8Array { /** 统一的运行时安全随机字节入口;不可用时必须失败,禁止回退 Math.random。 */
const nonce = new Uint8Array(NONCE_BYTES) export function secureRandomBytes(length: number): Uint8Array {
if (!Number.isInteger(length) || length <= 0) {
throw new Error('[XuqmSDK] Secure random byte length must be a positive integer.')
}
const value = new Uint8Array(length)
const cryptoApi = globalThis.crypto const cryptoApi = globalThis.crypto
if (!cryptoApi?.getRandomValues) { if (!cryptoApi?.getRandomValues) {
throw new Error('[XuqmSDK] Secure random source is unavailable; LKG cache was not written.') throw new Error('[XuqmSDK] Secure random source is unavailable; local cache was not written.')
} }
cryptoApi.getRandomValues(nonce) cryptoApi.getRandomValues(value)
return nonce return value
} }
/** /** 使用调用方提供的设备本地密钥完成带上下文绑定的 AES-GCM 加密。 */
* signingKey export function sealLocalCache(plaintext: string, encryptionKey: string, context: string): string {
* 线 Keystore const nonce = secureRandomBytes(NONCE_BYTES)
*/ const ciphertext = gcm(cacheKey(encryptionKey, context), nonce, utf8ToBytes(context)).encrypt(
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), utf8ToBytes(plaintext),
) )
return `${CACHE_FORMAT}.${bytesToBase64Url(nonce)}.${bytesToBase64Url(ciphertext)}` return `${CACHE_FORMAT}.${bytesToBase64Url(nonce)}.${bytesToBase64Url(ciphertext)}`
} }
export function openLocalCache(payload: string, secret: string, context: string): string { export function openLocalCache(payload: string, encryptionKey: string, context: string): string {
const parts = payload.split('.') const parts = payload.split('.')
if (parts.length !== 3 || parts[0] !== CACHE_FORMAT) { if (parts.length !== 3 || parts[0] !== CACHE_FORMAT) {
throw new Error('[XuqmSDK] LKG cache format is invalid.') throw new Error('[XuqmSDK] Encrypted local cache format is invalid.')
} }
const nonce = decodeBase64(parts[1]) const nonce = decodeBase64(parts[1])
if (nonce.length !== NONCE_BYTES) throw new Error('[XuqmSDK] LKG cache nonce is invalid.') if (nonce.length !== NONCE_BYTES) {
const plaintext = gcm(cacheKey(secret, context), nonce, utf8ToBytes(context)).decrypt( throw new Error('[XuqmSDK] Encrypted local cache nonce is invalid.')
}
const plaintext = gcm(cacheKey(encryptionKey, context), nonce, utf8ToBytes(context)).decrypt(
decodeBase64(parts[2]), decodeBase64(parts[2]),
) )
return utf8BytesToString(plaintext) return utf8BytesToString(plaintext)
} }
export function isCacheRecordFresh(cachedAt: number, now: number, ttlMs: number): boolean {
return Number.isFinite(cachedAt) && cachedAt <= now && ttlMs > 0 && now - cachedAt <= ttlMs
}

查看文件

@ -1,7 +1,7 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { hmacSha256Base64Url, sha256Hex } from '../src/crypto' import { sha256Hex } from '../src/crypto'
test('sha256Hex uses the canonical lowercase digest', () => { test('sha256Hex uses the canonical lowercase digest', () => {
assert.equal( assert.equal(
@ -9,7 +9,3 @@ test('sha256Hex uses the canonical lowercase digest', () => {
'8d7c5b94129df39d6bec13e9fed29bb15fd1715f2e75a506436d9464bd38250c', '8d7c5b94129df39d6bec13e9fed29bb15fd1715f2e75a506436d9464bd38250c',
) )
}) })
test('hmacSha256Base64Url returns an unpadded URL-safe value', () => {
assert.match(hmacSha256Base64Url('secret', 'payload'), /^[A-Za-z0-9_-]+$/)
})

查看文件

@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { apiRequest, configureHttp } from '../src/http' import { apiRequest, configureHttp } from '../src/http'
import { _setUserInfo, initConfigFromRemote } from '../src/config'
test('apiRequest aborts a stalled request at the shared timeout boundary', async () => { test('apiRequest aborts a stalled request at the shared timeout boundary', async () => {
const originalFetch = globalThis.fetch const originalFetch = globalThis.fetch
@ -24,3 +25,32 @@ test('apiRequest aborts a stalled request at the shared timeout boundary', async
globalThis.fetch = originalFetch globalThis.fetch = originalFetch
} }
}) })
test('configured requests use public identity headers without client HMAC headers', async () => {
const originalFetch = globalThis.fetch
let sentHeaders = new Headers()
initConfigFromRemote(
{ appKey: 'public-app-key', platformUrl: 'https://platform.example.test' },
{},
)
await _setUserInfo({ userId: 'user-1' })
globalThis.fetch = async (_input, init) => {
sentHeaders = new Headers(init?.headers)
return new Response(JSON.stringify({ data: { ok: true } }), {
headers: { 'content-type': 'application/json' },
status: 200,
})
}
try {
await apiRequest('/config', { skipAuth: true })
assert.equal(sentHeaders.get('X-App-Key'), 'public-app-key')
assert.equal(sentHeaders.get('X-User-Id'), 'user-1')
assert.equal(sentHeaders.has('X-Signature'), false)
assert.equal(sentHeaders.has('X-Nonce'), false)
assert.equal(sentHeaders.has('X-Timestamp'), false)
} finally {
await _setUserInfo(null)
globalThis.fetch = originalFetch
}
})

查看文件

@ -3,11 +3,48 @@ const fs = require('node:fs')
const os = require('node:os') const os = require('node:os')
const path = require('node:path') const path = require('node:path')
const test = require('node:test') const test = require('node:test')
const crypto = require('node:crypto')
const { withXuqmConfig } = require('../metro') const { withXuqmConfig } = require('../metro')
const { canonicalJson } = require('../security/canonical-json.js')
const trustedConfigKeys = require('../security/trusted-signing-keys.json')
const CONFIG_VECTOR = const TEST_KEY_ID = 'xuqm-config-test-no-secret'
'XUQM-CONFIG-V2.xuqm-config-dev-2026-07.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.MYRqE2BtQG0PvSnG3TLrr4PXd10C1cHhNA2iJLfy60XmQ5ixQBTCpn4e2g2YN0QQbZY0LVnou1rGONIzMrT7VidOMi_lHSfUf2BXnqlCIRm4yKcYCvALvrIF-Llv2xQWla0BTpKNq2Yuejpsw0oGTZGjNkvmlbBu8JJHAckHi-wP1sAgZuKVjjtJnjc7venWzaa75KwsqOYiNOkhIjWqTLNMG1siCMbX96d2A15KJYRUpwd-rBSpgjmOFLdH6DDaj2ZqCqQg6NRJprjsEorrCAnDjRjwoMKqHiIwHErfihw3v0AO19MJUzCeYzCzU2WfFw.Z-NMbyec47P4K_lfx3l7s3jZJVpd7ru8ySj2eBvXfteU4tVBoJ_3N7IRPMeiv1lUxMchsvNg8okxYpcBs4_2CQ' const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
const publicKeyDer = publicKey.export({ format: 'der', type: 'spki' })
trustedConfigKeys[TEST_KEY_ID] = publicKeyDer.subarray(-32).toString('base64')
function createConfigVector() {
const 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',
}
const plaintext = Buffer.from(canonicalJson(config), 'utf8')
const salt = crypto.randomBytes(16)
const iv = crypto.randomBytes(12)
const key = crypto.pbkdf2Sync('xuqm-config-file-v2.2026.internal', salt, 120_000, 32, 'sha256')
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv)
const encrypted = Buffer.concat([cipher.update(plaintext), cipher.final(), cipher.getAuthTag()])
const unsigned = [
'XUQM-CONFIG-V2',
TEST_KEY_ID,
salt.toString('base64url'),
iv.toString('base64url'),
encrypted.toString('base64url'),
].join('.')
return `${unsigned}.${crypto.sign(null, Buffer.from(unsigned, 'ascii'), privateKey).toString('base64url')}`
}
const CONFIG_VECTOR = createConfigVector()
test.after(() => {
delete trustedConfigKeys[TEST_KEY_ID]
})
test('withXuqmConfig schedules automatic initialization before the app entry', () => { test('withXuqmConfig schedules automatic initialization before the app entry', () => {
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-')) const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))

查看文件

@ -1,7 +1,12 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { normalizeRemoteConfig, parseCachedRemoteConfig } from '../src/remoteConfig' import {
normalizeRemoteConfig,
parseCachedRemoteConfig,
parseRemoteConfigCache,
serializeRemoteConfigCache,
} from '../src/remoteConfig'
test('platform update feature is authoritative over the flat compatibility field', () => { test('platform update feature is authoritative over the flat compatibility field', () => {
assert.equal( assert.equal(
@ -22,3 +27,37 @@ test('cached update availability is preserved exactly', () => {
assert.equal(parseCachedRemoteConfig({ updateEnabled: true })?.updateEnabled, true) assert.equal(parseCachedRemoteConfig({ updateEnabled: true })?.updateEnabled, true)
assert.equal(parseCachedRemoteConfig({ updateEnabled: false })?.updateEnabled, false) assert.equal(parseCachedRemoteConfig({ updateEnabled: false })?.updateEnabled, false)
}) })
test('non-sensitive LKG cache is strict, versioned and does not expire by age', () => {
const raw = serializeRemoteConfigCache(
{
bugCollectApiUrl: 'https://errors.example.test',
bugCollectEnabled: true,
updateEnabled: false,
},
1,
)
assert.deepEqual(parseRemoteConfigCache(raw), {
cachedAt: 1,
remote: {
bugCollectApiUrl: 'https://errors.example.test',
bugCollectEnabled: true,
updateEnabled: false,
},
})
assert.equal(raw.includes('signingKey'), false)
assert.equal(
parseRemoteConfigCache(
JSON.stringify({
...JSON.parse(raw),
injectedSecret: 'must-be-rejected',
}),
),
null,
)
})
test('cached remote config rejects unknown fields and unsafe endpoint schemes', () => {
assert.equal(parseCachedRemoteConfig({ updateEnabled: true, unknown: true }), null)
assert.equal(parseCachedRemoteConfig({ apiUrl: 'javascript:alert(1)' }), null)
})

查看文件

@ -48,7 +48,6 @@ test('awaitInitialization retries a transient config request without leaking fai
revision: 1, revision: 1,
schemaVersion: 2, schemaVersion: 2,
serverUrl: 'https://dev.xuqinmin.com', serverUrl: 'https://dev.xuqinmin.com',
signingKey: 'secret',
}, },
{ debug: true }, { debug: true },
) )

查看文件

@ -1,28 +1,23 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from '../src/secureCache' import { openLocalCache, sealLocalCache, secureRandomBytes } from '../src/secureCache'
test('LKG cache round-trips with context-bound authenticated encryption', () => { test('secure random bytes never fall back to a weak random source', () => {
const encrypted = sealLocalCache('{"enabled":true}', 'signed-secret', 'app|package|environment') assert.equal(secureRandomBytes(32).length, 32)
assert.equal( assert.throws(() => secureRandomBytes(0), /positive integer/)
openLocalCache(encrypted, 'signed-secret', 'app|package|environment'), })
'{"enabled":true}',
) test('device-local cache round-trips with context-bound authenticated encryption', () => {
const encrypted = sealLocalCache('{"enabled":true}', 'device-key', 'bugcollect|app')
assert.equal(openLocalCache(encrypted, 'device-key', 'bugcollect|app'), '{"enabled":true}')
assert.equal(encrypted.includes('enabled'), false) assert.equal(encrypted.includes('enabled'), false)
}) })
test('LKG cache rejects tampering and a different package/environment context', () => { test('device-local cache rejects tampering and a different application context', () => {
const encrypted = sealLocalCache('payload', 'signed-secret', 'app|package|environment') const encrypted = sealLocalCache('payload', 'device-key', 'bugcollect|app')
const parts = encrypted.split('.') const parts = encrypted.split('.')
parts[2] = `${parts[2][0] === 'A' ? 'B' : 'A'}${parts[2].slice(1)}` 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(parts.join('.'), 'device-key', 'bugcollect|app'))
assert.throws(() => openLocalCache(encrypted, 'signed-secret', 'app|other|environment')) assert.throws(() => openLocalCache(encrypted, 'device-key', 'bugcollect|other'))
})
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)
}) })

查看文件

@ -160,9 +160,29 @@ const plan = await UpdateSDK.checkAndInstallPlugin('prescription', {
SDK 会验证所有已安装模块在目标 common 下仍兼容,并拒绝 `builtAgainstNativeBaselineId` 与当前完整包不一致的候选,然后整组下载、校验、staging、激活、确认或回滚。Bundle 版本与事务状态只保存在原生 state;宿主不得另建 AsyncStorage 版本表,也不得逐层传递版本或路径状态。 SDK 会验证所有已安装模块在目标 common 下仍兼容,并拒绝 `builtAgainstNativeBaselineId` 与当前完整包不一致的候选,然后整组下载、校验、staging、激活、确认或回滚。Bundle 版本与事务状态只保存在原生 state;宿主不得另建 AsyncStorage 版本表,也不得逐层传递版本或路径状态。
所有公开检查、下载和安装 API 都要求平台明确开启 `features.update`。关闭时在任何网络 整包检查返回结构化结果,不展示 UI,也不以 `null` 混淆状态:
或原生操作前抛出 `UpdateDisabledError``code: UPDATE_DISABLED`);登录、启动等自动
后台路径会吞掉该状态并正常返回,不影响宿主使用。 ```ts
const result = await UpdateSDK.checkAppUpdate()
if (result.status === 'UPDATE_AVAILABLE' && result.update) {
showUpdateDialog(result.update, result.network)
}
```
状态固定为 `UPDATE_AVAILABLE / NO_UPDATE / LOGIN_REQUIRED / SERVICE_DISABLED / FAILED`
Android 同时返回 `WIFI / CELLULAR / CELLULAR_4G / CELLULAR_5G / ETHERNET / OTHER / NONE`
及是否计费网络,宿主自行决定是否允许下载安装。检查失败、未登录或服务关闭均不阻断
宿主业务。下载、安装和插件 API 仍要求平台明确开启 `features.update`,关闭时在任何
网络或原生操作前抛出 `UpdateDisabledError``code: UPDATE_DISABLED`)。
Android 整包能力全部桥接到原生权威实现:
- `downloadApk()`:只下载和校验,可选 `destination: 'PUBLIC_DOWNLOADS'`
- `downloadAndInstallApp()`下载、SHA-256/包名/签名校验并打开系统安装器。
- `NativeAppUpdate.installDownloaded()`:安装本次检查授权的已下载版本。
- `openStore(updateInfo)`:设备商店不可用时回退租户平台返回的通用下载页。
任务取消、失败或进程结束后不会在下次启动自动续传。
release-set 检查请求必须携带当前 `appVersion`、`nativeApiLevel` 和 release-set 检查请求必须携带当前 `appVersion`、`nativeApiLevel` 和
`nativeBaselineId`;原生基线缺失时客户端在请求前阻断。服务端候选必须提供 `nativeBaselineId`;原生基线缺失时客户端在请求前阻断。服务端候选必须提供

查看文件

@ -13,9 +13,15 @@ import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.WritableMap; import com.facebook.react.bridge.WritableMap;
import com.facebook.react.module.annotations.ReactModule; import com.facebook.react.module.annotations.ReactModule;
import com.facebook.react.modules.core.DeviceEventManagerModule; import com.facebook.react.modules.core.DeviceEventManagerModule;
import com.xuqm.sdk.XuqmSDK;
import com.xuqm.sdk.XuqmUserInfo;
import com.xuqm.sdk.update.UpdateSDK; import com.xuqm.sdk.update.UpdateSDK;
import com.xuqm.sdk.update.model.ApkInstallRequest; import com.xuqm.sdk.update.model.ApkInstallRequest;
import com.xuqm.sdk.update.model.UpdateCheckResult;
import com.xuqm.sdk.update.model.UpdateDownloadCallback;
import com.xuqm.sdk.update.model.UpdateDownloadDestination;
import com.xuqm.sdk.update.model.UpdateDownloadProgress; import com.xuqm.sdk.update.model.UpdateDownloadProgress;
import com.xuqm.sdk.update.model.UpdateInfo;
import com.xuqm.sdk.update.model.UpdateInstallCallback; import com.xuqm.sdk.update.model.UpdateInstallCallback;
import com.xuqm.sdk.update.model.UpdateInstallTask; import com.xuqm.sdk.update.model.UpdateInstallTask;
import com.xuqm.sdk.update.model.UpdateProgressListener; import com.xuqm.sdk.update.model.UpdateProgressListener;
@ -39,12 +45,32 @@ public final class XuqmAppUpdateModule extends ReactContextBaseJavaModule {
return NAME; return NAME;
} }
@ReactMethod
public void syncUser(@Nullable String userId, Promise promise) {
String normalized = userId == null ? "" : userId.trim();
XuqmSDK.INSTANCE.setUserInfo(
normalized.isEmpty() ? null : new XuqmUserInfo(normalized, null, null, null, null));
promise.resolve(null);
}
@ReactMethod
public void checkAppUpdate(@Nullable String userId, boolean bypassIgnore, Promise promise) {
String normalized = userId == null ? "" : userId.trim();
XuqmSDK.INSTANCE.setUserInfo(
normalized.isEmpty() ? null : new XuqmUserInfo(normalized, null, null, null, null));
UpdateSDK.startCheckAppUpdate(
getReactApplicationContext(),
bypassIgnore,
result -> promise.resolve(toCheckResultMap(result)));
}
@ReactMethod @ReactMethod
public synchronized void downloadAndInstall( public synchronized void downloadAndInstall(
String downloadUrl, String downloadUrl,
int versionCode, int versionCode,
@Nullable String sha256, @Nullable String sha256,
int retryCount, int retryCount,
String destination,
Promise promise) { Promise promise) {
if (activeTask != null && activeTask.isActive()) { if (activeTask != null && activeTask.isActive()) {
promise.reject("DOWNLOAD_FAILED", "An APK download is already in progress"); promise.reject("DOWNLOAD_FAILED", "An APK download is already in progress");
@ -59,7 +85,8 @@ public final class XuqmAppUpdateModule extends ReactContextBaseJavaModule {
downloadUrl, downloadUrl,
versionCode, versionCode,
sha256, sha256,
Math.max(0, retryCount)); Math.max(0, retryCount),
parseDestination(destination));
activeTask = UpdateSDK.startDownloadAndInstall( activeTask = UpdateSDK.startDownloadAndInstall(
getReactApplicationContext(), getReactApplicationContext(),
request, request,
@ -69,11 +96,44 @@ public final class XuqmAppUpdateModule extends ReactContextBaseJavaModule {
emitProgress(progress); emitProgress(progress);
} }
}, },
new UpdateInstallCallback() { installCallback(promise));
}
@ReactMethod
public synchronized void download(
String downloadUrl,
int versionCode,
@Nullable String sha256,
int retryCount,
String destination,
Promise promise) {
if (activeTask != null && activeTask.isActive()) {
promise.reject("DOWNLOAD_FAILED", "An APK download is already in progress");
return;
}
if (sha256 == null || sha256.trim().isEmpty()) {
promise.reject("HASH_MISMATCH", "APK SHA-256 is required");
return;
}
ApkInstallRequest request = new ApkInstallRequest(
downloadUrl,
versionCode,
sha256,
Math.max(0, retryCount),
parseDestination(destination));
activeTask = UpdateSDK.startDownload(
getReactApplicationContext(),
request,
this::emitProgress,
new UpdateDownloadCallback() {
@Override @Override
public void onSuccess() { public void onSuccess(@NonNull String localPath, @Nullable String publicUri) {
clearActiveTask(); clearActiveTask();
promise.resolve(null); WritableMap result = Arguments.createMap();
result.putString("localPath", localPath);
if (publicUri == null) result.putNull("publicUri");
else result.putString("publicUri", publicUri);
promise.resolve(result);
} }
@Override @Override
@ -84,6 +144,37 @@ public final class XuqmAppUpdateModule extends ReactContextBaseJavaModule {
}); });
} }
@ReactMethod
public synchronized void installDownloaded(
int versionCode,
@Nullable String sha256,
Promise promise) {
if (activeTask != null && activeTask.isActive()) {
promise.reject("INSTALL_FAILED", "Another update task is already in progress");
return;
}
if (sha256 == null || sha256.trim().isEmpty()) {
promise.reject("HASH_MISMATCH", "APK SHA-256 is required");
return;
}
activeTask = UpdateSDK.startInstallDownloaded(
getReactApplicationContext(),
versionCode,
sha256,
installCallback(promise));
}
@ReactMethod
public void openStore(
@Nullable String marketUrl,
@Nullable String appStoreUrl,
Promise promise) {
promise.resolve(UpdateSDK.openStore(
getReactApplicationContext(),
marketUrl,
appStoreUrl));
}
@ReactMethod @ReactMethod
public synchronized void cancelDownload(Promise promise) { public synchronized void cancelDownload(Promise promise) {
UpdateInstallTask task = activeTask; UpdateInstallTask task = activeTask;
@ -131,6 +222,60 @@ public final class XuqmAppUpdateModule extends ReactContextBaseJavaModule {
activeTask = null; activeTask = null;
} }
private UpdateInstallCallback installCallback(Promise promise) {
return new UpdateInstallCallback() {
@Override
public void onSuccess() {
clearActiveTask();
promise.resolve(null);
}
@Override
public void onError(@NonNull String code, @NonNull String message) {
clearActiveTask();
promise.reject(code, message);
}
};
}
private UpdateDownloadDestination parseDestination(String destination) {
return "PUBLIC_DOWNLOADS".equals(destination)
? UpdateDownloadDestination.PUBLIC_DOWNLOADS
: UpdateDownloadDestination.SDK_PRIVATE;
}
private WritableMap toCheckResultMap(UpdateCheckResult result) {
WritableMap payload = Arguments.createMap();
payload.putString("status", result.getStatus().name());
payload.putString("networkType", result.getNetwork().getType().name());
payload.putBoolean("isConnected", result.getNetwork().isConnected());
payload.putBoolean("isMetered", result.getNetwork().isMetered());
if (result.getErrorCode() == null) payload.putNull("errorCode");
else payload.putString("errorCode", result.getErrorCode());
if (result.getMessage() == null) payload.putNull("message");
else payload.putString("message", result.getMessage());
UpdateInfo update = result.getUpdate();
if (update == null) {
payload.putNull("update");
} else {
WritableMap info = Arguments.createMap();
info.putBoolean("needsUpdate", update.getNeedsUpdate());
info.putString("versionName", update.getVersionName());
info.putInt("versionCode", update.getVersionCode());
info.putString("downloadUrl", update.getDownloadUrl());
info.putString("changeLog", update.getChangeLog());
info.putBoolean("forceUpdate", update.getForceUpdate());
info.putString("appStoreUrl", update.getAppStoreUrl());
info.putString("marketUrl", update.getMarketUrl());
info.putBoolean("requiresLogin", update.getRequiresLogin());
info.putBoolean("alreadyDownloaded", update.getAlreadyDownloaded());
if (update.getApkHash() == null) info.putNull("sha256");
else info.putString("sha256", update.getApkHash());
payload.putMap("update", info);
}
return payload;
}
private void emitProgress(UpdateDownloadProgress progress) { private void emitProgress(UpdateDownloadProgress progress) {
WritableMap payload = Arguments.createMap(); WritableMap payload = Arguments.createMap();
payload.putDouble("bytesDownloaded", progress.getBytesDownloaded()); payload.putDouble("bytesDownloaded", progress.getBytesDownloaded());

查看文件

@ -1,10 +1,13 @@
import { NativeEventEmitter, NativeModules, Platform } from 'react-native' import { NativeEventEmitter, NativeModules, Platform } from 'react-native'
import type { UpdateDownloadProgress } from './UpdateSDK' import type { AppUpdateInfo, UpdateDownloadProgress } from './UpdateSDK'
export type AppInstallErrorCode = export type AppInstallErrorCode =
| 'DOWNLOAD_FAILED' | 'DOWNLOAD_FAILED'
| 'DOWNLOAD_CANCELLED' | 'DOWNLOAD_CANCELLED'
| 'HASH_MISMATCH' | 'HASH_MISMATCH'
| 'PACKAGE_MISMATCH'
| 'SIGNATURE_MISMATCH'
| 'AUTHORIZATION_EXPIRED'
| 'INSTALL_PERMISSION_REQUIRED' | 'INSTALL_PERMISSION_REQUIRED'
| 'INSTALLER_UNAVAILABLE' | 'INSTALLER_UNAVAILABLE'
| 'ANDROID_SDK_MISSING' | 'ANDROID_SDK_MISSING'
@ -23,12 +26,24 @@ export class AppInstallError extends Error {
} }
interface XuqmAppUpdateModuleInterface { interface XuqmAppUpdateModuleInterface {
syncUser(userId: string | null): Promise<void>
checkAppUpdate(userId: string | null, bypassIgnore: boolean): Promise<NativeUpdateCheckResult>
download(
downloadUrl: string,
versionCode: number,
sha256: string | null,
retryCount: number,
destination: UpdateDownloadDestination,
): Promise<DownloadedAppUpdate>
downloadAndInstall( downloadAndInstall(
downloadUrl: string, downloadUrl: string,
versionCode: number, versionCode: number,
sha256: string | null, sha256: string | null,
retryCount: number, retryCount: number,
destination: UpdateDownloadDestination,
): Promise<void> ): Promise<void>
installDownloaded(versionCode: number, sha256: string | null): Promise<void>
openStore(marketUrl: string | null, appStoreUrl: string | null): Promise<boolean>
cancelDownload(): Promise<void> cancelDownload(): Promise<void>
openInstallPermissionSettings(): Promise<boolean> openInstallPermissionSettings(): Promise<boolean>
} }
@ -52,6 +67,9 @@ function asInstallError(error: unknown): AppInstallError {
'DOWNLOAD_FAILED', 'DOWNLOAD_FAILED',
'DOWNLOAD_CANCELLED', 'DOWNLOAD_CANCELLED',
'HASH_MISMATCH', 'HASH_MISMATCH',
'PACKAGE_MISMATCH',
'SIGNATURE_MISMATCH',
'AUTHORIZATION_EXPIRED',
'INSTALL_PERMISSION_REQUIRED', 'INSTALL_PERMISSION_REQUIRED',
'INSTALLER_UNAVAILABLE', 'INSTALLER_UNAVAILABLE',
'ANDROID_SDK_MISSING', 'ANDROID_SDK_MISSING',
@ -64,12 +82,86 @@ function asInstallError(error: unknown): AppInstallError {
) )
} }
export type UpdateDownloadDestination = 'SDK_PRIVATE' | 'PUBLIC_DOWNLOADS'
export interface DownloadedAppUpdate {
localPath: string
publicUri: string | null
}
export type NativeUpdateCheckStatus =
'UPDATE_AVAILABLE' | 'NO_UPDATE' | 'LOGIN_REQUIRED' | 'SERVICE_DISABLED' | 'FAILED'
export type NativeNetworkType =
'NONE' | 'WIFI' | 'CELLULAR' | 'CELLULAR_4G' | 'CELLULAR_5G' | 'ETHERNET' | 'OTHER'
export interface NativeUpdateCheckResult {
status: NativeUpdateCheckStatus
networkType: NativeNetworkType
isConnected: boolean
isMetered: boolean
update: AppUpdateInfo | null
errorCode: string | null
message: string | null
}
export const NativeAppUpdate = { export const NativeAppUpdate = {
syncUser(userId: string | null): Promise<void> {
if (Platform.OS !== 'android') return Promise.resolve()
return getModule().syncUser(userId)
},
check(userId: string | null, bypassIgnore = false): Promise<NativeUpdateCheckResult> {
if (Platform.OS !== 'android') {
throw new AppInstallError('INSTALLER_UNAVAILABLE', 'Native app update is Android-only')
}
return getModule().checkAppUpdate(userId, bypassIgnore)
},
async download(
input: { downloadUrl: string; versionCode: number; sha256?: string },
options: {
signal?: AbortSignal
retryCount?: number
destination?: UpdateDownloadDestination
onProgress?: (progress: UpdateDownloadProgress) => void
} = {},
): Promise<DownloadedAppUpdate> {
if (Platform.OS !== 'android') {
throw new AppInstallError('INSTALLER_UNAVAILABLE', 'App download is Android-only')
}
const module = getModule()
if (!input.sha256) throw new AppInstallError('HASH_MISMATCH', 'APK SHA-256 is required')
const emitter = new NativeEventEmitter(NativeModules.XuqmAppUpdateModule)
const subscription = emitter.addListener(
'XuqmAppUpdateProgress',
(progress: UpdateDownloadProgress) => options.onProgress?.(progress),
)
const cancel = () => void module.cancelDownload()
options.signal?.addEventListener('abort', cancel, { once: true })
try {
if (options.signal?.aborted) throw new AppInstallError('DOWNLOAD_CANCELLED', 'Cancelled')
return await module.download(
input.downloadUrl,
input.versionCode,
input.sha256,
Math.max(0, Math.trunc(options.retryCount ?? 1)),
options.destination ?? 'SDK_PRIVATE',
)
} catch (error) {
throw asInstallError(error)
} finally {
subscription.remove()
options.signal?.removeEventListener('abort', cancel)
}
},
async downloadAndInstall( async downloadAndInstall(
input: { downloadUrl: string; versionCode: number; sha256?: string }, input: { downloadUrl: string; versionCode: number; sha256?: string },
options: { options: {
signal?: AbortSignal signal?: AbortSignal
retryCount?: number retryCount?: number
destination?: UpdateDownloadDestination
onProgress?: (progress: UpdateDownloadProgress) => void onProgress?: (progress: UpdateDownloadProgress) => void
} = {}, } = {},
): Promise<void> { ): Promise<void> {
@ -94,6 +186,7 @@ export const NativeAppUpdate = {
input.versionCode, input.versionCode,
input.sha256 ?? null, input.sha256 ?? null,
Math.max(0, Math.trunc(options.retryCount ?? 1)), Math.max(0, Math.trunc(options.retryCount ?? 1)),
options.destination ?? 'SDK_PRIVATE',
) )
} catch (error) { } catch (error) {
throw asInstallError(error) throw asInstallError(error)
@ -103,6 +196,23 @@ export const NativeAppUpdate = {
} }
}, },
async installDownloaded(input: { versionCode: number; sha256?: string }): Promise<void> {
if (Platform.OS !== 'android') {
throw new AppInstallError('INSTALLER_UNAVAILABLE', 'App installation is Android-only')
}
if (!input.sha256) throw new AppInstallError('HASH_MISMATCH', 'APK SHA-256 is required')
try {
await getModule().installDownloaded(input.versionCode, input.sha256)
} catch (error) {
throw asInstallError(error)
}
},
openStore(input: { marketUrl?: string; appStoreUrl?: string }): Promise<boolean> {
if (Platform.OS !== 'android') return Promise.resolve(false)
return getModule().openStore(input.marketUrl ?? null, input.appStoreUrl ?? null)
},
async cancel(): Promise<void> { async cancel(): Promise<void> {
await getModule().cancelDownload() await getModule().cancelDownload()
}, },

查看文件

@ -50,7 +50,23 @@ export interface AppUpdateInfo {
requiresLogin?: boolean requiresLogin?: boolean
alreadyDownloaded?: boolean alreadyDownloaded?: boolean
sha256?: string sha256?: string
skippedReason?: 'LOGIN_REQUIRED' }
export type AppUpdateCheckStatus =
'UPDATE_AVAILABLE' | 'NO_UPDATE' | 'LOGIN_REQUIRED' | 'SERVICE_DISABLED' | 'FAILED'
export interface AppUpdateNetwork {
type: 'NONE' | 'WIFI' | 'CELLULAR' | 'CELLULAR_4G' | 'CELLULAR_5G' | 'ETHERNET' | 'OTHER'
isConnected: boolean
isMetered: boolean
}
export interface AppUpdateCheckResult {
status: AppUpdateCheckStatus
network: AppUpdateNetwork | null
update: AppUpdateInfo | null
errorCode: string | null
message: string | null
} }
export type UpdateDownloadProgress = DownloadProgress export type UpdateDownloadProgress = DownloadProgress
@ -378,11 +394,59 @@ export const UpdateSDK = {
return [...pluginRegistry.values()].map(plugin => ({ ...plugin })) return [...pluginRegistry.values()].map(plugin => ({ ...plugin }))
}, },
async checkAppUpdate(bypassIgnore = false): Promise<AppUpdateInfo> { async checkAppUpdate(bypassIgnore = false): Promise<AppUpdateCheckResult> {
try {
await awaitInitialization() await awaitInitialization()
requireUpdateEnabled() } catch (error) {
return {
status: 'FAILED',
network: null,
update: null,
errorCode: 'SDK_NOT_READY',
message: error instanceof Error ? error.message : 'SDK initialization failed',
}
}
if (Platform.OS === 'android') {
try {
const result = await NativeAppUpdate.check(getUserId(), bypassIgnore)
return {
status: result.status,
network: {
type: result.networkType,
isConnected: result.isConnected,
isMetered: result.isMetered,
},
update: result.update,
errorCode: result.errorCode,
message: result.message,
}
} catch (error) {
return {
status: 'FAILED',
network: null,
update: null,
errorCode: 'ANDROID_SDK_UNAVAILABLE',
message: error instanceof Error ? error.message : 'Android update SDK unavailable',
}
}
}
if (!getConfig().updateEnabled) {
return {
status: 'SERVICE_DISABLED',
network: null,
update: null,
errorCode: null,
message: 'Update service is disabled',
}
}
if (isLoginRequiredWithoutSession()) { if (isLoginRequiredWithoutSession()) {
return { needsUpdate: false, requiresLogin: true, skippedReason: 'LOGIN_REQUIRED' } return {
status: 'LOGIN_REQUIRED',
network: null,
update: null,
errorCode: null,
message: 'Login is required before checking updates',
}
} }
const applyIgnore = async (info: AppUpdateInfo): Promise<AppUpdateInfo> => { const applyIgnore = async (info: AppUpdateInfo): Promise<AppUpdateInfo> => {
if (bypassIgnore || !info.needsUpdate || info.forceUpdate) return info if (bypassIgnore || !info.needsUpdate || info.forceUpdate) return info
@ -393,14 +457,23 @@ export const UpdateSDK = {
} }
if (!bypassIgnore) { if (!bypassIgnore) {
const cached = await readUpdateCache<AppUpdateInfo>(sessionCacheKey(UPDATE_APP_CACHE_KEY)) const cached = await readUpdateCache<AppUpdateInfo>(sessionCacheKey(UPDATE_APP_CACHE_KEY))
if (cached) return applyIgnore(cached) if (cached) {
const update = await applyIgnore(cached)
return {
status: update.needsUpdate ? 'UPDATE_AVAILABLE' : 'NO_UPDATE',
network: null,
update,
errorCode: null,
message: null,
}
}
} }
const config = getConfig() const config = getConfig()
const device = await getDeviceInfo() const device = await getDeviceInfo()
const params: Record<string, string> = { const params: Record<string, string> = {
appKey: config.appKey, appKey: config.appKey,
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS', platform: 'IOS',
currentVersionCode: String(getAppVersionCode()), currentVersionCode: String(getAppVersionCode()),
currentVersionName: getAppVersionName() ?? '', currentVersionName: getAppVersionName() ?? '',
deviceId: device.deviceId, deviceId: device.deviceId,
@ -412,6 +485,7 @@ export const UpdateSDK = {
if (userId) params.userId = userId if (userId) params.userId = userId
if (bypassIgnore) params.bypassIgnore = 'true' if (bypassIgnore) params.bypassIgnore = 'true'
try {
const raw = await apiRequest<RawAppUpdateInfo>('/api/v1/updates/app/check', { const raw = await apiRequest<RawAppUpdateInfo>('/api/v1/updates/app/check', {
skipAuth: true, skipAuth: true,
params, params,
@ -423,7 +497,23 @@ export const UpdateSDK = {
sha256: raw.sha256 ?? raw.apkHash ?? undefined, sha256: raw.sha256 ?? raw.apkHash ?? undefined,
} }
await writeUpdateCache(sessionCacheKey(UPDATE_APP_CACHE_KEY), normalized) await writeUpdateCache(sessionCacheKey(UPDATE_APP_CACHE_KEY), normalized)
return applyIgnore(normalized) const update = await applyIgnore(normalized)
return {
status: update.needsUpdate ? 'UPDATE_AVAILABLE' : 'NO_UPDATE',
network: null,
update,
errorCode: null,
message: null,
}
} catch (error) {
return {
status: 'FAILED',
network: null,
update: null,
errorCode: 'UPDATE_CHECK_FAILED',
message: error instanceof Error ? error.message : 'Update check failed',
}
}
}, },
async ignoreAppVersion(versionCode: number): Promise<void> { async ignoreAppVersion(versionCode: number): Promise<void> {
@ -440,24 +530,38 @@ export const UpdateSDK = {
return getIgnoredVersionCode() return getIgnoredVersionCode()
}, },
async openStore(appStoreUrl?: string, marketUrl?: string): Promise<void> { async openStore(updateInfo: AppUpdateInfo): Promise<boolean> {
const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl if (Platform.OS === 'android') {
if (url) await Linking.openURL(url) return NativeAppUpdate.openStore(updateInfo)
}
const url = Platform.OS === 'ios' ? updateInfo.appStoreUrl : undefined
if (!url) return false
await Linking.openURL(url)
return true
}, },
async downloadApk( async downloadApk(
updateInfo: AppUpdateInfo, updateInfo: AppUpdateInfo,
options: { signal?: AbortSignal; onProgress?: (progress: UpdateDownloadProgress) => void } = {}, options: {
): Promise<ArrayBuffer> { signal?: AbortSignal
retryCount?: number
destination?: import('./NativeAppUpdate').UpdateDownloadDestination
onProgress?: (progress: UpdateDownloadProgress) => void
} = {},
): Promise<import('./NativeAppUpdate').DownloadedAppUpdate> {
await awaitInitialization() await awaitInitialization()
requireUpdateEnabled() requireUpdateEnabled()
if (!updateInfo.downloadUrl) throw new Error('[UpdateSDK] App update has no downloadUrl') if (!updateInfo.downloadUrl || !updateInfo.versionCode) {
const bytes = await downloadBytes(updateInfo.downloadUrl, { throw new Error('[UpdateSDK] App update requires downloadUrl and versionCode')
...options, }
signal: currentSessionSignal(options.signal), return NativeAppUpdate.download(
}) {
if (updateInfo.sha256) assertSha256(bytes, updateInfo.sha256, 'APK') downloadUrl: updateInfo.downloadUrl,
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer versionCode: updateInfo.versionCode,
sha256: updateInfo.sha256,
},
{ ...options, signal: currentSessionSignal(options.signal) },
)
}, },
async downloadAndInstallApp( async downloadAndInstallApp(
@ -465,6 +569,7 @@ export const UpdateSDK = {
options: { options: {
signal?: AbortSignal signal?: AbortSignal
retryCount?: number retryCount?: number
destination?: import('./NativeAppUpdate').UpdateDownloadDestination
onProgress?: (progress: UpdateDownloadProgress) => void onProgress?: (progress: UpdateDownloadProgress) => void
} = {}, } = {},
): Promise<void> { ): Promise<void> {
@ -660,8 +765,11 @@ export const UpdateSDK = {
/** Pure check: full package has priority and no download or activation happens here. */ /** Pure check: full package has priority and no download or activation happens here. */
async checkStartupUpdate(): Promise<StartupUpdateResult> { async checkStartupUpdate(): Promise<StartupUpdateResult> {
const appUpdate = await UpdateSDK.checkAppUpdate() const appCheck = await UpdateSDK.checkAppUpdate()
if (appUpdate.needsUpdate) return { kind: 'app', update: appUpdate } if (appCheck.status === 'UPDATE_AVAILABLE' && appCheck.update) {
return { kind: 'app', update: appCheck.update }
}
if (appCheck.status !== 'NO_UPDATE') return { kind: 'none' }
const app = UpdateSDK.getRegisteredPlugins().find(plugin => plugin.type === 'app') const app = UpdateSDK.getRegisteredPlugins().find(plugin => plugin.type === 'app')
if (!app) throw new Error('[UpdateSDK] Exactly one app module must be registered') if (!app) throw new Error('[UpdateSDK] Exactly one app module must be registered')
const plan = await UpdateSDK.checkPluginRelease(app.moduleId) const plan = await UpdateSDK.checkPluginRelease(app.moduleId)
@ -741,10 +849,11 @@ export const UpdateSDK = {
}, },
/** @internal 共享登录态变化时取消旧灰度授权,并为新用户自动补检一次。 */ /** @internal 共享登录态变化时取消旧灰度授权,并为新用户自动补检一次。 */
async _handleSessionChange(loggedIn: boolean): Promise<void> { async _handleSessionChange(userId: string | null): Promise<void> {
await NativeAppUpdate.syncUser(userId)
await clearSessionUpdateState() await clearSessionUpdateState()
if ( if (
!loggedIn || !userId ||
!getConfig().updateEnabled || !getConfig().updateEnabled ||
!getConfig().updateRequiresLogin || !getConfig().updateRequiresLogin ||
getConfig().debug getConfig().debug

查看文件

@ -6,7 +6,7 @@ import {
import { UpdateSDK } from './UpdateSDK' import { UpdateSDK } from './UpdateSDK'
import { resolveCurrentBundleIdentity } from './BundleIdentity' import { resolveCurrentBundleIdentity } from './BundleIdentity'
_registerUserInfoHandler('rn-update', info => UpdateSDK._handleSessionChange(Boolean(info))) _registerUserInfoHandler('rn-update', info => UpdateSDK._handleSessionChange(info?.userId ?? null))
_registerInitializationHandler('rn-update-reset', () => UpdateSDK._reconcileNativeReset()) _registerInitializationHandler('rn-update-reset', () => UpdateSDK._reconcileNativeReset())
_registerBundleIdentityProvider('rn-update', resolveCurrentBundleIdentity) _registerBundleIdentityProvider('rn-update', resolveCurrentBundleIdentity)
@ -14,13 +14,20 @@ export { StaleReleaseSetError, UpdateDisabledError, UpdateSDK } from './UpdateSD
export type { export type {
PluginRegistration, PluginRegistration,
AppUpdateInfo, AppUpdateInfo,
AppUpdateCheckResult,
AppUpdateCheckStatus,
AppUpdateNetwork,
StartupUpdateResult, StartupUpdateResult,
UpdateDownloadProgress, UpdateDownloadProgress,
} from './UpdateSDK' } from './UpdateSDK'
export { NativeBundle } from './NativeBundle' export { NativeBundle } from './NativeBundle'
export { refreshPluginAssetResolver } from './PluginAssets' export { refreshPluginAssetResolver } from './PluginAssets'
export { AppInstallError, NativeAppUpdate } from './NativeAppUpdate' export { AppInstallError, NativeAppUpdate } from './NativeAppUpdate'
export type { AppInstallErrorCode } from './NativeAppUpdate' export type {
AppInstallErrorCode,
DownloadedAppUpdate,
UpdateDownloadDestination,
} from './NativeAppUpdate'
export type { BundleModuleState, NativeReleaseModule, PendingReleaseState } from './NativeBundle' export type { BundleModuleState, NativeReleaseModule, PendingReleaseState } from './NativeBundle'
export { planReleaseSet, ReleaseSetCompatibilityError } from './releaseSet' export { planReleaseSet, ReleaseSetCompatibilityError } from './releaseSet'
export type { export type {

查看文件

@ -75,18 +75,17 @@ Host;Host 卸载会以 `host_unmounted` 关闭其全部页面。
## 页面栈与系统返回 ## 页面栈与系统返回
每次 `openWebView` 都生成独立页面和 Handle。页面栈只显示顶层页面,关闭后恢复下一层 每次 `openWebView` 都生成独立页面和 Handle。页面栈只显示顶层页面,关闭后恢复下一层
页面及其导航状态。默认导航栏的左返回按钮只在存在网页历史时显示,且只执行网页后退; 页面及其导航状态。默认导航栏的左返回按钮常驻:存在网页历史时只执行网页后退,没有
右侧关闭按钮用于明确关闭当前页。 历史时需要在 1 秒内再次点击才关闭当前页,第一次点击显示非阻塞提示。Android 物理
返回使用完全相同的规则。右侧关闭按钮常驻并立即关闭当前页。
Android 系统返回先执行网页历史返回,没有历史时默认关闭当前页。需要防止误退出的宿主 宿主可以替换第一次返回时的提示;只有明确需要改变默认交互的宿主才应覆盖关闭模式或
可以启用双击关闭,并自行决定首次按键的提示样式 间隔
```tsx ```tsx
const page = openWebView({ const page = openWebView({
behavior: { behavior: {
hardwareBack: { hardwareBack: {
closeMode: 'doublePress',
doublePressIntervalMs: 1_000,
onFirstClosePress: () => showToast('再按一次退出当前页面'), onFirstClosePress: () => showToast('再按一次退出当前页面'),
}, },
}, },
@ -98,7 +97,8 @@ const page = openWebView({
`page.resume()`;暂停不等于关闭,不触发 `closed`,也不创建第二份 WebView。 `page.resume()`;暂停不等于关闭,不触发 `closed`,也不创建第二份 WebView。
导航标题和显隐修改属于当前页面,不能污染下层页面。H5 隐藏原生导航栏但保留状态栏 导航标题和显隐修改属于当前页面,不能污染下层页面。H5 隐藏原生导航栏但保留状态栏
时,Bridge 可以从页面上下文取得真实安全区高度。 时,Bridge 可以从页面上下文取得真实安全区高度。设置网络导航栏背景图片时,配置接收
后立即返回成功,图片由原生视图异步加载,不以图片探测或下载结果阻塞 H5 调用。
## 权限 ## 权限
@ -110,7 +110,22 @@ SDK 不向 Manifest/Info.plist 注入敏感权限。宿主按启用能力声明
``` ```
H5 调用 `getUserMedia` 时,XWebView 先检查页面配置和宿主决策,再在 Android 请求系统 H5 调用 `getUserMedia` 时,XWebView 先检查页面配置和宿主决策,再在 Android 请求系统
权限,最后才继续 WebView 媒体请求。未识别资源和 3 秒内未完成的自定义决策均拒绝。 权限,最后才继续 WebView 媒体请求。摄像头或麦克风必须同时启用能力并精确配置
`allowedOrigins`;当前文档跨域后不会沿用上一个 origin 的授权。未识别资源、未命中
白名单和 3 秒内未完成的自定义决策均拒绝:
```tsx
permissions: {
allowedOrigins: ['https://example.com'],
camera: true,
microphone: true,
}
```
默认菜单包含刷新、复制链接和在系统浏览器中打开,不包含关闭页面;关闭始终使用右上角
常驻按钮。宿主可通过 `navigationBar.menu.items` 替换菜单,或通过
`navigationBar.render` 自定义返回、关闭和菜单的展示与点击行为。自定义导航栏若希望
保留 SDK 的统一返回规则,应调用渲染上下文的 `onBackPress()`
## 下载与安全默认值 ## 下载与安全默认值
@ -141,4 +156,5 @@ common 内的 WebView Bridge 已删除,不提供兼容壳。
```bash ```bash
pnpm --dir packages/xwebview typecheck pnpm --dir packages/xwebview typecheck
pnpm --dir packages/xwebview test pnpm --dir packages/xwebview test
pnpm --dir packages/xwebview pack:check
``` ```

查看文件

@ -14,6 +14,7 @@
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/" "registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
}, },
"scripts": { "scripts": {
"pack:check": "corepack pnpm pack --dry-run",
"test": "node --import tsx --test tests/*.test.ts", "test": "node --import tsx --test tests/*.test.ts",
"typecheck": "tsc --noEmit -p tsconfig.json" "typecheck": "tsc --noEmit -p tsconfig.json"
}, },

查看文件

@ -6,7 +6,16 @@ import React, {
useState, useState,
useSyncExternalStore, useSyncExternalStore,
} from 'react' } from 'react'
import { ImageBackground, Modal, Pressable, StatusBar, StyleSheet, Text, View } from 'react-native' import {
ImageBackground,
Linking,
Modal,
Pressable,
StatusBar,
StyleSheet,
Text,
View,
} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg' import Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context' import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'
@ -17,6 +26,11 @@ import {
subscribeXWebViewRuntime, subscribeXWebViewRuntime,
type XWebViewPage, type XWebViewPage,
} from './runtime' } from './runtime'
import {
DEFAULT_BACK_CLOSE_INTERVAL_MS,
decideBackPress,
type PendingBackClose,
} from './backPolicy'
import type { import type {
XWebViewMenuItem, XWebViewMenuItem,
XWebViewNavigationBackground, XWebViewNavigationBackground,
@ -149,13 +163,22 @@ function DefaultMenu({
) )
} }
function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: number }) { function NavigationBar({
onBackPress,
page,
topInset,
}: {
onBackPress(): void
page: XWebViewPage
topInset: number
}) {
const [menuVisible, setMenuVisible] = useState(false) const [menuVisible, setMenuVisible] = useState(false)
const { config, handle, navigationState, presentation } = page const { config, handle, navigationState, presentation } = page
const navigationBar = config.navigationBar ?? {} const navigationBar = config.navigationBar ?? {}
const backButton = { const backButton = {
color: navigationBar.backButton?.color ?? presentation.titleColor, color: navigationBar.backButton?.color ?? presentation.titleColor,
visible: navigationBar.backButton?.visible ?? navigationState.canGoBack, // 默认只在网页确有上级历史时展示;右侧关闭按钮始终承担关闭当前容器的职责。
visible: navigationState.canGoBack && (navigationBar.backButton?.visible ?? true),
} }
const closeButton = { const closeButton = {
color: navigationBar.closeButton?.color ?? presentation.titleColor, color: navigationBar.closeButton?.color ?? presentation.titleColor,
@ -182,6 +205,19 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe
label: '复制链接', label: '复制链接',
onPress: () => Clipboard.setString(navigationState.url), onPress: () => Clipboard.setString(navigationState.url),
}, },
{
id: 'open-external',
label: '在浏览器中打开',
onPress: async () => {
try {
if (navigationState.url && (await Linking.canOpenURL(navigationState.url))) {
await Linking.openURL(navigationState.url)
}
} catch {
// 系统没有可用浏览器时保持当前 WebView,不让菜单操作影响页面使用。
}
},
},
], ],
[handle, navigationBar.menu?.items, navigationState.url], [handle, navigationBar.menu?.items, navigationState.url],
) )
@ -190,6 +226,7 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe
background: presentation.background, background: presentation.background,
backButton, backButton,
closeButton, closeButton,
onBackPress,
title: { title: {
color: presentation.titleColor, color: presentation.titleColor,
mode: navigationBar.title?.mode, mode: navigationBar.title?.mode,
@ -207,11 +244,7 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe
<View style={styles.navigationRow}> <View style={styles.navigationRow}>
<View style={styles.navigationSide}> <View style={styles.navigationSide}>
{backButton.visible ? ( {backButton.visible ? (
<Pressable <Pressable hitSlop={HIT_SLOP} onPress={onBackPress} style={styles.navigationButton}>
hitSlop={HIT_SLOP}
onPress={() => handle.goBack()}
style={styles.navigationButton}
>
<IconBack color={backButton.color} size={24} /> <IconBack color={backButton.color} size={24} />
</Pressable> </Pressable>
) : null} ) : null}
@ -258,7 +291,15 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe
) )
} }
function HostPage({ active, page }: { active: boolean; page: XWebViewPage }) { function HostPage({
active,
onBackPress,
page,
}: {
active: boolean
onBackPress(): void
page: XWebViewPage
}) {
const insets = useSafeAreaInsets() const insets = useSafeAreaInsets()
const { config, presentation } = page const { config, presentation } = page
const configuredStyle = config.statusBar?.contentStyle ?? 'auto' const configuredStyle = config.statusBar?.contentStyle ?? 'auto'
@ -284,7 +325,11 @@ function HostPage({ active, page }: { active: boolean; page: XWebViewPage }) {
/> />
) : null} ) : null}
{presentation.navigationBarVisible ? ( {presentation.navigationBarVisible ? (
<NavigationBar page={page} topInset={presentation.statusBarVisible ? insets.top : 0} /> <NavigationBar
onBackPress={onBackPress}
page={page}
topInset={presentation.statusBarVisible ? insets.top : 0}
/>
) : null} ) : null}
<XWebViewView active={active} config={config} pageId={page.id} statusBarHeight={insets.top} /> <XWebViewView active={active} config={config} pageId={page.id} statusBarHeight={insets.top} />
</View> </View>
@ -302,38 +347,88 @@ export function XWebViewHost() {
) )
useEffect(() => mountXWebViewHost(), []) useEffect(() => mountXWebViewHost(), [])
const pendingHardwareClose = useRef<{ at: number; pageId: string } | null>(null) const pendingBackClose = useRef<PendingBackClose | null>(null)
const hintTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
const [showBackHint, setShowBackHint] = useState(false)
const handleRequestClose = useCallback(() => { useEffect(
const page = [...snapshot.pages].reverse().find(item => !item.suspended) () => () => {
if (!page) return if (hintTimer.current) clearTimeout(hintTimer.current)
if (page.navigationState.canGoBack) { },
pendingHardwareClose.current = null [],
page.handle.goBack() )
const hideBackHint = useCallback(() => {
if (hintTimer.current) clearTimeout(hintTimer.current)
hintTimer.current = null
setShowBackHint(false)
}, [])
const showFirstBackHint = useCallback((page: XWebViewPage, intervalMs: number) => {
const customHint = page.config.behavior?.hardwareBack?.onFirstClosePress
if (customHint) {
customHint()
return return
} }
if (hintTimer.current) clearTimeout(hintTimer.current)
setShowBackHint(true)
hintTimer.current = setTimeout(() => {
hintTimer.current = null
setShowBackHint(false)
}, intervalMs)
}, [])
const handlePageBack = useCallback(
(page: XWebViewPage) => {
const hardwareBack = page.config.behavior?.hardwareBack const hardwareBack = page.config.behavior?.hardwareBack
if (hardwareBack?.closeMode !== 'doublePress') { if (!page.navigationState.canGoBack && hardwareBack?.closeMode === 'immediate') {
pendingHardwareClose.current = null pendingBackClose.current = null
hideBackHint()
page.handle.close('back') page.handle.close('back')
return return
} }
const now = Date.now() const now = Date.now()
const interval = Math.max(250, hardwareBack.doublePressIntervalMs ?? 1_000) const interval = Math.max(
const pending = pendingHardwareClose.current 250,
if (pending?.pageId === page.id && now - pending.at <= interval) { hardwareBack?.doublePressIntervalMs ?? DEFAULT_BACK_CLOSE_INTERVAL_MS,
pendingHardwareClose.current = null )
const decision = decideBackPress(
page.id,
page.navigationState.canGoBack,
now,
pendingBackClose.current,
interval,
)
if (decision === 'goBack') {
pendingBackClose.current = null
hideBackHint()
page.handle.goBack()
return
}
if (decision === 'close') {
pendingBackClose.current = null
hideBackHint()
page.handle.close('back') page.handle.close('back')
return return
} }
pendingHardwareClose.current = { at: now, pageId: page.id } pendingBackClose.current = { at: now, pageId: page.id }
hardwareBack.onFirstClosePress?.() showFirstBackHint(page, interval)
}, [snapshot.pages]) },
[hideBackHint, showFirstBackHint],
)
const handleRequestClose = useCallback(() => {
const page = [...snapshot.pages].reverse().find(item => !item.suspended)
if (page) handlePageBack(page)
}, [handlePageBack, snapshot.pages])
const visiblePages = snapshot.pages.filter(page => !page.suspended) const visiblePages = snapshot.pages.filter(page => !page.suspended)
const activePageId = visiblePages.at(-1)?.id const activePageId = visiblePages.at(-1)?.id
useEffect(() => {
pendingBackClose.current = null
hideBackHint()
}, [activePageId, hideBackHint])
return ( return (
<Modal <Modal
@ -346,9 +441,19 @@ export function XWebViewHost() {
> >
<SafeAreaProvider> <SafeAreaProvider>
<View style={styles.host}> <View style={styles.host}>
{snapshot.pages.map((page, index) => ( {snapshot.pages.map(page => (
<HostPage key={page.id} active={page.id === activePageId} page={page} /> <HostPage
key={page.id}
active={page.id === activePageId}
onBackPress={() => handlePageBack(page)}
page={page}
/>
))} ))}
{showBackHint ? (
<View pointerEvents="none" style={styles.backHintContainer}>
<Text style={styles.backHintText}></Text>
</View>
) : null}
</View> </View>
</SafeAreaProvider> </SafeAreaProvider>
</Modal> </Modal>
@ -356,6 +461,22 @@ export function XWebViewHost() {
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
backHintContainer: {
alignItems: 'center',
bottom: 64,
left: 24,
position: 'absolute',
right: 24,
},
backHintText: {
backgroundColor: 'rgba(17,24,39,0.88)',
borderRadius: 18,
color: '#ffffff',
fontSize: 14,
overflow: 'hidden',
paddingHorizontal: 16,
paddingVertical: 9,
},
hiddenPage: { hiddenPage: {
display: 'none', display: 'none',
}, },

查看文件

@ -2,7 +2,7 @@ import { useCallback } from 'react'
import { PermissionsAndroid, Platform } from 'react-native' import { PermissionsAndroid, Platform } from 'react-native'
import type { XWebViewPermissionRequest, XWebViewPermissionsConfig } from './types' import type { XWebViewPermissionRequest, XWebViewPermissionsConfig } from './types'
import type { WebPermissionKind } from './permissionPolicy' import { isPermissionOriginAllowed, type WebPermissionKind } from './permissionPolicy'
const ANDROID_PERMISSIONS = { const ANDROID_PERMISSIONS = {
camera: PermissionsAndroid.PERMISSIONS.CAMERA, camera: PermissionsAndroid.PERMISSIONS.CAMERA,
@ -57,8 +57,11 @@ async function requestAndroidPermissions(resources: WebPermissionKind[]): Promis
*/ */
export function useXWebViewPermissionDecision(config: XWebViewPermissionsConfig = {}) { export function useXWebViewPermissionDecision(config: XWebViewPermissionsConfig = {}) {
return useCallback( return useCallback(
async (origin: string, resources: WebPermissionKind[]) => { async (origin: string, documentUrl: string, resources: WebPermissionKind[]) => {
if (resources.length === 0) return false if (resources.length === 0) return false
if (!isPermissionOriginAllowed(origin, documentUrl, config.allowedOrigins ?? [])) {
return false
}
if (!(await requestFromHost(config, origin, resources))) return false if (!(await requestFromHost(config, origin, resources))) return false
if (Platform.OS !== 'android') return true if (Platform.OS !== 'android') return true
return requestAndroidPermissions(resources) return requestAndroidPermissions(resources)

查看文件

@ -1,5 +1,5 @@
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Image, Linking, Pressable, StyleSheet, Text, View } from 'react-native' import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard' import Clipboard from '@react-native-clipboard/clipboard'
import WebView from 'react-native-webview' import WebView from 'react-native-webview'
import type { import type {
@ -161,16 +161,6 @@ function isUriSource(source: XWebViewSource): source is Extract<XWebViewSource,
return typeof source.uri === 'string' return typeof source.uri === 'string'
} }
function validateImageSource(uri: string): Promise<void> {
return new Promise((resolve, reject) => {
Image.getSize(
uri,
() => resolve(),
() => reject(new Error('导航栏背景图片加载失败')),
)
})
}
export type XWebViewViewProps = { export type XWebViewViewProps = {
active?: boolean active?: boolean
config: XWebViewConfig config: XWebViewConfig
@ -262,10 +252,9 @@ export function XWebViewView({
getStatusBarHeight: () => statusBarHeight, getStatusBarHeight: () => statusBarHeight,
isActive: () => (pageId ? isActivePage(pageId) : active), isActive: () => (pageId ? isActivePage(pageId) : active),
pageId: resolvedPageId, pageId: resolvedPageId,
updateNavigation: async patch => { updateNavigation: patch => {
if (patch.background?.image) { // 网络图片由 ImageBackground 异步加载;Bridge 设置成功只代表配置已接收,
await validateImageSource(patch.background.image) // 不等待图片探测或下载,避免慢网络阻塞 H5 回调。
}
if (pageId) updatePageNavigation(pageId, patch) if (pageId) updatePageNavigation(pageId, patch)
}, },
updateWindow: patch => { updateWindow: patch => {
@ -376,13 +365,14 @@ export function XWebViewView({
if (parsed.__xwv === 'permission') { if (parsed.__xwv === 'permission') {
const id = String(parsed.id ?? '') const id = String(parsed.id ?? '')
const origin = String(parsed.origin ?? '') const origin = String(parsed.origin ?? '')
const documentUrl = event.nativeEvent.url || navigationState.url
const resources = Array.isArray(parsed.resources) const resources = Array.isArray(parsed.resources)
? parsed.resources.filter( ? parsed.resources.filter(
(value): value is 'camera' | 'microphone' => (value): value is 'camera' | 'microphone' =>
value === 'camera' || value === 'microphone', value === 'camera' || value === 'microphone',
) )
: [] : []
void decidePermission(origin, resources) void decidePermission(origin, documentUrl, resources)
.then(granted => { .then(granted => {
webViewRef.current?.injectJavaScript( webViewRef.current?.injectJavaScript(
`window.__XWV_PERMISSION_REPLY&&window.__XWV_PERMISSION_REPLY(${JSON.stringify(id)},${granted});true;`, `window.__XWV_PERMISSION_REPLY&&window.__XWV_PERMISSION_REPLY(${JSON.stringify(id)},${granted});true;`,
@ -420,6 +410,7 @@ export function XWebViewView({
config.bridge, config.bridge,
decidePermission, decidePermission,
downloads, downloads,
navigationState.url,
processBlobDownload, processBlobDownload,
processDownload, processDownload,
], ],

查看文件

@ -0,0 +1,23 @@
export const DEFAULT_BACK_CLOSE_INTERVAL_MS = 1_000
export type PendingBackClose = {
at: number
pageId: string
}
export type BackPressDecision = 'close' | 'goBack' | 'prompt'
/**
* Android 退
*/
export function decideBackPress(
pageId: string,
canGoBack: boolean,
now: number,
pending: PendingBackClose | null,
intervalMs = DEFAULT_BACK_CLOSE_INTERVAL_MS,
): BackPressDecision {
if (canGoBack) return 'goBack'
if (pending?.pageId === pageId && now - pending.at <= intervalMs) return 'close'
return 'prompt'
}

查看文件

@ -1,5 +1,30 @@
export type WebPermissionKind = 'camera' | 'microphone' export type WebPermissionKind = 'camera' | 'microphone'
function normalizedWebOrigin(value: string): string | null {
try {
const url = new URL(value)
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
return url.origin === 'null' ? null : url.origin
} catch {
return null
}
}
/**
* origin 宿
* documentUrl
*/
export function isPermissionOriginAllowed(
requestedOrigin: string,
documentUrl: string,
allowedOrigins: readonly string[],
): boolean {
const request = normalizedWebOrigin(requestedOrigin)
const document = normalizedWebOrigin(documentUrl)
if (!request || request !== document) return false
return allowedOrigins.some(origin => normalizedWebOrigin(origin) === request)
}
/** 将不同 WebView 内核返回的资源名称归一为稳定的业务权限。 */ /** 将不同 WebView 内核返回的资源名称归一为稳定的业务权限。 */
export function webPermissionKind(resource: string): WebPermissionKind | null { export function webPermissionKind(resource: string): WebPermissionKind | null {
const normalized = resource.toUpperCase() const normalized = resource.toUpperCase()

查看文件

@ -84,6 +84,8 @@ export type XWebViewNavigationRenderContext = XWebViewMenuContext & {
background: XWebViewNavigationBackground background: XWebViewNavigationBackground
closeButton: XWebViewNavigationButton closeButton: XWebViewNavigationButton
backButton: XWebViewNavigationButton backButton: XWebViewNavigationButton
/** 执行与默认左上角/物理返回一致的网页后退或双击关闭规则。 */
onBackPress(): void
title: Required<Pick<XWebViewNavigationTitle, 'color' | 'text'>> & title: Required<Pick<XWebViewNavigationTitle, 'color' | 'text'>> &
Pick<XWebViewNavigationTitle, 'mode'> Pick<XWebViewNavigationTitle, 'mode'>
} }
@ -120,6 +122,11 @@ export type XWebViewPermissionRequest = {
} }
export type XWebViewPermissionsConfig = { export type XWebViewPermissionsConfig = {
/**
* origin http/https origin
* https://example.com;跨域导航后的页面必须独立列入白名单。
*/
allowedOrigins?: readonly string[]
camera?: boolean camera?: boolean
microphone?: boolean microphone?: boolean
onRequest?: (request: XWebViewPermissionRequest) => void onRequest?: (request: XWebViewPermissionRequest) => void
@ -189,7 +196,8 @@ export type XWebViewBridgePageContext = {
getStatusBarHeight(): number getStatusBarHeight(): number
isActive(): boolean isActive(): boolean
pageId: string pageId: string
updateNavigation(patch: XWebViewNavigationPatch): void | Promise<void> /** 配置接收后立即返回;网络背景图片由视图异步加载。 */
updateNavigation(patch: XWebViewNavigationPatch): void
updateWindow(patch: XWebViewWindowPatch): void updateWindow(patch: XWebViewWindowPatch): void
} }
@ -210,7 +218,7 @@ export type XWebViewBehaviorConfig = {
injectedJavaScriptBeforeContentLoaded?: string injectedJavaScriptBeforeContentLoaded?: string
mixedContent?: 'never' | 'compatibility' | 'always' mixedContent?: 'never' | 'compatibility' | 'always'
hardwareBack?: { hardwareBack?: {
/** 默认立即关闭;doublePress 表示在指定时间内再次按返回键才关闭。 */ /** 默认双击关闭;仅在宿主明确要求时允许改为立即关闭。 */
closeMode?: 'immediate' | 'doublePress' closeMode?: 'immediate' | 'doublePress'
doublePressIntervalMs?: number doublePressIntervalMs?: number
onFirstClosePress?: () => void onFirstClosePress?: () => void

查看文件

@ -0,0 +1,18 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { decideBackPress } from '../src/backPolicy'
test('back press always navigates web history before considering page close', () => {
assert.equal(decideBackPress('page', true, 2_000, { at: 1_900, pageId: 'page' }), 'goBack')
})
test('back press prompts once and closes only within the one-second window', () => {
assert.equal(decideBackPress('page', false, 2_000, null), 'prompt')
assert.equal(decideBackPress('page', false, 2_999, { at: 2_000, pageId: 'page' }), 'close')
assert.equal(decideBackPress('page', false, 3_001, { at: 2_000, pageId: 'page' }), 'prompt')
})
test('pending close state never crosses WebView pages', () => {
assert.equal(decideBackPress('second', false, 2_500, { at: 2_000, pageId: 'first' }), 'prompt')
})

查看文件

@ -1,7 +1,11 @@
import assert from 'node:assert/strict' import assert from 'node:assert/strict'
import test from 'node:test' import test from 'node:test'
import { allowedWebPermissionResources, webPermissionKind } from '../src/permissionPolicy' import {
allowedWebPermissionResources,
isPermissionOriginAllowed,
webPermissionKind,
} from '../src/permissionPolicy'
test('WebView camera and microphone resource names use one canonical mapping', () => { test('WebView camera and microphone resource names use one canonical mapping', () => {
assert.equal(webPermissionKind('android.webkit.resource.VIDEO_CAPTURE'), 'camera') assert.equal(webPermissionKind('android.webkit.resource.VIDEO_CAPTURE'), 'camera')
@ -23,3 +27,31 @@ test('only system-authorized camera or microphone resources are granted', () =>
]) ])
assert.deepEqual(allowedWebPermissionResources([resources[2]], new Set()), []) assert.deepEqual(allowedWebPermissionResources([resources[2]], new Set()), [])
}) })
test('media permission requires the current document origin to match the exact allowlist', () => {
assert.equal(
isPermissionOriginAllowed(
'https://camera.example.test',
'https://camera.example.test/app/cert',
['https://camera.example.test'],
),
true,
)
assert.equal(
isPermissionOriginAllowed(
'https://camera.example.test',
'https://other.example.test/redirected',
['https://camera.example.test', 'https://other.example.test'],
),
false,
)
assert.equal(
isPermissionOriginAllowed(
'https://child.camera.example.test',
'https://child.camera.example.test',
['https://camera.example.test'],
),
false,
)
assert.equal(isPermissionOriginAllowed('null', 'file:///tmp/index.html', ['null']), false)
})