refactor(sdk): unify native update and secure runtime contracts
这个提交包含在:
父节点
c0d7115211
当前提交
21ff207ade
@ -1,17 +1,35 @@
|
||||
# RN SDK 重构实施接管文档
|
||||
|
||||
> 状态更新时间:2026-07-27
|
||||
> 状态更新时间:2026-07-28
|
||||
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
|
||||
> 发布约束:所有 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 发布;不得据此
|
||||
宣称原生集成已验证。
|
||||
|
||||
- common 已增加 `idle / initializing / ready / degraded / failed` 初始化状态、
|
||||
`XuqmError` 结构化错误、七天最后成功配置缓存,以及最多三次的指数退避抖动。
|
||||
`XuqmError` 结构化错误、严格 Schema 的非敏感 LKG,以及最多三次的指数退避抖动。
|
||||
平台暂不可用时优先进入 degraded;没有缓存时扩展返回 `XUQM_NOT_READY`,不向宿主
|
||||
制造未处理异常。最后成功配置使用 AES-GCM 完整性加密,密钥由签发配置和
|
||||
app/package/environment 上下文派生且不写入缓存;这里是应用沙箱内防明文与防篡改
|
||||
边界,不宣称具备 Android Keystore/iOS Keychain 的硬件保密性。
|
||||
制造未处理异常。LKG 只包含远端服务开关和地址,采用版本化 JSON;未知字段、非法
|
||||
地址、旧格式或损坏数据直接删除,不再依赖客户端可提取秘密伪装安全缓存。
|
||||
- 扩展 SDK 的唯一正式初始化入口是平台签发 `.xuqmconfig` 与 Metro 自动初始化;
|
||||
`XuqmSDK.initialize(options)` 及公开 `XuqmInitOptions` 已删除,不保留兼容壳。
|
||||
common-only 不初始化。未使用 `withXuqmConfig` 时,公开
|
||||
@ -111,9 +129,8 @@ com.xuqm.update.XuqmPluginPackageStagerTest` 共 25 tasks 构建并测试成功
|
||||
这些结果不得从当前门禁推断。
|
||||
- License 离线激活 SDK 已审计:RNSDK 不存在对应包、API、依赖、脚本、文档或空目录;
|
||||
npm 的 `license: UNLICENSED` 元数据和 RN 插件事务 `activation` 不属于离线授权。
|
||||
- 配置中的 `signingKey` 明确是客户端可提取的请求完整性/缓存派生材料,不是 License、
|
||||
用户授权或服务端秘密;敏感 API 仍依赖登录 `accessToken`,构建制品上传依赖独立
|
||||
Jenkins Bearer,并由服务端执行授权。
|
||||
- 新签发配置不再包含 `signingKey/appSecret`;common 请求只携带公开 appKey、可选
|
||||
userId 与登录 Bearer,构建制品上传继续使用独立 Jenkins Bearer。
|
||||
|
||||
## 1. 本轮目标
|
||||
|
||||
|
||||
@ -34,9 +34,8 @@ await XuqmSDK.logout()
|
||||
- `platformUrl`:Xuqm 租户平台地址。远程配置、整包更新、插件 release set 检查等平台接口只使用该地址;自动配置未提供时使用 SDK 内置的公有平台地址。
|
||||
- `apiUrl`:租户平台返回的业务扩展服务地址,只供明确声明使用该业务服务的扩展能力读取,不能替代 `platformUrl`。
|
||||
- common 的 `apiRequest()` 默认基础地址在 SDK 初始化后固定为 `platformUrl`;访问其它业务服务时必须传绝对 URL 或在 common-only 模式显式 `configureHttp()`,禁止用远程 `apiUrl` 静默改写平台请求。
|
||||
- 配置中的 `signingKey` 是可被客户端提取的请求完整性材料,不代表用户身份或接口
|
||||
授权;敏感接口必须继续校验登录 `accessToken`,构建制品上传使用 Jenkins 发布
|
||||
Bearer。
|
||||
- 配置只包含公开应用身份与平台地址,不携带 `appSecret` 或其它客户端长期认证秘密。
|
||||
敏感接口校验登录 `accessToken`,构建制品上传使用 Jenkins 发布 Bearer。
|
||||
|
||||
## common 通用能力
|
||||
|
||||
@ -45,7 +44,7 @@ await XuqmSDK.logout()
|
||||
- `apiRequest`、`configureHttp`、`useRequest`、`useApi`、`usePageApi`
|
||||
- `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`
|
||||
- `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`
|
||||
- `hmacSha256Base64Url`、`sha256Hex`
|
||||
- `sha256Hex`
|
||||
- `getDeviceId`、`getDeviceInfo`、`detectPushVendor`
|
||||
- `showToast`、`showAlert`、`showConfirm`、`ScaledImage`
|
||||
|
||||
@ -68,9 +67,11 @@ await XuqmSDK.logout()
|
||||
## update 插件检查边界
|
||||
|
||||
Update 服务由平台远程配置 `features.update` 控制,缺失时默认关闭。显式调用
|
||||
`checkAppUpdate`、`downloadApk`、`downloadAndInstallApp`、`checkPluginRelease` 或
|
||||
`installPluginRelease` 时,关闭状态统一抛出 `UpdateDisabledError`,错误码为
|
||||
`UPDATE_DISABLED`,且不会先访问网络或原生安装模块;自动启动检查会正常跳过。
|
||||
`checkAppUpdate` 返回结构化
|
||||
`UPDATE_AVAILABLE / NO_UPDATE / LOGIN_REQUIRED / SERVICE_DISABLED / FAILED`,同时携带
|
||||
Android 当前网络事实;检查失败或登录门禁不会阻断宿主。下载、安装与插件操作在服务
|
||||
关闭时仍统一抛出 `UpdateDisabledError`,错误码为 `UPDATE_DISABLED`,且不会先访问网络
|
||||
或原生安装模块;自动启动检查会正常跳过。
|
||||
|
||||
```ts
|
||||
const plan = await UpdateSDK.checkPluginRelease('buz1', {
|
||||
|
||||
@ -82,7 +82,8 @@ XUQM-CONFIG-V2.{keyId}.{salt}.{iv}.{ciphertextAndTag}.{signature}
|
||||
|
||||
明文必须是键按字典序排列、无空白、忽略 null 的 canonical JSON。必要字段为
|
||||
`schemaVersion=2`、`configId`、正整数 `revision`、`issuedAt`、`appKey`、`appName`、
|
||||
`serverUrl` 和 `signingKey`;`expiresAt` 省略表示长期有效。平台重新生成配置后,
|
||||
`serverUrl`;`expiresAt` 省略表示长期有效。配置不得包含 `signingKey`、`appSecret`
|
||||
或其它客户端长期秘密。平台重新生成配置后,
|
||||
旧 `configId/revision` 只会阻止后续 Release 构建,不会追溯影响已经安装的 App。
|
||||
|
||||
远程配置响应中的 `apiUrl` 是业务扩展服务地址,与配置文件中的平台地址职责不同:
|
||||
@ -127,7 +128,7 @@ await XuqmSDK.logout()
|
||||
|
||||
- 扩展项目缺少配置:`awaitInitialization()` 明确报错。
|
||||
- 签名、V2 Schema、canonical JSON 或有效期校验失败:构建立即失败。
|
||||
- 远程配置失败且存在七天内的最后成功配置:进入 `degraded` 并继续启动。
|
||||
- 远程配置失败且存在严格 Schema 校验通过的最后成功配置:进入 `degraded` 并继续启动。
|
||||
- 首次启动没有缓存:扩展能力返回 `XUQM_NOT_READY`,宿主核心业务继续运行。
|
||||
- 不自动切换到硬编码配置,不静默创建默认租户,不提供多级兼容回退。
|
||||
- common-only 项目没有配置时不会执行初始化,也不会报错。
|
||||
@ -135,15 +136,11 @@ await XuqmSDK.logout()
|
||||
## 安全约束
|
||||
|
||||
- 不打印密文或解密后的配置。
|
||||
- 不提交明文 appKey、signingKey 或租户密钥。
|
||||
- appKey 是公开应用身份但仍不得散落复制;不得提交租户密钥、Token 或服务端秘密。
|
||||
- 不在多个目录手工维护配置副本。
|
||||
- `.xuqmconfig` 变更必须通过平台重新签发。
|
||||
- 最后成功配置不会明文写入 AsyncStorage。SDK 使用签发配置中的 `signingKey`、
|
||||
appKey、包名和平台地址派生独立 AES-GCM 缓存密钥,密钥材料不落缓存;篡改、
|
||||
跨包或跨环境读取都会失败。该边界属于应用沙箱内加密,不宣称具备 Android
|
||||
Keystore/iOS Keychain 的硬件密钥保密性。
|
||||
- `signingKey` 是随客户端配置交付、可被终端提取的请求签名材料,只用于请求完整性
|
||||
标记和本地缓存派生,不是 License、授权凭据或服务端秘密,服务端不得仅凭该签名
|
||||
授予敏感权限。
|
||||
- 最后成功的远端配置是非敏感 LKG,以带版本、严格 Schema 的 JSON 保存在应用沙箱;
|
||||
未知字段、非法地址、旧格式或损坏数据直接删除。它不伪装成依赖客户端共享秘密的
|
||||
安全凭据。
|
||||
- 用户数据、灰度发布、上传构建制品等敏感 API 仍必须使用登录得到的用户
|
||||
`accessToken` 或 Jenkins 发布流水线的独立 Bearer,并由服务端执行真实授权。
|
||||
|
||||
@ -34,8 +34,13 @@ try {
|
||||
|
||||
SourceMap 上传使用独立打包后处理流程;不要再叠加基于 Metro `customSerializer` 的旧插件方案,以免破坏新版 Metro 的 exports 与序列化流程。
|
||||
|
||||
fatal/error 持久队列复用 common 的 AES-GCM 沙箱加密能力;没有签发密钥时不降级写入
|
||||
包含堆栈和设备信息的明文。Issue 只有在错误堆栈能唯一匹配当前原生 Bundle
|
||||
fatal/error 持久队列复用 common 的 AES-GCM 沙箱加密能力,并在设备首次需要持久化时
|
||||
使用系统安全随机源生成 256 位本地密钥。该密钥仅保存在当前应用沙箱,不来自
|
||||
`.xuqmconfig`、不打入 Bundle/安装包,也不进入日志或上报数据;安全随机源或存储不可用
|
||||
时直接放弃持久化,绝不降级写入包含堆栈和设备信息的明文。密钥丢失、格式损坏或应用
|
||||
上下文不匹配时清除不可解密队列。
|
||||
|
||||
Issue 只有在错误堆栈能唯一匹配当前原生 Bundle
|
||||
state/manifest 时才携带 `buildId/moduleId/moduleVersion/bundleHash` 并标记
|
||||
`symbolicationStatus=exact`;无法归属时明确为 `unavailable`,服务端不得猜测 latest
|
||||
Source Map。
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
||||
},
|
||||
"scripts": {
|
||||
"pack:check": "corepack pnpm pack --dry-run",
|
||||
"test": "node --import tsx --test tests/*.test.ts",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@ -107,7 +107,6 @@ function _createQueue(): LogQueue {
|
||||
return new LogQueue({
|
||||
bugCollectApiUrl: cfg.bugCollectApiUrl,
|
||||
appKey: cfg.appKey,
|
||||
encryptionSecret: cfg.signingKey,
|
||||
onDisabled: () => {
|
||||
_platformDisabled = true
|
||||
void AsyncStorage.setItem(disabledKey(), 'true').catch(() => undefined)
|
||||
|
||||
@ -5,6 +5,7 @@ import { shouldPersist } from './queuePolicy'
|
||||
import { retryWithBackoff } from '@xuqm/rn-common'
|
||||
import {
|
||||
clearStoredQueue,
|
||||
getOrCreateQueueEncryptionKey,
|
||||
isBugCollectDisabledPayload,
|
||||
openQueue,
|
||||
sealQueue,
|
||||
@ -31,7 +32,6 @@ export class LogQueue {
|
||||
private cfg: {
|
||||
bugCollectApiUrl: string
|
||||
appKey: string
|
||||
encryptionSecret?: string
|
||||
onDisabled?: () => void | Promise<void>
|
||||
},
|
||||
) {
|
||||
@ -156,13 +156,14 @@ export class LogQueue {
|
||||
private async _readStored(): Promise<BugCollectEvent[]> {
|
||||
const raw = await AsyncStorage.getItem(this.storageKey)
|
||||
if (!raw) return []
|
||||
if (!this.cfg.encryptionSecret) {
|
||||
const encryptionKey = await getOrCreateQueueEncryptionKey(AsyncStorage, this.cfg.appKey)
|
||||
if (!encryptionKey) {
|
||||
await AsyncStorage.removeItem(this.storageKey)
|
||||
return []
|
||||
}
|
||||
try {
|
||||
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 {
|
||||
await AsyncStorage.removeItem(this.storageKey)
|
||||
return []
|
||||
@ -172,13 +173,16 @@ export class LogQueue {
|
||||
private async _writeStored(queue: BugCollectEvent[]): Promise<void> {
|
||||
if (queue.length === 0) {
|
||||
await AsyncStorage.removeItem(this.storageKey)
|
||||
} else if (!this.cfg.encryptionSecret) {
|
||||
// 没有签发密钥时绝不降级写入含栈和设备信息的明文持久队列。
|
||||
await AsyncStorage.removeItem(this.storageKey)
|
||||
} else {
|
||||
const encryptionKey = await getOrCreateQueueEncryptionKey(AsyncStorage, this.cfg.appKey)
|
||||
if (!encryptionKey) {
|
||||
// 安全随机源或本地存储不可用时不持久化,绝不写入含栈和设备信息的明文。
|
||||
await AsyncStorage.removeItem(this.storageKey)
|
||||
return
|
||||
}
|
||||
await AsyncStorage.setItem(
|
||||
this.storageKey,
|
||||
sealQueue(queue, this.cfg.encryptionSecret, this.storageContext),
|
||||
sealQueue(queue, encryptionKey, this.storageContext),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@ -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'
|
||||
|
||||
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 {
|
||||
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(
|
||||
storage: { removeItem(key: string): Promise<unknown> },
|
||||
appKey: string,
|
||||
@ -14,17 +73,21 @@ export async function clearStoredQueue(
|
||||
await storage.removeItem(storedQueueKey(appKey))
|
||||
}
|
||||
|
||||
export function sealQueue(queue: BugCollectEvent[], secret: string, context: string): string {
|
||||
return sealLocalCache(JSON.stringify(queue), secret, context)
|
||||
export function sealQueue(
|
||||
queue: BugCollectEvent[],
|
||||
encryptionKey: string,
|
||||
context: string,
|
||||
): string {
|
||||
return sealLocalCache(JSON.stringify(queue), encryptionKey, context)
|
||||
}
|
||||
|
||||
export function openQueue(
|
||||
payload: string,
|
||||
secret: string,
|
||||
encryptionKey: string,
|
||||
context: string,
|
||||
cutoff: number,
|
||||
): 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')
|
||||
return parsed.filter(event => Number.isFinite(event.timestamp) && event.timestamp >= cutoff)
|
||||
}
|
||||
|
||||
@ -3,8 +3,10 @@ import test from 'node:test'
|
||||
|
||||
import {
|
||||
clearStoredQueue,
|
||||
getOrCreateQueueEncryptionKey,
|
||||
isBugCollectDisabledPayload,
|
||||
openQueue,
|
||||
queueEncryptionKeyStorageKey,
|
||||
sealQueue,
|
||||
} from '../src/queue/queueStorage'
|
||||
import type { IssueEvent } from '../src/types'
|
||||
@ -21,14 +23,46 @@ const issue: IssueEvent = {
|
||||
symbolicationStatus: 'unavailable',
|
||||
}
|
||||
|
||||
test('persistent queue is encrypted, context-bound and tamper-evident', () => {
|
||||
const encrypted = sealQueue([issue], 'signing-key', 'bugcollect-queue|app')
|
||||
function createMemoryStorage() {
|
||||
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.deepEqual(openQueue(encrypted, 'signing-key', 'bugcollect-queue|app', 0), [issue])
|
||||
assert.throws(() => openQueue(encrypted, 'signing-key', 'bugcollect-queue|other', 0))
|
||||
assert.deepEqual(openQueue(encrypted, key, 'bugcollect-queue|app', 0), [issue])
|
||||
assert.throws(() => openQueue(encrypted, key, 'bugcollect-queue|other', 0))
|
||||
const parts = encrypted.split('.')
|
||||
parts[2] = `${parts[2][0] === 'A' ? 'B' : 'A'}${parts[2].slice(1)}`
|
||||
assert.throws(() => openQueue(parts.join('.'), 'signing-key', 'bugcollect-queue|app', 0))
|
||||
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', () => {
|
||||
|
||||
@ -21,7 +21,7 @@ const result = await apiRequest('/health', { skipAuth: true })
|
||||
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 或多套配置约定。
|
||||
|
||||
@ -49,6 +54,10 @@ Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败
|
||||
`updateEnabled` 只在前者缺失时读取。两者都缺失时安全默认关闭,最后成功配置缓存会
|
||||
原样保存开关值。关闭 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`:统一小文件流式读取与大文件落盘、进度和取消能力。
|
||||
- `compareVersions`、`satisfiesVersion`、`compatibleMajorRange`:统一 SemVer 与插件兼容范围判断。
|
||||
- `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`:统一时间解析与过期判断。
|
||||
- `formatNumericDateTime`:为 API 参数和固定数字布局提供不受 locale 标点/顺序影响的本地时间格式,调用方通过选项控制日期、时间、秒和分隔符。
|
||||
- `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`、`registerAndroidDownloadedFile`:统一文件与 Android 下载登记操作。
|
||||
- `hmacSha256Base64Url`、`sha256Hex`:签名与文件摘要使用的统一实现。平台签发的
|
||||
- `sha256Hex`:文件摘要使用的统一实现。平台签发的
|
||||
`.xuqmconfig` 只允许由 Metro 构建插件验签和解析,不向运行时暴露通用解密 API。
|
||||
- `getDeviceInfo`、`getDeviceId`、`useApi`、`usePageApi`、`showToast` 等项目通用能力。
|
||||
|
||||
|
||||
@ -47,7 +47,6 @@ function assertConfigPayload(config, plaintext) {
|
||||
'iosBundleId',
|
||||
'harmonyBundleName',
|
||||
'serverUrl',
|
||||
'signingKey',
|
||||
])
|
||||
const unknownField = Object.keys(config).find(field => !allowedFields.has(field))
|
||||
if (unknownField) throw new Error(`配置包含不受支持字段:${unknownField}`)
|
||||
@ -63,7 +62,7 @@ function assertConfigPayload(config, plaintext) {
|
||||
if (!Number.isInteger(config.revision) || config.revision <= 0) {
|
||||
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()) {
|
||||
throw new Error(`配置 ${field} 不能为空`)
|
||||
}
|
||||
|
||||
@ -30,6 +30,7 @@
|
||||
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
||||
},
|
||||
"scripts": {
|
||||
"pack:check": "corepack pnpm pack --dry-run",
|
||||
"test": "node --import tsx --test tests/*.test.ts tests/*.test.cjs",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
|
||||
@ -33,8 +33,6 @@ export interface XuqmConfig {
|
||||
/** Update 灰度检查是否要求共享登录态;由租户平台动态配置。 */
|
||||
updateRequiresLogin: boolean
|
||||
configurationSource: 'remote' | 'cache'
|
||||
// 请求签名密钥(从配置文件读取)
|
||||
signingKey?: string
|
||||
}
|
||||
|
||||
export type XuqmInitializationState = 'idle' | 'initializing' | 'ready' | 'degraded' | 'failed'
|
||||
@ -91,11 +89,7 @@ export interface XuqmRemoteConfig {
|
||||
updateRequiresLogin?: boolean
|
||||
}
|
||||
|
||||
export function initConfigFromRemote(
|
||||
options: InternalInitOptions,
|
||||
remote: XuqmRemoteConfig,
|
||||
signingKey?: string,
|
||||
): void {
|
||||
export function initConfigFromRemote(options: InternalInitOptions, remote: XuqmRemoteConfig): void {
|
||||
const platformUrl = (options.platformUrl ?? '').replace(/\/$/, '')
|
||||
const apiUrl = remote.apiUrl ?? remote.imApiUrl ?? ''
|
||||
_config = {
|
||||
@ -114,7 +108,6 @@ export function initConfigFromRemote(
|
||||
// 服务端未下发匿名策略时按安全默认禁止匿名更新检查。
|
||||
updateRequiresLogin: remote.updateRequiresLogin ?? true,
|
||||
configurationSource: options.configSource ?? 'remote',
|
||||
signingKey,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { hmac } from '@noble/hashes/hmac.js'
|
||||
import { sha256 } from '@noble/hashes/sha2.js'
|
||||
import { bytesToHex, utf8ToBytes } from '@noble/hashes/utils.js'
|
||||
import { encodeBase64 } from '../security/base64.js'
|
||||
@ -13,7 +12,6 @@ export interface DecryptedConfig {
|
||||
iosBundleId?: string
|
||||
harmonyBundleName?: string
|
||||
serverUrl: string
|
||||
signingKey: string
|
||||
issuedAt: string
|
||||
expiresAt?: string
|
||||
}
|
||||
@ -23,14 +21,6 @@ export function bytesToBase64(value: Uint8Array): string {
|
||||
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. */
|
||||
export function sha256Hex(value: string | Uint8Array): string {
|
||||
return bytesToHex(sha256(typeof value === 'string' ? utf8ToBytes(value) : value))
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage'
|
||||
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
|
||||
import { getOptionalConfig, getUserId } from './config'
|
||||
import { hmacSha256Base64Url } from './crypto'
|
||||
import { safeRequestPath } from './api/diagnostics'
|
||||
|
||||
const TOKEN_KEY = '@xuqm:token'
|
||||
@ -29,15 +28,6 @@ export async function _clearAccessToken(): Promise<void> {
|
||||
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>(
|
||||
path: string,
|
||||
options: {
|
||||
@ -68,16 +58,9 @@ export async function apiRequest<T>(
|
||||
}
|
||||
|
||||
const config = getOptionalConfig()
|
||||
const signingKey = config?.signingKey
|
||||
if (config && signingKey) {
|
||||
const timestamp = Math.floor(Date.now() / 1000).toString()
|
||||
const nonce = generateRequestNonce(signingKey, config.appKey)
|
||||
if (config) {
|
||||
const userId = getUserId() ?? ''
|
||||
const payload = `${config.appKey}\n${userId}\n${timestamp}\n${nonce}`
|
||||
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
|
||||
}
|
||||
|
||||
|
||||
@ -20,7 +20,7 @@ export { retryWithBackoff } from './retry'
|
||||
export type { RetryOptions } from './retry'
|
||||
export { awaitInitialization } from './sdk'
|
||||
export { apiRequest, configureHttp } from './http'
|
||||
export { bytesToBase64, hmacSha256Base64Url, sha256Hex } from './crypto'
|
||||
export { bytesToBase64, sha256Hex } from './crypto'
|
||||
export type { DecryptedConfig } from './crypto'
|
||||
export { DEFAULT_TENANT_PLATFORM_URL, DEFAULT_IM_WS_URL } from './constants'
|
||||
export { getDeviceId, getDeviceInfo, detectPushVendor } from './device'
|
||||
|
||||
@ -1,18 +1,44 @@
|
||||
import { z } from 'zod'
|
||||
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({
|
||||
apiUrl: z.string().optional(),
|
||||
imApiUrl: z.string().optional(),
|
||||
imWsUrl: z.string().optional(),
|
||||
fileServiceUrl: z.string().optional(),
|
||||
apiUrl: endpointSchema.optional(),
|
||||
imApiUrl: endpointSchema.optional(),
|
||||
imWsUrl: endpointSchema.optional(),
|
||||
fileServiceUrl: endpointSchema.optional(),
|
||||
imEnabled: z.boolean().optional(),
|
||||
pushEnabled: z.boolean().optional(),
|
||||
bugCollectApiUrl: z.string().optional(),
|
||||
bugCollectApiUrl: endpointSchema.optional(),
|
||||
bugCollectEnabled: z.boolean().optional(),
|
||||
updateEnabled: 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
|
||||
.extend({
|
||||
@ -48,8 +74,29 @@ export function normalizeRemoteConfig(value: unknown): XuqmRemoteConfig {
|
||||
}
|
||||
}
|
||||
|
||||
/** 旧缓存允许缺少 updateEnabled,最终由 XuqmConfig 按安全默认 false 收敛。 */
|
||||
/** 缓存只接受归一化后的非敏感字段,并拒绝所有未知字段。 */
|
||||
export function parseCachedRemoteConfig(value: unknown): XuqmRemoteConfig | null {
|
||||
const parsed = flatRemoteConfigSchema.safeParse(value)
|
||||
const parsed = strictCachedRemoteConfigSchema.safeParse(value)
|
||||
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,
|
||||
type InternalInitOptions,
|
||||
type XuqmLoginOptions,
|
||||
type XuqmRemoteConfig,
|
||||
type XuqmUserInfo,
|
||||
} from './config'
|
||||
import { DEFAULT_TENANT_PLATFORM_URL } from './constants'
|
||||
@ -19,9 +18,13 @@ import { sha256Hex } from './crypto'
|
||||
import { XuqmError } from './errors'
|
||||
import { _clearAccessToken, _saveAccessToken, configureHttp } from './http'
|
||||
import { retryWithBackoff } from './retry'
|
||||
import { isCacheRecordFresh, openLocalCache, sealLocalCache } from './secureCache'
|
||||
import type { DecryptedConfig } from './crypto'
|
||||
import { normalizeRemoteConfig, parseCachedRemoteConfig } from './remoteConfig'
|
||||
import {
|
||||
normalizeRemoteConfig,
|
||||
parseRemoteConfigCache,
|
||||
serializeRemoteConfigCache,
|
||||
type CachedRemoteConfig,
|
||||
} from './remoteConfig'
|
||||
|
||||
let _initPromise: Promise<void> | null = null
|
||||
let _resolvedConfigInitPromise: Promise<void> | null = null
|
||||
@ -32,12 +35,7 @@ let _resolvedConfigRequest: {
|
||||
let _initializationKey: string | null = null
|
||||
let _sessionTransition: Promise<void> = Promise.resolve()
|
||||
let _activeSessionKey: string | null = null
|
||||
const REMOTE_CONFIG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000
|
||||
const REMOTE_CONFIG_CACHE_PREFIX = '@xuqm_common:remote_config:v1:'
|
||||
interface CachedRemoteConfig {
|
||||
cachedAt: number
|
||||
remote: XuqmRemoteConfig
|
||||
}
|
||||
|
||||
function runtimePlatform(): 'ANDROID' | 'IOS' {
|
||||
try {
|
||||
@ -62,24 +60,16 @@ function storageKey(options: InternalInitOptions): string {
|
||||
|
||||
async function readCachedRemoteConfig(
|
||||
options: InternalInitOptions,
|
||||
signingKey?: string,
|
||||
): Promise<CachedRemoteConfig | null> {
|
||||
if (!signingKey) return null
|
||||
try {
|
||||
const encrypted = await AsyncStorage.getItem(storageKey(options))
|
||||
if (!encrypted) return null
|
||||
const raw = openLocalCache(encrypted, signingKey, initializationKey(options))
|
||||
const cached = JSON.parse(raw) as CachedRemoteConfig
|
||||
const parsedRemote = parseCachedRemoteConfig(cached?.remote)
|
||||
if (
|
||||
!cached ||
|
||||
!isCacheRecordFresh(cached.cachedAt, Date.now(), REMOTE_CONFIG_CACHE_TTL_MS) ||
|
||||
!parsedRemote
|
||||
) {
|
||||
const raw = await AsyncStorage.getItem(storageKey(options))
|
||||
if (!raw) return null
|
||||
const cached = parseRemoteConfigCache(raw)
|
||||
if (!cached) {
|
||||
await AsyncStorage.removeItem(storageKey(options))
|
||||
return null
|
||||
}
|
||||
return { cachedAt: cached.cachedAt, remote: parsedRemote }
|
||||
return cached
|
||||
} catch {
|
||||
await AsyncStorage.removeItem(storageKey(options)).catch(() => undefined)
|
||||
return null
|
||||
@ -89,22 +79,15 @@ async function readCachedRemoteConfig(
|
||||
async function writeCachedRemoteConfig(
|
||||
options: InternalInitOptions,
|
||||
remote: CachedRemoteConfig['remote'],
|
||||
signingKey?: string,
|
||||
): Promise<void> {
|
||||
if (!signingKey) return
|
||||
try {
|
||||
const encrypted = sealLocalCache(
|
||||
JSON.stringify({ cachedAt: Date.now(), remote } satisfies CachedRemoteConfig),
|
||||
signingKey,
|
||||
initializationKey(options),
|
||||
)
|
||||
await AsyncStorage.setItem(storageKey(options), encrypted)
|
||||
await AsyncStorage.setItem(storageKey(options), serializeRemoteConfigCache(remote, Date.now()))
|
||||
} catch {
|
||||
// 安全随机数或沙箱存储不可用时跳过缓存,不能降级为明文,也不能阻断初始化。
|
||||
// 非敏感 LKG 写入失败不能阻断在线初始化。
|
||||
}
|
||||
}
|
||||
|
||||
function startInitialization(options: InternalInitOptions, signingKey?: string): Promise<void> {
|
||||
function startInitialization(options: InternalInitOptions): Promise<void> {
|
||||
const nextKey = initializationKey(options)
|
||||
if (isInitialized()) {
|
||||
if (_initializationKey !== nextKey) {
|
||||
@ -153,9 +136,9 @@ function startInitialization(options: InternalInitOptions, signingKey?: string):
|
||||
)
|
||||
const json = (await res.json()) as Record<string, unknown>
|
||||
remote = normalizeRemoteConfig(json.data ?? json)
|
||||
await writeCachedRemoteConfig(options, remote, signingKey)
|
||||
await writeCachedRemoteConfig(options, remote)
|
||||
} catch (error) {
|
||||
const cached = await readCachedRemoteConfig(options, signingKey)
|
||||
const cached = await readCachedRemoteConfig(options)
|
||||
if (!cached) {
|
||||
throw new XuqmError(
|
||||
'XUQM_NOT_READY',
|
||||
@ -166,7 +149,7 @@ function startInitialization(options: InternalInitOptions, signingKey?: string):
|
||||
remote = cached.remote
|
||||
source = 'cache'
|
||||
}
|
||||
initConfigFromRemote({ ...options, platformUrl, configSource: source }, remote, signingKey)
|
||||
initConfigFromRemote({ ...options, platformUrl, configSource: source }, remote)
|
||||
// Xuqm 平台接口始终回到租户平台。remote.apiUrl 是业务扩展服务地址,
|
||||
// 二者职责不同;混用会把版本检查错误地发送到 App 自身的业务服务。
|
||||
configureHttp({
|
||||
@ -284,19 +267,16 @@ export function _initializeFromResolvedConfig(
|
||||
|
||||
_resolvedConfigInitPromise = (async () => {
|
||||
const platformUrl = config.serverUrl
|
||||
await startInitialization(
|
||||
{
|
||||
appKey: config.appKey,
|
||||
platformUrl,
|
||||
debug: options?.debug,
|
||||
bugCollectMode: options?.bugCollectMode,
|
||||
packageName:
|
||||
runtimePlatform() === 'IOS'
|
||||
? (config.iosBundleId ?? config.packageName)
|
||||
: config.packageName,
|
||||
},
|
||||
config.signingKey,
|
||||
)
|
||||
await startInitialization({
|
||||
appKey: config.appKey,
|
||||
platformUrl,
|
||||
debug: options?.debug,
|
||||
bugCollectMode: options?.bugCollectMode,
|
||||
packageName:
|
||||
runtimePlatform() === 'IOS'
|
||||
? (config.iosBundleId ?? config.packageName)
|
||||
: config.packageName,
|
||||
})
|
||||
})().catch(error => {
|
||||
_resolvedConfigInitPromise = null
|
||||
throw error
|
||||
|
||||
@ -20,41 +20,40 @@ function utf8BytesToString(value: Uint8Array): string {
|
||||
return decodeURIComponent(encoded)
|
||||
}
|
||||
|
||||
function randomNonce(): Uint8Array {
|
||||
const nonce = new Uint8Array(NONCE_BYTES)
|
||||
/** 统一的运行时安全随机字节入口;不可用时必须失败,禁止回退 Math.random。 */
|
||||
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
|
||||
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)
|
||||
return nonce
|
||||
cryptoApi.getRandomValues(value)
|
||||
return value
|
||||
}
|
||||
|
||||
/**
|
||||
* 应用沙箱内的带完整性加密。密钥由平台签发配置中的 signingKey 派生但不落缓存;
|
||||
* 这是防止缓存明文与离线篡改的边界,不宣称具备硬件 Keystore 保密性。
|
||||
*/
|
||||
export function sealLocalCache(plaintext: string, secret: string, context: string): string {
|
||||
const nonce = randomNonce()
|
||||
const ciphertext = gcm(cacheKey(secret, context), nonce, utf8ToBytes(context)).encrypt(
|
||||
/** 使用调用方提供的设备本地密钥完成带上下文绑定的 AES-GCM 加密。 */
|
||||
export function sealLocalCache(plaintext: string, encryptionKey: string, context: string): string {
|
||||
const nonce = secureRandomBytes(NONCE_BYTES)
|
||||
const ciphertext = gcm(cacheKey(encryptionKey, context), nonce, utf8ToBytes(context)).encrypt(
|
||||
utf8ToBytes(plaintext),
|
||||
)
|
||||
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('.')
|
||||
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])
|
||||
if (nonce.length !== NONCE_BYTES) throw new Error('[XuqmSDK] LKG cache nonce is invalid.')
|
||||
const plaintext = gcm(cacheKey(secret, context), nonce, utf8ToBytes(context)).decrypt(
|
||||
if (nonce.length !== NONCE_BYTES) {
|
||||
throw new Error('[XuqmSDK] Encrypted local cache nonce is invalid.')
|
||||
}
|
||||
const plaintext = gcm(cacheKey(encryptionKey, context), nonce, utf8ToBytes(context)).decrypt(
|
||||
decodeBase64(parts[2]),
|
||||
)
|
||||
return utf8BytesToString(plaintext)
|
||||
}
|
||||
|
||||
export function isCacheRecordFresh(cachedAt: number, now: number, ttlMs: number): boolean {
|
||||
return Number.isFinite(cachedAt) && cachedAt <= now && ttlMs > 0 && now - cachedAt <= ttlMs
|
||||
}
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import { hmacSha256Base64Url, sha256Hex } from '../src/crypto'
|
||||
import { sha256Hex } from '../src/crypto'
|
||||
|
||||
test('sha256Hex uses the canonical lowercase digest', () => {
|
||||
assert.equal(
|
||||
@ -9,7 +9,3 @@ test('sha256Hex uses the canonical lowercase digest', () => {
|
||||
'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 { apiRequest, configureHttp } from '../src/http'
|
||||
import { _setUserInfo, initConfigFromRemote } from '../src/config'
|
||||
|
||||
test('apiRequest aborts a stalled request at the shared timeout boundary', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
@ -24,3 +25,32 @@ test('apiRequest aborts a stalled request at the shared timeout boundary', async
|
||||
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 path = require('node:path')
|
||||
const test = require('node:test')
|
||||
const crypto = require('node:crypto')
|
||||
|
||||
const { withXuqmConfig } = require('../metro')
|
||||
const { canonicalJson } = require('../security/canonical-json.js')
|
||||
const trustedConfigKeys = require('../security/trusted-signing-keys.json')
|
||||
|
||||
const CONFIG_VECTOR =
|
||||
'XUQM-CONFIG-V2.xuqm-config-dev-2026-07.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.MYRqE2BtQG0PvSnG3TLrr4PXd10C1cHhNA2iJLfy60XmQ5ixQBTCpn4e2g2YN0QQbZY0LVnou1rGONIzMrT7VidOMi_lHSfUf2BXnqlCIRm4yKcYCvALvrIF-Llv2xQWla0BTpKNq2Yuejpsw0oGTZGjNkvmlbBu8JJHAckHi-wP1sAgZuKVjjtJnjc7venWzaa75KwsqOYiNOkhIjWqTLNMG1siCMbX96d2A15KJYRUpwd-rBSpgjmOFLdH6DDaj2ZqCqQg6NRJprjsEorrCAnDjRjwoMKqHiIwHErfihw3v0AO19MJUzCeYzCzU2WfFw.Z-NMbyec47P4K_lfx3l7s3jZJVpd7ru8ySj2eBvXfteU4tVBoJ_3N7IRPMeiv1lUxMchsvNg8okxYpcBs4_2CQ'
|
||||
const TEST_KEY_ID = 'xuqm-config-test-no-secret'
|
||||
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', () => {
|
||||
const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'xuqm-metro-'))
|
||||
|
||||
@ -1,7 +1,12 @@
|
||||
import assert from 'node:assert/strict'
|
||||
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', () => {
|
||||
assert.equal(
|
||||
@ -22,3 +27,37 @@ test('cached update availability is preserved exactly', () => {
|
||||
assert.equal(parseCachedRemoteConfig({ updateEnabled: true })?.updateEnabled, true)
|
||||
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,
|
||||
schemaVersion: 2,
|
||||
serverUrl: 'https://dev.xuqinmin.com',
|
||||
signingKey: 'secret',
|
||||
},
|
||||
{ debug: true },
|
||||
)
|
||||
|
||||
@ -1,28 +1,23 @@
|
||||
import assert from 'node:assert/strict'
|
||||
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', () => {
|
||||
const encrypted = sealLocalCache('{"enabled":true}', 'signed-secret', 'app|package|environment')
|
||||
assert.equal(
|
||||
openLocalCache(encrypted, 'signed-secret', 'app|package|environment'),
|
||||
'{"enabled":true}',
|
||||
)
|
||||
test('secure random bytes never fall back to a weak random source', () => {
|
||||
assert.equal(secureRandomBytes(32).length, 32)
|
||||
assert.throws(() => secureRandomBytes(0), /positive integer/)
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
test('LKG cache rejects tampering and a different package/environment context', () => {
|
||||
const encrypted = sealLocalCache('payload', 'signed-secret', 'app|package|environment')
|
||||
test('device-local cache rejects tampering and a different application context', () => {
|
||||
const encrypted = sealLocalCache('payload', 'device-key', 'bugcollect|app')
|
||||
const parts = encrypted.split('.')
|
||||
parts[2] = `${parts[2][0] === 'A' ? 'B' : 'A'}${parts[2].slice(1)}`
|
||||
assert.throws(() => openLocalCache(parts.join('.'), 'signed-secret', 'app|package|environment'))
|
||||
assert.throws(() => openLocalCache(encrypted, 'signed-secret', 'app|other|environment'))
|
||||
})
|
||||
|
||||
test('LKG freshness rejects future timestamps and expired records', () => {
|
||||
const now = 1_000_000
|
||||
assert.equal(isCacheRecordFresh(now - 1_000, now, 2_000), true)
|
||||
assert.equal(isCacheRecordFresh(now - 2_001, now, 2_000), false)
|
||||
assert.equal(isCacheRecordFresh(now + 1, now, 2_000), false)
|
||||
assert.throws(() => openLocalCache(parts.join('.'), 'device-key', 'bugcollect|app'))
|
||||
assert.throws(() => openLocalCache(encrypted, 'device-key', 'bugcollect|other'))
|
||||
})
|
||||
|
||||
@ -160,9 +160,29 @@ const plan = await UpdateSDK.checkAndInstallPlugin('prescription', {
|
||||
|
||||
SDK 会验证所有已安装模块在目标 common 下仍兼容,并拒绝 `builtAgainstNativeBaselineId` 与当前完整包不一致的候选,然后整组下载、校验、staging、激活、确认或回滚。Bundle 版本与事务状态只保存在原生 state;宿主不得另建 AsyncStorage 版本表,也不得逐层传递版本或路径状态。
|
||||
|
||||
所有公开检查、下载和安装 API 都要求平台明确开启 `features.update`。关闭时在任何网络
|
||||
或原生操作前抛出 `UpdateDisabledError`(`code: UPDATE_DISABLED`);登录、启动等自动
|
||||
后台路径会吞掉该状态并正常返回,不影响宿主使用。
|
||||
整包检查返回结构化结果,不展示 UI,也不以 `null` 混淆状态:
|
||||
|
||||
```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` 和
|
||||
`nativeBaselineId`;原生基线缺失时客户端在请求前阻断。服务端候选必须提供
|
||||
|
||||
@ -13,9 +13,15 @@ import com.facebook.react.bridge.ReactMethod;
|
||||
import com.facebook.react.bridge.WritableMap;
|
||||
import com.facebook.react.module.annotations.ReactModule;
|
||||
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.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.UpdateInfo;
|
||||
import com.xuqm.sdk.update.model.UpdateInstallCallback;
|
||||
import com.xuqm.sdk.update.model.UpdateInstallTask;
|
||||
import com.xuqm.sdk.update.model.UpdateProgressListener;
|
||||
@ -39,12 +45,32 @@ public final class XuqmAppUpdateModule extends ReactContextBaseJavaModule {
|
||||
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
|
||||
public synchronized void downloadAndInstall(
|
||||
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");
|
||||
@ -59,7 +85,8 @@ public final class XuqmAppUpdateModule extends ReactContextBaseJavaModule {
|
||||
downloadUrl,
|
||||
versionCode,
|
||||
sha256,
|
||||
Math.max(0, retryCount));
|
||||
Math.max(0, retryCount),
|
||||
parseDestination(destination));
|
||||
activeTask = UpdateSDK.startDownloadAndInstall(
|
||||
getReactApplicationContext(),
|
||||
request,
|
||||
@ -69,11 +96,44 @@ public final class XuqmAppUpdateModule extends ReactContextBaseJavaModule {
|
||||
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
|
||||
public void onSuccess() {
|
||||
public void onSuccess(@NonNull String localPath, @Nullable String publicUri) {
|
||||
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
|
||||
@ -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
|
||||
public synchronized void cancelDownload(Promise promise) {
|
||||
UpdateInstallTask task = activeTask;
|
||||
@ -131,6 +222,60 @@ public final class XuqmAppUpdateModule extends ReactContextBaseJavaModule {
|
||||
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) {
|
||||
WritableMap payload = Arguments.createMap();
|
||||
payload.putDouble("bytesDownloaded", progress.getBytesDownloaded());
|
||||
|
||||
@ -1,10 +1,13 @@
|
||||
import { NativeEventEmitter, NativeModules, Platform } from 'react-native'
|
||||
import type { UpdateDownloadProgress } from './UpdateSDK'
|
||||
import type { AppUpdateInfo, UpdateDownloadProgress } from './UpdateSDK'
|
||||
|
||||
export type AppInstallErrorCode =
|
||||
| 'DOWNLOAD_FAILED'
|
||||
| 'DOWNLOAD_CANCELLED'
|
||||
| 'HASH_MISMATCH'
|
||||
| 'PACKAGE_MISMATCH'
|
||||
| 'SIGNATURE_MISMATCH'
|
||||
| 'AUTHORIZATION_EXPIRED'
|
||||
| 'INSTALL_PERMISSION_REQUIRED'
|
||||
| 'INSTALLER_UNAVAILABLE'
|
||||
| 'ANDROID_SDK_MISSING'
|
||||
@ -23,12 +26,24 @@ export class AppInstallError extends Error {
|
||||
}
|
||||
|
||||
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(
|
||||
downloadUrl: string,
|
||||
versionCode: number,
|
||||
sha256: string | null,
|
||||
retryCount: number,
|
||||
destination: UpdateDownloadDestination,
|
||||
): Promise<void>
|
||||
installDownloaded(versionCode: number, sha256: string | null): Promise<void>
|
||||
openStore(marketUrl: string | null, appStoreUrl: string | null): Promise<boolean>
|
||||
cancelDownload(): Promise<void>
|
||||
openInstallPermissionSettings(): Promise<boolean>
|
||||
}
|
||||
@ -52,6 +67,9 @@ function asInstallError(error: unknown): AppInstallError {
|
||||
'DOWNLOAD_FAILED',
|
||||
'DOWNLOAD_CANCELLED',
|
||||
'HASH_MISMATCH',
|
||||
'PACKAGE_MISMATCH',
|
||||
'SIGNATURE_MISMATCH',
|
||||
'AUTHORIZATION_EXPIRED',
|
||||
'INSTALL_PERMISSION_REQUIRED',
|
||||
'INSTALLER_UNAVAILABLE',
|
||||
'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 = {
|
||||
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(
|
||||
input: { downloadUrl: string; versionCode: number; sha256?: string },
|
||||
options: {
|
||||
signal?: AbortSignal
|
||||
retryCount?: number
|
||||
destination?: UpdateDownloadDestination
|
||||
onProgress?: (progress: UpdateDownloadProgress) => void
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
@ -94,6 +186,7 @@ export const NativeAppUpdate = {
|
||||
input.versionCode,
|
||||
input.sha256 ?? null,
|
||||
Math.max(0, Math.trunc(options.retryCount ?? 1)),
|
||||
options.destination ?? 'SDK_PRIVATE',
|
||||
)
|
||||
} catch (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> {
|
||||
await getModule().cancelDownload()
|
||||
},
|
||||
|
||||
@ -50,7 +50,23 @@ export interface AppUpdateInfo {
|
||||
requiresLogin?: boolean
|
||||
alreadyDownloaded?: boolean
|
||||
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
|
||||
@ -378,11 +394,59 @@ export const UpdateSDK = {
|
||||
return [...pluginRegistry.values()].map(plugin => ({ ...plugin }))
|
||||
},
|
||||
|
||||
async checkAppUpdate(bypassIgnore = false): Promise<AppUpdateInfo> {
|
||||
await awaitInitialization()
|
||||
requireUpdateEnabled()
|
||||
async checkAppUpdate(bypassIgnore = false): Promise<AppUpdateCheckResult> {
|
||||
try {
|
||||
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()) {
|
||||
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> => {
|
||||
if (bypassIgnore || !info.needsUpdate || info.forceUpdate) return info
|
||||
@ -393,14 +457,23 @@ export const UpdateSDK = {
|
||||
}
|
||||
if (!bypassIgnore) {
|
||||
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 device = await getDeviceInfo()
|
||||
const params: Record<string, string> = {
|
||||
appKey: config.appKey,
|
||||
platform: Platform.OS === 'android' ? 'ANDROID' : 'IOS',
|
||||
platform: 'IOS',
|
||||
currentVersionCode: String(getAppVersionCode()),
|
||||
currentVersionName: getAppVersionName() ?? '',
|
||||
deviceId: device.deviceId,
|
||||
@ -412,18 +485,35 @@ export const UpdateSDK = {
|
||||
if (userId) params.userId = userId
|
||||
if (bypassIgnore) params.bypassIgnore = 'true'
|
||||
|
||||
const raw = await apiRequest<RawAppUpdateInfo>('/api/v1/updates/app/check', {
|
||||
skipAuth: true,
|
||||
params,
|
||||
signal: activeSessionAbort.signal,
|
||||
})
|
||||
const normalized: AppUpdateInfo = {
|
||||
...raw,
|
||||
downloadUrl: normalizeDownloadUrl(raw.downloadUrl),
|
||||
sha256: raw.sha256 ?? raw.apkHash ?? undefined,
|
||||
try {
|
||||
const raw = await apiRequest<RawAppUpdateInfo>('/api/v1/updates/app/check', {
|
||||
skipAuth: true,
|
||||
params,
|
||||
signal: activeSessionAbort.signal,
|
||||
})
|
||||
const normalized: AppUpdateInfo = {
|
||||
...raw,
|
||||
downloadUrl: normalizeDownloadUrl(raw.downloadUrl),
|
||||
sha256: raw.sha256 ?? raw.apkHash ?? undefined,
|
||||
}
|
||||
await writeUpdateCache(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> {
|
||||
@ -440,24 +530,38 @@ export const UpdateSDK = {
|
||||
return getIgnoredVersionCode()
|
||||
},
|
||||
|
||||
async openStore(appStoreUrl?: string, marketUrl?: string): Promise<void> {
|
||||
const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl
|
||||
if (url) await Linking.openURL(url)
|
||||
async openStore(updateInfo: AppUpdateInfo): Promise<boolean> {
|
||||
if (Platform.OS === 'android') {
|
||||
return NativeAppUpdate.openStore(updateInfo)
|
||||
}
|
||||
const url = Platform.OS === 'ios' ? updateInfo.appStoreUrl : undefined
|
||||
if (!url) return false
|
||||
await Linking.openURL(url)
|
||||
return true
|
||||
},
|
||||
|
||||
async downloadApk(
|
||||
updateInfo: AppUpdateInfo,
|
||||
options: { signal?: AbortSignal; onProgress?: (progress: UpdateDownloadProgress) => void } = {},
|
||||
): Promise<ArrayBuffer> {
|
||||
options: {
|
||||
signal?: AbortSignal
|
||||
retryCount?: number
|
||||
destination?: import('./NativeAppUpdate').UpdateDownloadDestination
|
||||
onProgress?: (progress: UpdateDownloadProgress) => void
|
||||
} = {},
|
||||
): Promise<import('./NativeAppUpdate').DownloadedAppUpdate> {
|
||||
await awaitInitialization()
|
||||
requireUpdateEnabled()
|
||||
if (!updateInfo.downloadUrl) throw new Error('[UpdateSDK] App update has no downloadUrl')
|
||||
const bytes = await downloadBytes(updateInfo.downloadUrl, {
|
||||
...options,
|
||||
signal: currentSessionSignal(options.signal),
|
||||
})
|
||||
if (updateInfo.sha256) assertSha256(bytes, updateInfo.sha256, 'APK')
|
||||
return bytes.buffer.slice(bytes.byteOffset, bytes.byteOffset + bytes.byteLength) as ArrayBuffer
|
||||
if (!updateInfo.downloadUrl || !updateInfo.versionCode) {
|
||||
throw new Error('[UpdateSDK] App update requires downloadUrl and versionCode')
|
||||
}
|
||||
return NativeAppUpdate.download(
|
||||
{
|
||||
downloadUrl: updateInfo.downloadUrl,
|
||||
versionCode: updateInfo.versionCode,
|
||||
sha256: updateInfo.sha256,
|
||||
},
|
||||
{ ...options, signal: currentSessionSignal(options.signal) },
|
||||
)
|
||||
},
|
||||
|
||||
async downloadAndInstallApp(
|
||||
@ -465,6 +569,7 @@ export const UpdateSDK = {
|
||||
options: {
|
||||
signal?: AbortSignal
|
||||
retryCount?: number
|
||||
destination?: import('./NativeAppUpdate').UpdateDownloadDestination
|
||||
onProgress?: (progress: UpdateDownloadProgress) => void
|
||||
} = {},
|
||||
): Promise<void> {
|
||||
@ -660,8 +765,11 @@ export const UpdateSDK = {
|
||||
|
||||
/** Pure check: full package has priority and no download or activation happens here. */
|
||||
async checkStartupUpdate(): Promise<StartupUpdateResult> {
|
||||
const appUpdate = await UpdateSDK.checkAppUpdate()
|
||||
if (appUpdate.needsUpdate) return { kind: 'app', update: appUpdate }
|
||||
const appCheck = await UpdateSDK.checkAppUpdate()
|
||||
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')
|
||||
if (!app) throw new Error('[UpdateSDK] Exactly one app module must be registered')
|
||||
const plan = await UpdateSDK.checkPluginRelease(app.moduleId)
|
||||
@ -741,10 +849,11 @@ export const UpdateSDK = {
|
||||
},
|
||||
|
||||
/** @internal 共享登录态变化时取消旧灰度授权,并为新用户自动补检一次。 */
|
||||
async _handleSessionChange(loggedIn: boolean): Promise<void> {
|
||||
async _handleSessionChange(userId: string | null): Promise<void> {
|
||||
await NativeAppUpdate.syncUser(userId)
|
||||
await clearSessionUpdateState()
|
||||
if (
|
||||
!loggedIn ||
|
||||
!userId ||
|
||||
!getConfig().updateEnabled ||
|
||||
!getConfig().updateRequiresLogin ||
|
||||
getConfig().debug
|
||||
|
||||
@ -6,7 +6,7 @@ import {
|
||||
import { UpdateSDK } from './UpdateSDK'
|
||||
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())
|
||||
_registerBundleIdentityProvider('rn-update', resolveCurrentBundleIdentity)
|
||||
|
||||
@ -14,13 +14,20 @@ export { StaleReleaseSetError, UpdateDisabledError, UpdateSDK } from './UpdateSD
|
||||
export type {
|
||||
PluginRegistration,
|
||||
AppUpdateInfo,
|
||||
AppUpdateCheckResult,
|
||||
AppUpdateCheckStatus,
|
||||
AppUpdateNetwork,
|
||||
StartupUpdateResult,
|
||||
UpdateDownloadProgress,
|
||||
} from './UpdateSDK'
|
||||
export { NativeBundle } from './NativeBundle'
|
||||
export { refreshPluginAssetResolver } from './PluginAssets'
|
||||
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 { planReleaseSet, ReleaseSetCompatibilityError } from './releaseSet'
|
||||
export type {
|
||||
|
||||
@ -75,18 +75,17 @@ Host;Host 卸载会以 `host_unmounted` 关闭其全部页面。
|
||||
## 页面栈与系统返回
|
||||
|
||||
每次 `openWebView` 都生成独立页面和 Handle。页面栈只显示顶层页面,关闭后恢复下一层
|
||||
页面及其导航状态。默认导航栏的左返回按钮只在存在网页历史时显示,且只执行网页后退;
|
||||
右侧关闭按钮用于明确关闭当前页。
|
||||
页面及其导航状态。默认导航栏的左返回按钮常驻:存在网页历史时只执行网页后退,没有
|
||||
历史时需要在 1 秒内再次点击才关闭当前页,第一次点击显示非阻塞提示。Android 物理
|
||||
返回使用完全相同的规则。右侧关闭按钮常驻并立即关闭当前页。
|
||||
|
||||
Android 系统返回先执行网页历史返回,没有历史时默认关闭当前页。需要防止误退出的宿主
|
||||
可以启用双击关闭,并自行决定首次按键的提示样式:
|
||||
宿主可以替换第一次返回时的提示;只有明确需要改变默认交互的宿主才应覆盖关闭模式或
|
||||
间隔:
|
||||
|
||||
```tsx
|
||||
const page = openWebView({
|
||||
behavior: {
|
||||
hardwareBack: {
|
||||
closeMode: 'doublePress',
|
||||
doublePressIntervalMs: 1_000,
|
||||
onFirstClosePress: () => showToast('再按一次退出当前页面'),
|
||||
},
|
||||
},
|
||||
@ -98,7 +97,8 @@ const page = openWebView({
|
||||
`page.resume()`;暂停不等于关闭,不触发 `closed`,也不创建第二份 WebView。
|
||||
|
||||
导航标题和显隐修改属于当前页面,不能污染下层页面。H5 隐藏原生导航栏但保留状态栏
|
||||
时,Bridge 可以从页面上下文取得真实安全区高度。
|
||||
时,Bridge 可以从页面上下文取得真实安全区高度。设置网络导航栏背景图片时,配置接收
|
||||
后立即返回成功,图片由原生视图异步加载,不以图片探测或下载结果阻塞 H5 调用。
|
||||
|
||||
## 权限
|
||||
|
||||
@ -110,7 +110,22 @@ SDK 不向 Manifest/Info.plist 注入敏感权限。宿主按启用能力声明
|
||||
```
|
||||
|
||||
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
|
||||
pnpm --dir packages/xwebview typecheck
|
||||
pnpm --dir packages/xwebview test
|
||||
pnpm --dir packages/xwebview pack:check
|
||||
```
|
||||
|
||||
@ -14,6 +14,7 @@
|
||||
"registry": "https://nexus.xuqinmin.com/repository/npm-hosted/"
|
||||
},
|
||||
"scripts": {
|
||||
"pack:check": "corepack pnpm pack --dry-run",
|
||||
"test": "node --import tsx --test tests/*.test.ts",
|
||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||
},
|
||||
|
||||
@ -6,7 +6,16 @@ import React, {
|
||||
useState,
|
||||
useSyncExternalStore,
|
||||
} 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 Svg, { Defs, LinearGradient, Rect, Stop } from 'react-native-svg'
|
||||
import { SafeAreaProvider, useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
@ -17,6 +26,11 @@ import {
|
||||
subscribeXWebViewRuntime,
|
||||
type XWebViewPage,
|
||||
} from './runtime'
|
||||
import {
|
||||
DEFAULT_BACK_CLOSE_INTERVAL_MS,
|
||||
decideBackPress,
|
||||
type PendingBackClose,
|
||||
} from './backPolicy'
|
||||
import type {
|
||||
XWebViewMenuItem,
|
||||
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 { config, handle, navigationState, presentation } = page
|
||||
const navigationBar = config.navigationBar ?? {}
|
||||
const backButton = {
|
||||
color: navigationBar.backButton?.color ?? presentation.titleColor,
|
||||
visible: navigationBar.backButton?.visible ?? navigationState.canGoBack,
|
||||
// 默认只在网页确有上级历史时展示;右侧关闭按钮始终承担关闭当前容器的职责。
|
||||
visible: navigationState.canGoBack && (navigationBar.backButton?.visible ?? true),
|
||||
}
|
||||
const closeButton = {
|
||||
color: navigationBar.closeButton?.color ?? presentation.titleColor,
|
||||
@ -182,6 +205,19 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe
|
||||
label: '复制链接',
|
||||
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],
|
||||
)
|
||||
@ -190,6 +226,7 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe
|
||||
background: presentation.background,
|
||||
backButton,
|
||||
closeButton,
|
||||
onBackPress,
|
||||
title: {
|
||||
color: presentation.titleColor,
|
||||
mode: navigationBar.title?.mode,
|
||||
@ -207,11 +244,7 @@ function NavigationBar({ page, topInset }: { page: XWebViewPage; topInset: numbe
|
||||
<View style={styles.navigationRow}>
|
||||
<View style={styles.navigationSide}>
|
||||
{backButton.visible ? (
|
||||
<Pressable
|
||||
hitSlop={HIT_SLOP}
|
||||
onPress={() => handle.goBack()}
|
||||
style={styles.navigationButton}
|
||||
>
|
||||
<Pressable hitSlop={HIT_SLOP} onPress={onBackPress} style={styles.navigationButton}>
|
||||
<IconBack color={backButton.color} size={24} />
|
||||
</Pressable>
|
||||
) : 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 { config, presentation } = page
|
||||
const configuredStyle = config.statusBar?.contentStyle ?? 'auto'
|
||||
@ -284,7 +325,11 @@ function HostPage({ active, page }: { active: boolean; page: XWebViewPage }) {
|
||||
/>
|
||||
) : null}
|
||||
{presentation.navigationBarVisible ? (
|
||||
<NavigationBar page={page} topInset={presentation.statusBarVisible ? insets.top : 0} />
|
||||
<NavigationBar
|
||||
onBackPress={onBackPress}
|
||||
page={page}
|
||||
topInset={presentation.statusBarVisible ? insets.top : 0}
|
||||
/>
|
||||
) : null}
|
||||
<XWebViewView active={active} config={config} pageId={page.id} statusBarHeight={insets.top} />
|
||||
</View>
|
||||
@ -302,38 +347,88 @@ export function XWebViewHost() {
|
||||
)
|
||||
|
||||
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 page = [...snapshot.pages].reverse().find(item => !item.suspended)
|
||||
if (!page) return
|
||||
if (page.navigationState.canGoBack) {
|
||||
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])
|
||||
if (page) handlePageBack(page)
|
||||
}, [handlePageBack, snapshot.pages])
|
||||
|
||||
const visiblePages = snapshot.pages.filter(page => !page.suspended)
|
||||
const activePageId = visiblePages.at(-1)?.id
|
||||
useEffect(() => {
|
||||
pendingBackClose.current = null
|
||||
hideBackHint()
|
||||
}, [activePageId, hideBackHint])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@ -346,9 +441,19 @@ export function XWebViewHost() {
|
||||
>
|
||||
<SafeAreaProvider>
|
||||
<View style={styles.host}>
|
||||
{snapshot.pages.map((page, index) => (
|
||||
<HostPage key={page.id} active={page.id === activePageId} page={page} />
|
||||
{snapshot.pages.map(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>
|
||||
</SafeAreaProvider>
|
||||
</Modal>
|
||||
@ -356,6 +461,22 @@ export function XWebViewHost() {
|
||||
}
|
||||
|
||||
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: {
|
||||
display: 'none',
|
||||
},
|
||||
|
||||
@ -2,7 +2,7 @@ import { useCallback } from 'react'
|
||||
import { PermissionsAndroid, Platform } from 'react-native'
|
||||
|
||||
import type { XWebViewPermissionRequest, XWebViewPermissionsConfig } from './types'
|
||||
import type { WebPermissionKind } from './permissionPolicy'
|
||||
import { isPermissionOriginAllowed, type WebPermissionKind } from './permissionPolicy'
|
||||
|
||||
const ANDROID_PERMISSIONS = {
|
||||
camera: PermissionsAndroid.PERMISSIONS.CAMERA,
|
||||
@ -57,8 +57,11 @@ async function requestAndroidPermissions(resources: WebPermissionKind[]): Promis
|
||||
*/
|
||||
export function useXWebViewPermissionDecision(config: XWebViewPermissionsConfig = {}) {
|
||||
return useCallback(
|
||||
async (origin: string, resources: WebPermissionKind[]) => {
|
||||
async (origin: string, documentUrl: string, resources: WebPermissionKind[]) => {
|
||||
if (resources.length === 0) return false
|
||||
if (!isPermissionOriginAllowed(origin, documentUrl, config.allowedOrigins ?? [])) {
|
||||
return false
|
||||
}
|
||||
if (!(await requestFromHost(config, origin, resources))) return false
|
||||
if (Platform.OS !== 'android') return true
|
||||
return requestAndroidPermissions(resources)
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
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 WebView from 'react-native-webview'
|
||||
import type {
|
||||
@ -161,16 +161,6 @@ function isUriSource(source: XWebViewSource): source is Extract<XWebViewSource,
|
||||
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 = {
|
||||
active?: boolean
|
||||
config: XWebViewConfig
|
||||
@ -262,10 +252,9 @@ export function XWebViewView({
|
||||
getStatusBarHeight: () => statusBarHeight,
|
||||
isActive: () => (pageId ? isActivePage(pageId) : active),
|
||||
pageId: resolvedPageId,
|
||||
updateNavigation: async patch => {
|
||||
if (patch.background?.image) {
|
||||
await validateImageSource(patch.background.image)
|
||||
}
|
||||
updateNavigation: patch => {
|
||||
// 网络图片由 ImageBackground 异步加载;Bridge 设置成功只代表配置已接收,
|
||||
// 不等待图片探测或下载,避免慢网络阻塞 H5 回调。
|
||||
if (pageId) updatePageNavigation(pageId, patch)
|
||||
},
|
||||
updateWindow: patch => {
|
||||
@ -376,13 +365,14 @@ export function XWebViewView({
|
||||
if (parsed.__xwv === 'permission') {
|
||||
const id = String(parsed.id ?? '')
|
||||
const origin = String(parsed.origin ?? '')
|
||||
const documentUrl = event.nativeEvent.url || navigationState.url
|
||||
const resources = Array.isArray(parsed.resources)
|
||||
? parsed.resources.filter(
|
||||
(value): value is 'camera' | 'microphone' =>
|
||||
value === 'camera' || value === 'microphone',
|
||||
)
|
||||
: []
|
||||
void decidePermission(origin, resources)
|
||||
void decidePermission(origin, documentUrl, resources)
|
||||
.then(granted => {
|
||||
webViewRef.current?.injectJavaScript(
|
||||
`window.__XWV_PERMISSION_REPLY&&window.__XWV_PERMISSION_REPLY(${JSON.stringify(id)},${granted});true;`,
|
||||
@ -420,6 +410,7 @@ export function XWebViewView({
|
||||
config.bridge,
|
||||
decidePermission,
|
||||
downloads,
|
||||
navigationState.url,
|
||||
processBlobDownload,
|
||||
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'
|
||||
|
||||
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 内核返回的资源名称归一为稳定的业务权限。 */
|
||||
export function webPermissionKind(resource: string): WebPermissionKind | null {
|
||||
const normalized = resource.toUpperCase()
|
||||
|
||||
@ -84,6 +84,8 @@ export type XWebViewNavigationRenderContext = XWebViewMenuContext & {
|
||||
background: XWebViewNavigationBackground
|
||||
closeButton: XWebViewNavigationButton
|
||||
backButton: XWebViewNavigationButton
|
||||
/** 执行与默认左上角/物理返回一致的网页后退或双击关闭规则。 */
|
||||
onBackPress(): void
|
||||
title: Required<Pick<XWebViewNavigationTitle, 'color' | 'text'>> &
|
||||
Pick<XWebViewNavigationTitle, 'mode'>
|
||||
}
|
||||
@ -120,6 +122,11 @@ export type XWebViewPermissionRequest = {
|
||||
}
|
||||
|
||||
export type XWebViewPermissionsConfig = {
|
||||
/**
|
||||
* 允许请求摄像头或麦克风的网页 origin。仅支持精确的 http/https origin,
|
||||
* 例如 https://example.com;跨域导航后的页面必须独立列入白名单。
|
||||
*/
|
||||
allowedOrigins?: readonly string[]
|
||||
camera?: boolean
|
||||
microphone?: boolean
|
||||
onRequest?: (request: XWebViewPermissionRequest) => void
|
||||
@ -189,7 +196,8 @@ export type XWebViewBridgePageContext = {
|
||||
getStatusBarHeight(): number
|
||||
isActive(): boolean
|
||||
pageId: string
|
||||
updateNavigation(patch: XWebViewNavigationPatch): void | Promise<void>
|
||||
/** 配置接收后立即返回;网络背景图片由视图异步加载。 */
|
||||
updateNavigation(patch: XWebViewNavigationPatch): void
|
||||
updateWindow(patch: XWebViewWindowPatch): void
|
||||
}
|
||||
|
||||
@ -210,7 +218,7 @@ export type XWebViewBehaviorConfig = {
|
||||
injectedJavaScriptBeforeContentLoaded?: string
|
||||
mixedContent?: 'never' | 'compatibility' | 'always'
|
||||
hardwareBack?: {
|
||||
/** 默认立即关闭;doublePress 表示在指定时间内再次按返回键才关闭。 */
|
||||
/** 默认双击关闭;仅在宿主明确要求时允许改为立即关闭。 */
|
||||
closeMode?: 'immediate' | 'doublePress'
|
||||
doublePressIntervalMs?: number
|
||||
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 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', () => {
|
||||
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()), [])
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
|
||||
正在加载...
在新工单中引用
屏蔽一个用户