From 21ff207ade41358199fed7f7089b57eabe25b970 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Tue, 28 Jul 2026 20:29:06 +0800 Subject: [PATCH] refactor(sdk): unify native update and secure runtime contracts --- docs/IMPLEMENTATION_HANDOFF.md | 35 +++- docs/SDK-API参考.md | 15 +- docs/配置文件规范.md | 17 +- packages/bugcollect/README.md | 9 +- packages/bugcollect/package.json | 1 + packages/bugcollect/src/BugCollect.ts | 1 - packages/bugcollect/src/queue/LogQueue.ts | 18 +- packages/bugcollect/src/queue/queueStorage.ts | 73 ++++++- .../bugcollect/tests/queue-storage.test.ts | 44 +++- packages/common/README.md | 20 +- packages/common/metro/index.js | 3 +- packages/common/package.json | 1 + packages/common/src/config.ts | 9 +- packages/common/src/crypto.ts | 10 - packages/common/src/http.ts | 19 +- packages/common/src/index.ts | 2 +- packages/common/src/remoteConfig.ts | 61 +++++- packages/common/src/sdk.ts | 74 +++---- packages/common/src/secureCache.ts | 39 ++-- packages/common/tests/crypto.test.ts | 6 +- packages/common/tests/http.test.ts | 30 +++ packages/common/tests/metro.test.cjs | 41 +++- packages/common/tests/remote-config.test.ts | 41 +++- packages/common/tests/sdk.test.ts | 1 - packages/common/tests/secureCache.test.ts | 31 ++- packages/update/README.md | 26 ++- .../com/xuqm/update/XuqmAppUpdateModule.java | 153 +++++++++++++- packages/update/src/NativeAppUpdate.ts | 112 +++++++++- packages/update/src/UpdateSDK.ts | 177 +++++++++++++--- packages/update/src/index.ts | 11 +- packages/xwebview/README.md | 32 ++- packages/xwebview/package.json | 1 + packages/xwebview/src/XWebViewHost.tsx | 197 ++++++++++++++---- packages/xwebview/src/XWebViewPermissions.ts | 7 +- packages/xwebview/src/XWebViewView.tsx | 23 +- packages/xwebview/src/backPolicy.ts | 23 ++ packages/xwebview/src/permissionPolicy.ts | 25 +++ packages/xwebview/src/types.ts | 12 +- packages/xwebview/tests/backPolicy.test.ts | 18 ++ .../xwebview/tests/permissionPolicy.test.ts | 34 ++- 40 files changed, 1151 insertions(+), 301 deletions(-) create mode 100644 packages/xwebview/src/backPolicy.ts create mode 100644 packages/xwebview/tests/backPolicy.test.ts diff --git a/docs/IMPLEMENTATION_HANDOFF.md b/docs/IMPLEMENTATION_HANDOFF.md index 92375d9..818650a 100644 --- a/docs/IMPLEMENTATION_HANDOFF.md +++ b/docs/IMPLEMENTATION_HANDOFF.md @@ -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. 本轮目标 diff --git a/docs/SDK-API参考.md b/docs/SDK-API参考.md index 8af1016..138c13a 100644 --- a/docs/SDK-API参考.md +++ b/docs/SDK-API参考.md @@ -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', { diff --git a/docs/配置文件规范.md b/docs/配置文件规范.md index 0953d74..f0432bc 100644 --- a/docs/配置文件规范.md +++ b/docs/配置文件规范.md @@ -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,并由服务端执行真实授权。 diff --git a/packages/bugcollect/README.md b/packages/bugcollect/README.md index f64ac7e..5daaae7 100644 --- a/packages/bugcollect/README.md +++ b/packages/bugcollect/README.md @@ -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。 diff --git a/packages/bugcollect/package.json b/packages/bugcollect/package.json index 3c7943f..07e3f93 100644 --- a/packages/bugcollect/package.json +++ b/packages/bugcollect/package.json @@ -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" }, diff --git a/packages/bugcollect/src/BugCollect.ts b/packages/bugcollect/src/BugCollect.ts index 4765186..660a08f 100644 --- a/packages/bugcollect/src/BugCollect.ts +++ b/packages/bugcollect/src/BugCollect.ts @@ -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) diff --git a/packages/bugcollect/src/queue/LogQueue.ts b/packages/bugcollect/src/queue/LogQueue.ts index b49979a..2a4f93e 100644 --- a/packages/bugcollect/src/queue/LogQueue.ts +++ b/packages/bugcollect/src/queue/LogQueue.ts @@ -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 }, ) { @@ -156,13 +156,14 @@ export class LogQueue { private async _readStored(): Promise { 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 { 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), ) } } diff --git a/packages/bugcollect/src/queue/queueStorage.ts b/packages/bugcollect/src/queue/queueStorage.ts index 4791d50..d9c2ad5 100644 --- a/packages/bugcollect/src/queue/queueStorage.ts +++ b/packages/bugcollect/src/queue/queueStorage.ts @@ -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>() + +type QueueKeyStorage = { + getItem(key: string): Promise + setItem(key: string, value: string): Promise +} + +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 { + 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 }, 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) } diff --git a/packages/bugcollect/tests/queue-storage.test.ts b/packages/bugcollect/tests/queue-storage.test.ts index 1e8f3b2..c36f5e2 100644 --- a/packages/bugcollect/tests/queue-storage.test.ts +++ b/packages/bugcollect/tests/queue-storage.test.ts @@ -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() + 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', () => { diff --git a/packages/common/README.md b/packages/common/README.md index 72696d3..b5b9328 100644 --- a/packages/common/README.md +++ b/packages/common/README.md @@ -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` 等项目通用能力。 diff --git a/packages/common/metro/index.js b/packages/common/metro/index.js index 9e90d11..a6ae508 100644 --- a/packages/common/metro/index.js +++ b/packages/common/metro/index.js @@ -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} 不能为空`) } diff --git a/packages/common/package.json b/packages/common/package.json index 376cdac..770b9fe 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -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" }, diff --git a/packages/common/src/config.ts b/packages/common/src/config.ts index 0de5c5c..dd103cb 100644 --- a/packages/common/src/config.ts +++ b/packages/common/src/config.ts @@ -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, } } diff --git a/packages/common/src/crypto.ts b/packages/common/src/crypto.ts index 1fb4a9d..214dd73 100644 --- a/packages/common/src/crypto.ts +++ b/packages/common/src/crypto.ts @@ -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)) diff --git a/packages/common/src/http.ts b/packages/common/src/http.ts index eb02325..e12dbac 100644 --- a/packages/common/src/http.ts +++ b/packages/common/src/http.ts @@ -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 { 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( path: string, options: { @@ -68,16 +58,9 @@ export async function apiRequest( } 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 } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 6906669..d5c45ae 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -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' diff --git a/packages/common/src/remoteConfig.ts b/packages/common/src/remoteConfig.ts index b8b3887..3773c6e 100644 --- a/packages/common/src/remoteConfig.ts +++ b/packages/common/src/remoteConfig.ts @@ -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 + } +} diff --git a/packages/common/src/sdk.ts b/packages/common/src/sdk.ts index 798a78a..42019df 100644 --- a/packages/common/src/sdk.ts +++ b/packages/common/src/sdk.ts @@ -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 | null = null let _resolvedConfigInitPromise: Promise | null = null @@ -32,12 +35,7 @@ let _resolvedConfigRequest: { let _initializationKey: string | null = null let _sessionTransition: Promise = Promise.resolve() let _activeSessionKey: string | null = null -const REMOTE_CONFIG_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1_000 const REMOTE_CONFIG_CACHE_PREFIX = '@xuqm_common:remote_config:v1:' -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 { - 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 { - 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 { +function startInitialization(options: InternalInitOptions): Promise { 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 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 diff --git a/packages/common/src/secureCache.ts b/packages/common/src/secureCache.ts index ce417ab..d54a682 100644 --- a/packages/common/src/secureCache.ts +++ b/packages/common/src/secureCache.ts @@ -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 -} diff --git a/packages/common/tests/crypto.test.ts b/packages/common/tests/crypto.test.ts index 22ea0e1..3cee1ba 100644 --- a/packages/common/tests/crypto.test.ts +++ b/packages/common/tests/crypto.test.ts @@ -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_-]+$/) -}) diff --git a/packages/common/tests/http.test.ts b/packages/common/tests/http.test.ts index 0f5637e..109ea5c 100644 --- a/packages/common/tests/http.test.ts +++ b/packages/common/tests/http.test.ts @@ -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 + } +}) diff --git a/packages/common/tests/metro.test.cjs b/packages/common/tests/metro.test.cjs index 8838e61..69d9713 100644 --- a/packages/common/tests/metro.test.cjs +++ b/packages/common/tests/metro.test.cjs @@ -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-')) diff --git a/packages/common/tests/remote-config.test.ts b/packages/common/tests/remote-config.test.ts index 347f5c0..692077f 100644 --- a/packages/common/tests/remote-config.test.ts +++ b/packages/common/tests/remote-config.test.ts @@ -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) +}) diff --git a/packages/common/tests/sdk.test.ts b/packages/common/tests/sdk.test.ts index d14da6f..3b5f36e 100644 --- a/packages/common/tests/sdk.test.ts +++ b/packages/common/tests/sdk.test.ts @@ -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 }, ) diff --git a/packages/common/tests/secureCache.test.ts b/packages/common/tests/secureCache.test.ts index be516ed..2409a8d 100644 --- a/packages/common/tests/secureCache.test.ts +++ b/packages/common/tests/secureCache.test.ts @@ -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')) }) diff --git a/packages/update/README.md b/packages/update/README.md index 20d89a4..bb8cb7b 100644 --- a/packages/update/README.md +++ b/packages/update/README.md @@ -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`;原生基线缺失时客户端在请求前阻断。服务端候选必须提供 diff --git a/packages/update/android/src/main/java/com/xuqm/update/XuqmAppUpdateModule.java b/packages/update/android/src/main/java/com/xuqm/update/XuqmAppUpdateModule.java index c383e27..f2f1416 100644 --- a/packages/update/android/src/main/java/com/xuqm/update/XuqmAppUpdateModule.java +++ b/packages/update/android/src/main/java/com/xuqm/update/XuqmAppUpdateModule.java @@ -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()); diff --git a/packages/update/src/NativeAppUpdate.ts b/packages/update/src/NativeAppUpdate.ts index bb0b410..339cfaf 100644 --- a/packages/update/src/NativeAppUpdate.ts +++ b/packages/update/src/NativeAppUpdate.ts @@ -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 + checkAppUpdate(userId: string | null, bypassIgnore: boolean): Promise + download( + downloadUrl: string, + versionCode: number, + sha256: string | null, + retryCount: number, + destination: UpdateDownloadDestination, + ): Promise downloadAndInstall( downloadUrl: string, versionCode: number, sha256: string | null, retryCount: number, + destination: UpdateDownloadDestination, ): Promise + installDownloaded(versionCode: number, sha256: string | null): Promise + openStore(marketUrl: string | null, appStoreUrl: string | null): Promise cancelDownload(): Promise openInstallPermissionSettings(): Promise } @@ -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 { + if (Platform.OS !== 'android') return Promise.resolve() + return getModule().syncUser(userId) + }, + + check(userId: string | null, bypassIgnore = false): Promise { + 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 { + 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 { @@ -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 { + 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 { + if (Platform.OS !== 'android') return Promise.resolve(false) + return getModule().openStore(input.marketUrl ?? null, input.appStoreUrl ?? null) + }, + async cancel(): Promise { await getModule().cancelDownload() }, diff --git a/packages/update/src/UpdateSDK.ts b/packages/update/src/UpdateSDK.ts index 68f1d6d..df3c1ce 100644 --- a/packages/update/src/UpdateSDK.ts +++ b/packages/update/src/UpdateSDK.ts @@ -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 { - await awaitInitialization() - requireUpdateEnabled() + async checkAppUpdate(bypassIgnore = false): Promise { + 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 => { if (bypassIgnore || !info.needsUpdate || info.forceUpdate) return info @@ -393,14 +457,23 @@ export const UpdateSDK = { } if (!bypassIgnore) { const cached = await readUpdateCache(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 = { 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('/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('/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 { @@ -440,24 +530,38 @@ export const UpdateSDK = { return getIgnoredVersionCode() }, - async openStore(appStoreUrl?: string, marketUrl?: string): Promise { - const url = Platform.OS === 'ios' ? appStoreUrl : marketUrl - if (url) await Linking.openURL(url) + async openStore(updateInfo: AppUpdateInfo): Promise { + 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 { + options: { + signal?: AbortSignal + retryCount?: number + destination?: import('./NativeAppUpdate').UpdateDownloadDestination + onProgress?: (progress: UpdateDownloadProgress) => void + } = {}, + ): Promise { 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 { @@ -660,8 +765,11 @@ export const UpdateSDK = { /** Pure check: full package has priority and no download or activation happens here. */ async checkStartupUpdate(): Promise { - 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 { + async _handleSessionChange(userId: string | null): Promise { + await NativeAppUpdate.syncUser(userId) await clearSessionUpdateState() if ( - !loggedIn || + !userId || !getConfig().updateEnabled || !getConfig().updateRequiresLogin || getConfig().debug diff --git a/packages/update/src/index.ts b/packages/update/src/index.ts index 938ffb8..a73fe06 100644 --- a/packages/update/src/index.ts +++ b/packages/update/src/index.ts @@ -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 { diff --git a/packages/xwebview/README.md b/packages/xwebview/README.md index 8a7bade..d3a67d9 100644 --- a/packages/xwebview/README.md +++ b/packages/xwebview/README.md @@ -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 ``` diff --git a/packages/xwebview/package.json b/packages/xwebview/package.json index d4ba41f..aa8298a 100644 --- a/packages/xwebview/package.json +++ b/packages/xwebview/package.json @@ -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" }, diff --git a/packages/xwebview/src/XWebViewHost.tsx b/packages/xwebview/src/XWebViewHost.tsx index 59bba66..35d6bff 100644 --- a/packages/xwebview/src/XWebViewHost.tsx +++ b/packages/xwebview/src/XWebViewHost.tsx @@ -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 {backButton.visible ? ( - handle.goBack()} - style={styles.navigationButton} - > + ) : 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 ? ( - + ) : null} @@ -302,38 +347,88 @@ export function XWebViewHost() { ) useEffect(() => mountXWebViewHost(), []) - const pendingHardwareClose = useRef<{ at: number; pageId: string } | null>(null) + const pendingBackClose = useRef(null) + const hintTimer = useRef | 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 ( - {snapshot.pages.map((page, index) => ( - + {snapshot.pages.map(page => ( + handlePageBack(page)} + page={page} + /> ))} + {showBackHint ? ( + + 再按一次关闭当前页面 + + ) : null} @@ -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', }, diff --git a/packages/xwebview/src/XWebViewPermissions.ts b/packages/xwebview/src/XWebViewPermissions.ts index 9e34b04..0c7baa0 100644 --- a/packages/xwebview/src/XWebViewPermissions.ts +++ b/packages/xwebview/src/XWebViewPermissions.ts @@ -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) diff --git a/packages/xwebview/src/XWebViewView.tsx b/packages/xwebview/src/XWebViewView.tsx index ca5ff70..8532fb9 100644 --- a/packages/xwebview/src/XWebViewView.tsx +++ b/packages/xwebview/src/XWebViewView.tsx @@ -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 { - 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, ], diff --git a/packages/xwebview/src/backPolicy.ts b/packages/xwebview/src/backPolicy.ts new file mode 100644 index 0000000..394639e --- /dev/null +++ b/packages/xwebview/src/backPolicy.ts @@ -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' +} diff --git a/packages/xwebview/src/permissionPolicy.ts b/packages/xwebview/src/permissionPolicy.ts index 972a73b..32e18c9 100644 --- a/packages/xwebview/src/permissionPolicy.ts +++ b/packages/xwebview/src/permissionPolicy.ts @@ -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() diff --git a/packages/xwebview/src/types.ts b/packages/xwebview/src/types.ts index bdd826a..975414f 100644 --- a/packages/xwebview/src/types.ts +++ b/packages/xwebview/src/types.ts @@ -84,6 +84,8 @@ export type XWebViewNavigationRenderContext = XWebViewMenuContext & { background: XWebViewNavigationBackground closeButton: XWebViewNavigationButton backButton: XWebViewNavigationButton + /** 执行与默认左上角/物理返回一致的网页后退或双击关闭规则。 */ + onBackPress(): void title: Required> & Pick } @@ -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 + /** 配置接收后立即返回;网络背景图片由视图异步加载。 */ + 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 diff --git a/packages/xwebview/tests/backPolicy.test.ts b/packages/xwebview/tests/backPolicy.test.ts new file mode 100644 index 0000000..3838c57 --- /dev/null +++ b/packages/xwebview/tests/backPolicy.test.ts @@ -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') +}) diff --git a/packages/xwebview/tests/permissionPolicy.test.ts b/packages/xwebview/tests/permissionPolicy.test.ts index c95bc2c..1d1f3bf 100644 --- a/packages/xwebview/tests/permissionPolicy.test.ts +++ b/packages/xwebview/tests/permissionPolicy.test.ts @@ -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) +})