比较提交
2 次代码提交
c0d7115211
...
28a636821a
| 作者 | SHA1 | 提交日期 | |
|---|---|---|---|
|
|
28a636821a | ||
|
|
21ff207ade |
@ -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,19 +267,16 @@ 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,
|
bugCollectMode: options?.bugCollectMode,
|
||||||
bugCollectMode: options?.bugCollectMode,
|
packageName:
|
||||||
packageName:
|
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> {
|
||||||
await awaitInitialization()
|
try {
|
||||||
requireUpdateEnabled()
|
await awaitInitialization()
|
||||||
|
} 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,18 +485,35 @@ export const UpdateSDK = {
|
|||||||
if (userId) params.userId = userId
|
if (userId) params.userId = userId
|
||||||
if (bypassIgnore) params.bypassIgnore = 'true'
|
if (bypassIgnore) params.bypassIgnore = 'true'
|
||||||
|
|
||||||
const raw = await apiRequest<RawAppUpdateInfo>('/api/v1/updates/app/check', {
|
try {
|
||||||
skipAuth: true,
|
const raw = await apiRequest<RawAppUpdateInfo>('/api/v1/updates/app/check', {
|
||||||
params,
|
skipAuth: true,
|
||||||
signal: activeSessionAbort.signal,
|
params,
|
||||||
})
|
signal: activeSessionAbort.signal,
|
||||||
const normalized: AppUpdateInfo = {
|
})
|
||||||
...raw,
|
const normalized: AppUpdateInfo = {
|
||||||
downloadUrl: normalizeDownloadUrl(raw.downloadUrl),
|
...raw,
|
||||||
sha256: raw.sha256 ?? raw.apkHash ?? undefined,
|
downloadUrl: normalizeDownloadUrl(raw.downloadUrl),
|
||||||
|
sha256: raw.sha256 ?? raw.apkHash ?? undefined,
|
||||||
|
}
|
||||||
|
await writeUpdateCache(sessionCacheKey(UPDATE_APP_CACHE_KEY), 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',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
await writeUpdateCache(sessionCacheKey(UPDATE_APP_CACHE_KEY), normalized)
|
|
||||||
return applyIgnore(normalized)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
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)
|
||||||
|
|
||||||
|
useEffect(
|
||||||
|
() => () => {
|
||||||
|
if (hintTimer.current) clearTimeout(hintTimer.current)
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
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
|
||||||
|
if (!page.navigationState.canGoBack && hardwareBack?.closeMode === 'immediate') {
|
||||||
|
pendingBackClose.current = null
|
||||||
|
hideBackHint()
|
||||||
|
page.handle.close('back')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Date.now()
|
||||||
|
const interval = Math.max(
|
||||||
|
250,
|
||||||
|
hardwareBack?.doublePressIntervalMs ?? DEFAULT_BACK_CLOSE_INTERVAL_MS,
|
||||||
|
)
|
||||||
|
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')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingBackClose.current = { at: now, pageId: page.id }
|
||||||
|
showFirstBackHint(page, interval)
|
||||||
|
},
|
||||||
|
[hideBackHint, showFirstBackHint],
|
||||||
|
)
|
||||||
|
|
||||||
const handleRequestClose = useCallback(() => {
|
const handleRequestClose = useCallback(() => {
|
||||||
const page = [...snapshot.pages].reverse().find(item => !item.suspended)
|
const page = [...snapshot.pages].reverse().find(item => !item.suspended)
|
||||||
if (!page) return
|
if (page) handlePageBack(page)
|
||||||
if (page.navigationState.canGoBack) {
|
}, [handlePageBack, snapshot.pages])
|
||||||
pendingHardwareClose.current = null
|
|
||||||
page.handle.goBack()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const hardwareBack = page.config.behavior?.hardwareBack
|
|
||||||
if (hardwareBack?.closeMode !== 'doublePress') {
|
|
||||||
pendingHardwareClose.current = null
|
|
||||||
page.handle.close('back')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = Date.now()
|
|
||||||
const interval = Math.max(250, hardwareBack.doublePressIntervalMs ?? 1_000)
|
|
||||||
const pending = pendingHardwareClose.current
|
|
||||||
if (pending?.pageId === page.id && now - pending.at <= interval) {
|
|
||||||
pendingHardwareClose.current = null
|
|
||||||
page.handle.close('back')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
pendingHardwareClose.current = { at: now, pageId: page.id }
|
|
||||||
hardwareBack.onFirstClosePress?.()
|
|
||||||
}, [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)
|
||||||
|
})
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户