refactor(rn-sdk): unify host runtime and webview contracts

这个提交包含在:
XuqmGroup 2026-07-18 06:59:57 +08:00
父节点 144a3bcb2d
当前提交 aacfbf0edd
共有 21 个文件被更改,包括 579 次插入109 次删除

查看文件

@ -14,10 +14,12 @@
## Architecture constraints
- Shared behavior must have one canonical implementation across common/update/xwebview/bugcollect.
- Every capability and code category must have one canonical owner and one canonical implementation. Do not scatter the same responsibility across packages or layers, and do not keep copied variants, forwarding compatibility wrappers, duplicate helpers, schemas, validators, models, lifecycle logic, or state machines. Search before adding code; if ownership is wrong, move/refactor the implementation and remove the superseded one in the same change.
- Plugin runtime state, bundle manifests, compatibility checks, atomic activation, crash-safe rollback, and embedded recovery belong to the SDK/tooling rather than each host app.
- CLI operations must support interactive local use and deterministic non-interactive CI use.
- Do not integrate or expand `@xuqm/rn-push` for the current scope.
- Public API changes must update package exports, native files where applicable, tests, examples, CLI help, README, and docs together.
- Removing obsolete, invalid, or duplicate files must also remove every newly empty parent directory in the same change. Source, test, documentation, mock, patch, native-platform, and configuration directories must not be empty or retained with placeholder files such as `.gitkeep`; ignored dependency and build outputs are outside the source-tree audit.
## Delivery constraints

查看文件

@ -1,6 +1,6 @@
# RN SDK 重构实施接管文档
> 状态更新时间2026-07-17
> 状态更新时间2026-07-18
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
> 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。
@ -39,10 +39,10 @@
- 本地已使用 JDK 21 + Gradle 9.3.1 编译通过;RN Bridge 明确输出 Java 17 字节码,不再隐式回落到 Java 8。
- RN 包不再内置第二份 AGP buildscript;宿主负责插件版本,独立夹具使用与 Android SDK 一致的 AGP 9.1.0。
- `pnpm validate`:通过。
- common15 个测试通过。
- common16 个测试通过。
- bugcollect4 个独立测试通过。
- xwebview2 个独立测试通过。
- update CLI6 个测试通过;release-set6 个测试通过。
- xwebview4 个独立测试通过。
- update CLI9 个测试通过;release-set6 个测试通过。
- IM 现有测试3 个通过;IM 不在本轮开发范围。
- 所有 workspace TypeScript typecheck通过。
@ -51,8 +51,11 @@
- common 已提供配置、共享会话、HTTP、日期、文件、加密和 XWebView bridge。
- common 已新增统一流式下载、SHA-256、RFC 文件名解析和 SemVer/版本范围能力;update/xwebview 后续必须复用,不再保留私有实现。
- common-only 宿主缺少配置文件时不会触发自动初始化,已有测试覆盖。
- 配置文件自动初始化遇到临时网络失败时会清理失败 Promise;扩展下一次等待初始化时使用同一加密配置重试,不要求宿主重新传 appKey/URL,也不会产生未处理 Promise 或重复错误日志。
- bugcollect、update、xwebview 通过 `@xuqm/rn-common/internal` 触发一次自动初始化入口。
- update CLI 已能从 schema v3 `xuqm.config.json` 构建 startup/common/app/buz,生成携带 SemVer 兼容范围和原生 API 等级的内嵌 manifest。
- Android bundle 引擎不在 `xuqm.config.json` 重复声明CLI 直接读取宿主 `android/gradle.properties``hermesEnabled`。启用时所有插件统一编译为 Hermes bytecode、组合 source map,并在 manifest/上传表单记录 `bundleFormat`
- common 的唯一 XWebView 契约新增实时导航状态;全屏与内嵌容器统一提供 `canGoBack`、`canGoForward`、当前标题和 URL,并支持宿主配置回调。Bridge 不再重复声明第二份 controller 类型。
- RN update 已有 release-set 纯领域规划器;Android 原生模块已改为整组 staging/激活/确认/回滚,并从 APK assets 恢复内嵌基线。
### 3.3 已确认缺口
@ -69,24 +72,26 @@
## 4. 实施进度
| 工作项 | 状态 | 说明 |
| ----------------------------- | ------ | --------------------------------------------------- |
| 基线审计与门禁 | 完成 | 2026-07-17 本地 validate 全通过 |
| 实时接管文档 | 进行中 | 本文件为唯一实施状态入口 |
| common 公共上下文收敛 | 完成 | 公共基础与共享生命周期 15 测试 |
| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本与策略已统一,4 测试 |
| xwebview 文件能力与测试 | 完成 | 文件能力下沉 common,2 测试通过 |
| update release set 与原生事务 | 进行中 | Android Snapshot 已发布;待 Bridge 编译和模拟器验证 |
| package 内容校验 | 待实施 | 四包完成后执行 |
| Jenkins alpha 发布 | 待实施 | 仅在全部门禁通过后触发 |
| App4 接入 | 暂缓 | 用户要求先完成 RN SDK |
| 工作项 | 状态 | 说明 |
| ----------------------------- | ------ | ----------------------------------------------------- |
| 基线审计与门禁 | 完成 | 2026-07-17 本地 validate 全通过 |
| 实时接管文档 | 进行中 | 本文件为唯一实施状态入口 |
| common 公共上下文收敛 | 完成 | 公共基础与共享生命周期 16 测试 |
| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本与策略已统一,4 测试 |
| xwebview 文件/权限能力与测试 | 完成 | 文件下沉 common,H5 摄像头/麦克风统一授权,4 测试通过 |
| update release set 与原生事务 | 进行中 | Android Snapshot 已发布;待 Bridge 编译和模拟器验证 |
| package 内容校验 | 待实施 | 四包完成后执行 |
| Jenkins alpha 发布 | 待实施 | 仅在全部门禁通过后触发 |
| App4 接入 | 进行中 | App4 已接入已发布 alpha;等待包含初始化重试的新 alpha |
## 5. 下一步操作
1. 用仓库内 `native-test` 编译 RN Java Bridge,再在 Android 宿主补充 release-set crash/recovery 仪器测试。
2. Jenkins 发布 RN 四个 alpha 包后,执行模拟器整包更新与插件更新验证。
3. 收敛 Jenkins 四包依赖发布顺序和 package 内容检查。
4. 后续租户平台实现 `/api/v1/rn/release-set/check`,服务端只返回目标入口与 common 的兼容闭包。
1. 通过 Jenkins 发布包含 common 初始化重试的新 alpha;本地源码版本号不手工替代 Jenkins 的原子版本分配。
2. App4 使用新精确版本后,在网络正常的 Android 设备验证自动初始化、登录及扩展共享会话。
3. 用仓库内 `native-test` 编译 RN Java Bridge,再在 Android 宿主补充 release-set crash/recovery 仪器测试。
4. 收敛 Jenkins 四包依赖发布顺序和 package 内容检查。
5. 后续租户平台实现 `/api/v1/rn/release-set/check`,服务端只返回目标入口与 common 的兼容闭包。
6. Jenkins 下一版 `@xuqm/rn-update` 必须包含 Hermes 字节码构建改造;App4 不允许长期依赖手工修改的 `node_modules`
## 6. 常用验证命令
@ -101,6 +106,35 @@ pnpm --dir packages/update test
## 7. 本轮变更记录
### 2026-07-18 / Android Hermes 插件产物
- `xuqm-rn` 从宿主 `android/gradle.properties` 读取唯一 `hermesEnabled`,不要求用户再维护插件引擎字段。
- Android Hermes 宿主在 Metro bundle 后统一调用与 React Native 版本匹配的 `hermes-compiler`;source map 使用 RN 官方组合脚本合并 Metro 与 Hermes 映射。
- 内嵌 manifest 与上传表单新增 `bundleFormat`,便于服务端和诊断工具识别 JavaScript/Hermes 产物。
- 新增独立 fixture,验证普通宿主保持 JavaScript、Hermes 宿主生成字节码;update CLI 9 测试、release-set 6 测试、typecheck、package 内容检查全部通过。
- App4 arm64 内嵌构建已真实生成六个 Hermes bytecode bundle 并安装启动;ARM64 软件模拟器启动仍慢,必须在真实 Android 设备补性能验收。
### 2026-07-18 / XWebView 鉴权 H5 首屏请求头
- `XWebViewConfig` 新增可选 `headers: Record<string, string>`;全屏与内联两种 XWebView 都只在 URL 首次加载的 `source` 上使用同一配置,不新增 App 专属桥接。
- App4 的签章详情使用该能力携带当前平台 userId/sessionId/currentClientId 和签名头;SDK 不理解也不保存医网信平台状态。
- common/xwebview 类型检查、4 项 xwebview 测试及整个 RN SDK `pnpm validate` 已通过。
### 2026-07-18 / common 自动初始化恢复
- Metro pre-main 自动初始化只负责提前发起任务并消费拒绝,不在 SDK 内重复打印宿主会处理的错误。
- common 保存构建期加密配置请求;临时失败会同时清理初始化 Promise 和配置初始化 Promise,下一次 `awaitInitialization()` 自动重试。
- 重试仍复用 `startInitialization` 的单 Promise 和配置一致性约束,没有增加第二套初始化入口或参数透传。
- 单元测试覆盖“第一次远程配置失败、第二次等待初始化成功”,common 16 个测试与 TypeScript 检查通过。
- 新增 `formatNumericDateTime` 作为 API 固定时间格式和跨项目数字日期布局的唯一通用实现;App4 已删除公告、消息、通知、日程和反馈中的重复年月日拼接。
### 2026-07-18 / 空目录收敛约束
- 删除无效文件时必须同步删除空父目录,不允许用 `.gitkeep` 维持没有职责的源码结构。
- 已删除 bugcollect 遗留的空 `specs`、`ios`、Android `log` 包以及根 `src/shims`;依赖和 Gradle 生成目录不属于源码结构。
- 该规则已写入仓库 `AGENTS.md`,后续文件迁移、模块删除和脚手架调整必须在同一次变更中完成目录收尾。
- 清理后 `pnpm validate` 通过:格式、六个 workspace 类型检查以及 common/bugcollect/update/im/xwebview 全部测试无回归。
### 2026-07-17 / Jenkins #48 失败修复
- `sdk-rn-publish #48` 在 Windows 棡出后因 8 个文件换行不一致被 Prettier 正确阻断,未发布任何包。
@ -119,7 +153,7 @@ pnpm --dir packages/update test
- 新增 `sha256Hex`,作为后续 Bundle/APK 校验的公共实现。
- 新增纯 JS 文件名模块,Node/CI 测试不会加载 `react-native` 原生模块。
- 新增格式化硬门禁,范围限定为本轮四个 SDK 包,避免改动不在范围内的 IM/Push。
- 验证common typecheck、15 个测试、Prettier 全部通过。
- 验证common typecheck、16 个测试、Prettier 全部通过。
### 2026-07-17 / bugcollect 与 xwebview 第一轮

查看文件

@ -50,6 +50,8 @@ await XuqmSDK.initialize({ appKey: 'app-key' })
同一配置的并发初始化会复用同一个 Promise;重复使用不同配置会直接报错。
Metro 预加载发起的自动初始化如果因临时网络或 DNS 问题失败,common 会终止该 Promise,避免形成未处理异常。随后第一个真正需要配置的扩展调用共享 `awaitInitialization()` 时,会使用同一份加密配置重新尝试;配置、appKey 和平台地址不需要由页面或子 SDK 再次传入。持续失败由宿主入口统一记录一次,SDK 不重复打印同一错误。
## 扩展包的唯一登录入口
应用登录成功后只调用一次:
@ -68,7 +70,7 @@ await XuqmSDK.login({
await XuqmSDK.logout()
```
`accessToken` 仅用于公共 HTTP 鉴权,`userSig` 仅用于 IM。所有已安装的官方子 SDK 订阅这一次会话变更,不得各自初始化或登录。
`accessToken` 仅用于公共 HTTP 鉴权,`userSig` 仅用于 IM。所有已安装的官方子 SDK 订阅这一次会话变更,不得各自初始化或登录。未使用 IM 的应用无需传 `userSig`
## 通用能力
@ -76,6 +78,7 @@ await XuqmSDK.logout()
- `downloadBytes`、`downloadText`、`downloadFileToPath`:统一小文件流式读取与大文件落盘、进度和取消能力。
- `compareVersions`、`satisfiesVersion`、`compatibleMajorRange`:统一 SemVer 与插件兼容范围判断。
- `expirationStatus`、`toTimestamp`、`millisecondsUntil`、`formatDateTime`:统一时间解析与过期判断。
- `formatNumericDateTime`:为 API 参数和固定数字布局提供不受 locale 标点/顺序影响的本地时间格式,调用方通过选项控制日期、时间、秒和分隔符。
- `fileExists`、`ensureDirectory`、`readFileAsBase64`、`writeBase64File`、`openLocalFile`、`registerAndroidDownloadedFile`:统一文件与 Android 下载登记操作。
- `decryptXuqmFile`、`hmacSha256Base64Url`:基于 `@noble/*` 的跨平台加密实现。
- `getDeviceInfo`、`getDeviceId`、`useApi`、`usePageApi`、`showToast` 等项目通用能力。

查看文件

@ -29,7 +29,9 @@ function tryRequireConfig(): string | null {
/**
*
* Android SDK runCatching + Log.w
* Promise
* awaitInitialization()宿
* SDK 宿
*/
export function tryAutoInit(): void {
if (_autoInitAttempted) return
@ -39,9 +41,7 @@ export function tryAutoInit(): void {
const encrypted = tryRequireConfig()
if (!encrypted) return
_initializeFromConfigFile(encrypted, { debug: __DEV__ }).catch((e: unknown) => {
console.error('[XuqmSDK] Auto-init failed:', e)
})
void _initializeFromConfigFile(encrypted, { debug: __DEV__ }).catch(() => undefined)
}
tryAutoInit()

查看文件

@ -2,6 +2,14 @@ export type DateInput = Date | number | string | null | undefined
export type ExpirationStatus = 'missing' | 'valid' | 'expired'
export type NumericDateTimeFormatOptions = {
dateSeparator?: string
includeDate?: boolean
includeSeconds?: boolean
includeTime?: boolean
timeSeparator?: string
}
/**
* Converts a supported date value to epoch milliseconds.
* Numeric values below 10^12 are treated as epoch seconds; larger values as milliseconds.
@ -64,3 +72,38 @@ export function formatDateTime(
if (timestamp == null) return null
return new Intl.DateTimeFormat(locale, options).format(new Date(timestamp))
}
/**
* Formats local date parts without locale-dependent punctuation or word order.
* Use this for API parameters and UIs whose protocol requires a fixed numeric layout.
*/
export function formatNumericDateTime(
value: DateInput,
options: NumericDateTimeFormatOptions = {},
): string | null {
const timestamp = toTimestamp(value)
if (timestamp == null) return null
const {
dateSeparator = '-',
includeDate = true,
includeSeconds = true,
includeTime = true,
timeSeparator = ':',
} = options
const date = new Date(timestamp)
const pad = (part: number) => String(part).padStart(2, '0')
const values: string[] = []
if (includeDate) {
values.push(
[date.getFullYear(), pad(date.getMonth() + 1), pad(date.getDate())].join(dateSeparator),
)
}
if (includeTime) {
const time = [pad(date.getHours()), pad(date.getMinutes())]
if (includeSeconds) time.push(pad(date.getSeconds()))
values.push(time.join(timeSeparator))
}
return values.join(' ')
}

查看文件

@ -8,8 +8,14 @@ export type { DecryptedConfig, XuqmEncryptedFileMagic } from './crypto'
export { DEFAULT_TENANT_PLATFORM_URL, DEFAULT_IM_WS_URL } from './constants'
export { getDeviceId, getDeviceInfo, detectPushVendor } from './device'
export type { DeviceInfo, PushVendor } from './device'
export { expirationStatus, formatDateTime, millisecondsUntil, toTimestamp } from './date'
export type { DateInput, ExpirationStatus } from './date'
export {
expirationStatus,
formatDateTime,
formatNumericDateTime,
millisecondsUntil,
toTimestamp,
} from './date'
export type { DateInput, ExpirationStatus, NumericDateTimeFormatOptions } from './date'
export {
deleteFile,
downloadFileToPath,
@ -54,6 +60,7 @@ export type {
XWebViewClickMenu,
XWebViewConfig,
XWebViewControllerAPI,
XWebViewNavigationState,
XWebViewDownloadDecision,
XWebViewDownloadDestination,
XWebViewDownloadProgress,

查看文件

@ -15,6 +15,10 @@ import { decryptConfigFile } from './crypto'
let _initPromise: Promise<void> | null = null
let _configFileInitPromise: Promise<void> | null = null
let _configFileRequest: {
encryptedContent: string
options?: { debug?: boolean }
} | null = null
let _initializationKey: string | null = null
let _sessionTransition: Promise<void> = Promise.resolve()
let _activeSessionKey: string | null = null
@ -147,7 +151,12 @@ export const XuqmSDK = {
export async function awaitInitialization(): Promise<void> {
if (isInitialized()) return
const pendingInitialization = _configFileInitPromise ?? _initPromise
const pendingInitialization =
_configFileInitPromise ??
_initPromise ??
(_configFileRequest
? _initializeFromConfigFile(_configFileRequest.encryptedContent, _configFileRequest.options)
: null)
if (!pendingInitialization) {
throw new Error(
'[XuqmSDK] Automatic initialization did not start. Place config.xuqmconfig in src/assets/config and wrap Metro with withXuqmConfig(), or call XuqmSDK.initialize().',
@ -161,6 +170,7 @@ export function _initializeFromConfigFile(
encryptedContent: string,
options?: { debug?: boolean },
): Promise<void> {
_configFileRequest = { encryptedContent, options }
if (_configFileInitPromise) return _configFileInitPromise
_configFileInitPromise = (async () => {

查看文件

@ -2,17 +2,9 @@ import { Alert, Platform, ToastAndroid } from 'react-native'
import { getDeviceInfo } from '../device'
import { getUserInfo } from '../config'
import { _getAccessToken } from '../http'
import type { XWebViewConfig } from './types'
import type { XWebViewConfig, XWebViewControllerAPI } from './types'
export type XWebViewControllerAPI = {
refresh: () => void
close: () => void
goBack: () => void
goForward: () => void
copyUrl: () => void
postMessageToWeb: (jsString: string) => void
getTitle: () => string
}
export type { XWebViewControllerAPI } from './types'
// ─── Navigation ref (宿主注入) ─────────────────────────────────────────────────
@ -220,4 +212,11 @@ export const XWebViewControl: XWebViewControllerAPI = {
copyUrl: () => _controller?.copyUrl(),
postMessageToWeb: js => _controller?.postMessageToWeb(js),
getTitle: () => _controller?.getTitle() ?? '',
getNavigationState: () =>
_controller?.getNavigationState() ?? {
canGoBack: false,
canGoForward: false,
title: '',
url: '',
},
}

查看文件

@ -44,6 +44,13 @@ export type XWebViewDownloadResult = {
fileSize: number
}
export type XWebViewNavigationState = {
canGoBack: boolean
canGoForward: boolean
title: string
url: string
}
export type XWebViewControllerAPI = {
refresh: () => void
close: () => void
@ -52,6 +59,7 @@ export type XWebViewControllerAPI = {
copyUrl: () => void
postMessageToWeb: (jsString: string) => void
getTitle: () => string
getNavigationState: () => XWebViewNavigationState
}
export type XWebViewDownloadDestination = 'sandbox' | 'publicDownloads'
@ -67,8 +75,11 @@ export type XWebViewConfig = {
openForBrowser?: boolean
clickMenu?: XWebViewClickMenu
url?: string
/** 首次加载 URL 时携带的请求头,适用于需要会话鉴权的业务 H5。 */
headers?: Record<string, string>
content?: string
onMessage?: (event: XWebViewMessageEvent) => void
onNavigationStateChange?: (state: XWebViewNavigationState) => void
injectedJavaScript?: string
onPermissionRequest?: (request: XWebViewPermissionRequest) => void
autoDownload?: boolean

查看文件

@ -1,7 +1,12 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { expirationStatus, millisecondsUntil, toTimestamp } from '../src/date'
import {
expirationStatus,
formatNumericDateTime,
millisecondsUntil,
toTimestamp,
} from '../src/date'
test('toTimestamp accepts seconds, milliseconds and API date strings', () => {
assert.equal(toTimestamp(1_700_000_000), 1_700_000_000_000)
@ -10,6 +15,26 @@ test('toTimestamp accepts seconds, milliseconds and API date strings', () => {
assert.equal(toTimestamp('invalid'), null)
})
test('formatNumericDateTime produces stable protocol and display layouts', () => {
const value = new Date(2026, 6, 18, 9, 5, 4)
assert.equal(formatNumericDateTime(value), '2026-07-18 09:05:04')
assert.equal(
formatNumericDateTime(value, {
dateSeparator: '/',
includeTime: false,
}),
'2026/07/18',
)
assert.equal(
formatNumericDateTime(value, {
includeDate: false,
includeSeconds: false,
}),
'09:05',
)
assert.equal(formatNumericDateTime('invalid'), null)
})
test('expirationStatus uses one canonical strict end-time decision', () => {
const now = 1_700_000_000_000
assert.equal(expirationStatus(null, now), 'missing')

查看文件

@ -6,11 +6,14 @@ import { _initializeFromConfigFile, awaitInitialization } from '../src/sdk'
const CONFIG_VECTOR =
'XUQM-CONFIG-V1.AAECAwQFBgcICQoLDA0ODw.EBESExQVFhcYGRob.vhdKh3AhEdeftXCdXp9KusVySkrBNAzmdaA747uf1tVWGFnAsJeCLe2mHKk0dxxez8SRTknIbk1UuRibnXY0Gbot1fmkR-jSN2ssMJO0_zQ8dUu8'
test('awaitInitialization observes config decryption already in progress', async () => {
test('awaitInitialization observes in-flight work and retries a transient config request failure', async () => {
const originalFetch = globalThis.fetch
let requestCount = 0
globalThis.fetch = async () => {
requestCount += 1
if (requestCount === 1) {
throw new TypeError('Network request failed')
}
return new Response(
JSON.stringify({
data: {
@ -28,9 +31,12 @@ test('awaitInitialization observes config decryption already in progress', async
const autoInitialization = _initializeFromConfigFile(CONFIG_VECTOR, {
debug: true,
})
await assert.rejects(() => awaitInitialization(), /Network request failed/)
await assert.rejects(() => autoInitialization, /Network request failed/)
// 配置内容由 common 保存,扩展包再次等待时可以自行重试,无需宿主传递配置。
await assert.doesNotReject(() => awaitInitialization())
await autoInitialization
assert.equal(requestCount, 1)
assert.equal(requestCount, 2)
} finally {
globalThis.fetch = originalFetch
}

查看文件

@ -35,6 +35,11 @@ apply from: file("../../node_modules/@xuqm/rn-update/android/xuqm-bundles.gradle
该脚本关闭 React Native 默认单体 Bundle,避免宿主同时维护两套启动产物。
插件产物的引擎只有一个事实来源Android 宿主的 `android/gradle.properties`。当
`hermesEnabled=true` 时,CLI 自动使用与宿主 React Native 匹配的 `hermes-compiler` 将每个
startup/common/app/buz 编译成 Hermes bytecode,并组合 Metro/Hermes source map;无需也不允许在
`xuqm.config.json` 再声明一份引擎。manifest 和发布请求会记录 `bundleFormat`
## 运行时规则
安装仅包含 buz 的 release set 后,使用

查看文件

@ -7,9 +7,11 @@ import {
mkdirSync,
readFileSync,
readdirSync,
renameSync,
rmSync,
writeFileSync,
} from 'node:fs'
import { createRequire } from 'node:module'
import path from 'node:path'
import process from 'node:process'
import {
@ -60,6 +62,73 @@ function runReactNative(cliArgs) {
})
}
function androidUsesHermes() {
const propertiesFile = path.join(root, 'android', 'gradle.properties')
if (!existsSync(propertiesFile)) return false
const properties = readFileSync(propertiesFile, 'utf8')
const match = properties.match(/^\s*hermesEnabled\s*=\s*(true|false)\s*$/im)
return match?.[1].toLowerCase() === 'true'
}
function resolveHermesCompiler() {
const hostRequire = createRequire(path.join(root, 'package.json'))
const reactNativeManifest = hostRequire.resolve('react-native/package.json')
const reactNativeRequire = createRequire(reactNativeManifest)
const compilerManifest = reactNativeRequire.resolve('hermes-compiler/package.json')
const osDirectory =
process.platform === 'darwin'
? 'osx-bin'
: process.platform === 'linux' && process.arch === 'x64'
? 'linux64-bin'
: process.platform === 'win32'
? 'win64-bin'
: null
if (!osDirectory) {
fail(`Hermes compiler does not support ${process.platform}/${process.arch}`)
}
const executable = process.platform === 'win32' ? 'hermesc.exe' : 'hermesc'
const compiler = path.join(path.dirname(compilerManifest), 'hermesc', osDirectory, executable)
if (!existsSync(compiler)) fail(`Hermes compiler is missing: ${compiler}`)
return { compiler, reactNativeDirectory: path.dirname(reactNativeManifest) }
}
function compileHermesBundle(output, sourceMapEnabled) {
const { compiler, reactNativeDirectory } = resolveHermesCompiler()
const bytecode = `${output}.hbc`
const packagerSourceMap = `${output}.packager.map`
const compilerSourceMap = `${bytecode}.map`
const outputSourceMap = `${output}.map`
if (sourceMapEnabled) renameSync(outputSourceMap, packagerSourceMap)
execFileSync(
compiler,
[
'-w',
'-emit-binary',
'-max-diagnostic-width=80',
'-out',
bytecode,
output,
'-O',
...(sourceMapEnabled ? ['-output-source-map'] : []),
],
{ cwd: root, stdio: 'inherit' },
)
rmSync(output)
renameSync(bytecode, output)
if (sourceMapEnabled) {
const composeSourceMaps = path.join(reactNativeDirectory, 'scripts', 'compose-source-maps.js')
execFileSync(
process.execPath,
[composeSourceMaps, packagerSourceMap, compilerSourceMap, '-o', outputSourceMap],
{ cwd: root, stdio: 'inherit' },
)
rmSync(packagerSourceMap)
rmSync(compilerSourceMap)
}
}
function init() {
const packageFile = path.join(root, 'package.json')
if (!existsSync(packageFile)) fail('run this command from a React Native project root')
@ -168,6 +237,7 @@ function build(platform, optionArgs = []) {
assertPlatform(platform)
const config = doctor()
const modules = parseSelectedModules(config, optionArgs)
const useHermes = platform === 'android' && androidUsesHermes()
for (const module of modules) {
const outputDirectory = bundleOutputDirectory(root, config, platform, module.id)
rmSync(outputDirectory, { force: true, recursive: true })
@ -194,6 +264,8 @@ function build(platform, optionArgs = []) {
}
console.log(`xuqm-rn: building ${module.id}/${platform}`)
runReactNative(cliArgs.slice(1))
if (useHermes)
compileHermesBundle(bundleFile(root, config, platform, module.id), module.sourceMap === true)
}
return { config, modules }
}
@ -258,6 +330,7 @@ function createManifest(config, platform, modules, appVersion, nativeBaselineId)
appVersion,
mainModuleName: config.mainModuleName,
platform,
bundleFormat: platform === 'android' && androidUsesHermes() ? 'hermes-bytecode' : 'javascript',
version: config.manifestVersion ?? 1,
nativeBaselineId,
modules: entries,

查看文件

@ -51,6 +51,7 @@ test('embed creates a complete versioned manifest from a host fixture', () => {
const manifest = JSON.parse(readFileSync(path.join(output, 'manifest.json'), 'utf8'))
assert.equal(manifest.appVersion, '7.2.14')
assert.equal(manifest.bundleFormat, 'javascript')
assert.equal(manifest.modules.common.moduleVersion, '1.0.0')
assert.equal(manifest.modules.app.moduleVersion, '1.0.0')
assert.equal(manifest.modules.app.commonVersionRange, '>=1.0.0 <2.0.0')
@ -62,6 +63,108 @@ test('embed creates a complete versioned manifest from a host fixture', () => {
}
})
test('Android Hermes hosts compile plugin bundles to bytecode', () => {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-hermes-'))
try {
writeFileSync(
path.join(root, 'package.json'),
JSON.stringify({
name: 'fixture',
version: '1.0.0',
dependencies: { '@xuqm/rn-update': '0.5.0-alpha.10' },
}),
)
writeFileSync(path.join(root, 'common.ts'), '')
mkdirSync(path.join(root, 'android'), { recursive: true })
writeFileSync(path.join(root, 'android', 'gradle.properties'), 'hermesEnabled=true\n')
writeFileSync(
path.join(root, 'xuqm.config.json'),
JSON.stringify({
schemaVersion: 3,
appId: 'fixture.app',
mainModuleName: 'Fixture',
pluginVersion: '1.0.0',
appVersionRange: '>=1.0.0 <2.0.0',
outputDir: './bundle',
embeddedOutput: { android: './generated' },
modules: [{ id: 'common', type: 'common', entry: './common.ts' }],
}),
)
const reactNativeDirectory = path.join(root, 'node_modules', 'react-native')
const compilerDirectory = path.join(root, 'node_modules', 'hermes-compiler')
mkdirSync(path.join(reactNativeDirectory, 'scripts'), { recursive: true })
mkdirSync(
path.join(
compilerDirectory,
'hermesc',
process.platform === 'win32'
? 'win64-bin'
: process.platform === 'darwin'
? 'osx-bin'
: 'linux64-bin',
),
{ recursive: true },
)
writeFileSync(
path.join(reactNativeDirectory, 'package.json'),
JSON.stringify({
name: 'react-native',
version: '0.86.0',
dependencies: { 'hermes-compiler': '1.0.0' },
}),
)
writeFileSync(
path.join(reactNativeDirectory, 'cli.js'),
`
const fs = require('node:fs');
const path = require('node:path');
const args = process.argv.slice(2);
const output = args[args.indexOf('--bundle-output') + 1];
fs.mkdirSync(path.dirname(output), { recursive: true });
fs.writeFileSync(output, 'javascript');
`,
)
writeFileSync(
path.join(compilerDirectory, 'package.json'),
JSON.stringify({ name: 'hermes-compiler', version: '1.0.0' }),
)
const compiler = path.join(
compilerDirectory,
'hermesc',
process.platform === 'win32'
? 'win64-bin'
: process.platform === 'darwin'
? 'osx-bin'
: 'linux64-bin',
process.platform === 'win32' ? 'hermesc.exe' : 'hermesc',
)
writeFileSync(
compiler,
`#!/usr/bin/env node
const fs = require('node:fs');
const args = process.argv.slice(2);
fs.writeFileSync(args[args.indexOf('-out') + 1], 'hermes-bytecode');
`,
{ mode: 0o755 },
)
execFileSync(process.execPath, [cli, 'embed', 'android'], {
cwd: root,
stdio: 'pipe',
})
assert.equal(
readFileSync(path.join(root, 'generated', 'common.android.bundle'), 'utf8'),
'hermes-bytecode',
)
const manifest = JSON.parse(readFileSync(path.join(root, 'generated', 'manifest.json'), 'utf8'))
assert.equal(manifest.bundleFormat, 'hermes-bytecode')
} finally {
rmSync(root, { recursive: true, force: true })
}
})
test('build invokes the host-local React Native CLI with a consistent color environment', () => {
const root = mkdtempSync(path.join(tmpdir(), 'xuqm-cli-local-rn-'))
try {

查看文件

@ -1,6 +1,7 @@
#!/usr/bin/env node
import { existsSync, readFileSync } from 'node:fs'
import path from 'node:path'
import { createInterface } from 'node:readline'
import process from 'node:process'
import {
@ -42,6 +43,13 @@ const nativeBaselineId = computeNativeBaseline(root, platform)
const serverUrl = process.env.XUQM_SERVER_URL ?? config.release?.serverUrl
const appKey = process.env.XUQM_APP_KEY ?? config.release?.appKey
const apiToken = process.env.XUQM_API_TOKEN ?? config.release?.apiToken
const androidPropertiesFile = path.join(root, 'android', 'gradle.properties')
const bundleFormat =
platform === 'android' &&
existsSync(androidPropertiesFile) &&
/^\s*hermesEnabled\s*=\s*true\s*$/im.test(readFileSync(androidPropertiesFile, 'utf8'))
? 'hermes-bytecode'
: 'javascript'
if (!serverUrl || !appKey || !apiToken) {
throw new Error(
'publish requires XUQM_SERVER_URL, XUQM_APP_KEY and XUQM_API_TOKEN (or release.* config)',
@ -66,6 +74,7 @@ async function uploadBundle(module, note, minNativeApiLevel) {
form.append('version', module.moduleVersion)
form.append('appVersionRange', module.appVersionRange)
form.append('builtAgainstNativeBaselineId', nativeBaselineId)
form.append('bundleFormat', bundleFormat)
form.append('commonVersionRange', module.commonVersionRange ?? '')
form.append('minNativeApiLevel', String(module.minNativeApiLevel ?? minNativeApiLevel))
form.append('note', note)

查看文件

@ -1,5 +1,16 @@
# @xuqm/rn-xwebview
## 摄像头与麦克风权限
Android 宿主按实际业务在 `AndroidManifest.xml` 声明权限:
```xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
```
H5 请求摄像头或麦克风时,XWebView 默认统一申请 Android 运行时权限,只向 WebView 放行系统已授权的资源。宿主无需在每个页面重复实现。需要额外来源校验或自定义提示时可提供 `onPermissionRequest`,此时授权决策完全由宿主回调负责。
统一 WebView、JSBridge 与文件下载模块。它复用 `@xuqm/rn-common` 的配置、会话、文件和 MIME 能力,不提供第二套网络或用户状态。
## 安装
@ -14,11 +25,27 @@ pnpm add @xuqm/rn-common @xuqm/rn-xwebview \
```tsx
import { XWebViewView, openXWebView } from '@xuqm/rn-xwebview'
openXWebView('https://example.com', { title: '页面' })
openXWebView(() => navigation.navigate('XWebView'), {
title: '页面',
url: 'https://example.com',
onNavigationStateChange(state) {
console.log(state.canGoBack, state.canGoForward, state.url)
},
})
;<XWebViewView config={{ url: 'https://example.com', title: '页面' }} />
;<XWebViewView
config={{
url: 'https://example.com',
title: '页面',
onNavigationStateChange(state) {
console.log(state.title, state.url)
},
}}
/>
```
全屏和内嵌容器都从同一 `XWebViewConfig.onNavigationStateChange` 返回实时导航状态。命令式场景可调用 `XWebViewControl.getNavigationState()`;业务层不得自行轮询标题或维护第二份 URL/前进后退状态。
下载统一使用 `handleDownloadRequest`、`startDownload` 或 `saveBase64File`。目录解析、RFC 文件名、冲突策略、MIME 推断、大文件落盘、进度、取消、Android 下载登记和打开文件均复用 common,不在 xwebview 或业务层重复实现。
H5 需要原生能力时,通过 `setXWebViewScanQRCodeHandler` 等全局 handler 接入。用户、token、平台地址不得作为页面 props 逐级传递;Bridge 在调用时读取 common 当前会话。

查看文件

@ -0,0 +1,80 @@
import { useCallback } from 'react'
import { PermissionsAndroid, Platform } from 'react-native'
import type { XWebViewPermissionRequest } from '@xuqm/rn-common'
import {
allowedWebPermissionResources,
webPermissionKind,
type WebPermissionKind,
} from './permissionPolicy'
type NativePermissionRequest = {
nativeEvent: {
origin: string
resources: string[]
grant: (resources: string[]) => void
deny: () => void
}
}
const ANDROID_PERMISSIONS = {
camera: PermissionsAndroid.PERMISSIONS.CAMERA,
microphone: PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
} as const
async function requestAndroidPermissions(
resources: string[],
): Promise<ReadonlySet<WebPermissionKind>> {
const requestedKinds = new Set<WebPermissionKind>()
for (const resource of resources) {
const kind = webPermissionKind(resource)
if (kind) requestedKinds.add(kind)
}
if (requestedKinds.size === 0) return requestedKinds
const results = await PermissionsAndroid.requestMultiple(
[...requestedKinds].map(kind => ANDROID_PERMISSIONS[kind]),
)
return new Set(
[...requestedKinds].filter(
kind => results[ANDROID_PERMISSIONS[kind]] === PermissionsAndroid.RESULTS.GRANTED,
),
)
}
/**
* XWebView 宿宿使
*/
export function useXWebViewPermissionRequest(
onPermissionRequest?: (request: XWebViewPermissionRequest) => void,
) {
return useCallback(
(request: NativePermissionRequest) => {
const { nativeEvent } = request
if (onPermissionRequest) {
onPermissionRequest({
deny: nativeEvent.deny,
grant: resources => nativeEvent.grant(resources ?? nativeEvent.resources),
origin: nativeEvent.origin,
resources: nativeEvent.resources,
})
return
}
if (Platform.OS !== 'android') {
nativeEvent.grant(nativeEvent.resources)
return
}
void requestAndroidPermissions(nativeEvent.resources)
.then(grantedKinds => {
const allowed = allowedWebPermissionResources(nativeEvent.resources, grantedKinds)
if (allowed.length > 0) nativeEvent.grant(allowed)
else nativeEvent.deny()
})
.catch(nativeEvent.deny)
},
[onPermissionRequest],
)
}

查看文件

@ -29,6 +29,7 @@ import { getXWebViewConfig, setXWebViewController, type XWebViewConfig } from '@
import XWebViewProgress from './XWebViewProgress'
import { fetchDownloadInfo, saveBase64File, startDownload } from './XWebViewDownload'
import { useXWebViewPermissionRequest } from './XWebViewPermissions'
import IconBack from './icons/IconBack'
import IconClose from './icons/IconClose'
import IconMenu from './icons/IconMenu'
@ -244,9 +245,11 @@ export function XWebViewScreen() {
showMenu = true,
title: initialTitle = '',
url,
headers,
content,
injectedJavaScript,
onMessage: userOnMessage,
onNavigationStateChange,
onPermissionRequest,
autoDownload = true,
downloadConflict = 'rename',
@ -263,9 +266,11 @@ export function XWebViewScreen() {
const [progress, setProgress] = useState(0)
const [showProgress, setShowProgress] = useState(false)
const [canGoBack, setCanGoBack] = useState(false)
const [canGoForward, setCanGoForward] = useState(false)
const [currentUrl, setCurrentUrl] = useState(url ?? '')
const [currentTitle, setCurrentTitle] = useState(initialTitle)
const [loadError, setLoadError] = useState<string | null>(null)
const handlePermissionRequest = useXWebViewPermissionRequest(onPermissionRequest)
useEffect(() => {
setXWebViewController({
@ -279,9 +284,10 @@ export function XWebViewScreen() {
copyUrl: () => Clipboard.setString(currentUrl),
postMessageToWeb: (js: string) => webViewRef.current?.injectJavaScript(js),
getTitle: () => currentTitle,
getNavigationState: () => ({ canGoBack, canGoForward, title: currentTitle, url: currentUrl }),
})
return () => setXWebViewController(null)
}, [currentUrl, currentTitle, navigation, onClose])
}, [canGoBack, canGoForward, currentUrl, currentTitle, navigation, onClose])
useLayoutEffect(() => {
navigation.setOptions({ headerShown: false })
@ -319,10 +325,17 @@ export function XWebViewScreen() {
const handleNavigationStateChange = useCallback(
(state: WebViewNavigation) => {
setCanGoBack(state.canGoBack)
setCanGoForward(state.canGoForward)
if (state.url) setCurrentUrl(state.url)
if (autoTitle && state.title) setCurrentTitle(state.title)
onNavigationStateChange?.({
canGoBack: state.canGoBack,
canGoForward: state.canGoForward,
title: state.title ?? '',
url: state.url ?? '',
})
},
[autoTitle],
[autoTitle, onNavigationStateChange],
)
const handleLoadProgress = useCallback((event: WebViewProgressEvent) => {
@ -472,26 +485,6 @@ export function XWebViewScreen() {
setShowProgress(false)
}, [])
const handlePermissionRequest = useCallback(
(request: {
nativeEvent: {
origin: string
resources: string[]
grant: (r: string[]) => void
deny: () => void
}
}) => {
if (!onPermissionRequest) return
onPermissionRequest({
origin: request.nativeEvent.origin,
resources: request.nativeEvent.resources,
grant: (res?: string[]) => request.nativeEvent.grant(res ?? request.nativeEvent.resources),
deny: () => request.nativeEvent.deny(),
})
},
[onPermissionRequest],
)
const handleOpenWindow = useCallback((event: WebViewOpenWindowEvent) => {
const targetUrl = event.nativeEvent.targetUrl
if (targetUrl) {
@ -502,7 +495,7 @@ export function XWebViewScreen() {
}, [])
const source = url
? ({ uri: url } as const)
? ({ uri: url, headers } as const)
: ({ html: content ?? '<html><body></body></html>' } as const)
const injected = DIALOG_OVERRIDE_JS + '\n' + (injectedJavaScript ?? '') + '\ntrue;'
@ -567,7 +560,7 @@ export function XWebViewScreen() {
onNavigationStateChange={handleNavigationStateChange}
onMessage={handleMessage}
onShouldStartLoadWithRequest={handleShouldStartLoad}
onPermissionRequest={onPermissionRequest ? handlePermissionRequest : undefined}
onPermissionRequest={handlePermissionRequest}
injectedJavaScript={injected}
onOpenWindow={handleOpenWindow}
javaScriptEnabled

查看文件

@ -16,6 +16,7 @@ import { getXWebViewConfig, setXWebViewController, type XWebViewConfig } from '@
import XWebViewProgress from './XWebViewProgress'
import { fetchDownloadInfo, saveBase64File, startDownload } from './XWebViewDownload'
import { useXWebViewPermissionRequest } from './XWebViewPermissions'
const WebViewAny = WebView as unknown as React.ComponentType<any>
const DOWNLOAD_EXTENSIONS = [
@ -134,9 +135,11 @@ export function XWebViewView({ config: configProp }: { config?: XWebViewConfig }
showStatusBar = true,
title: initialTitle = '',
url,
headers,
content,
injectedJavaScript,
onMessage: userOnMessage,
onNavigationStateChange,
onPermissionRequest,
autoDownload = true,
downloadConflict = 'rename',
@ -153,9 +156,11 @@ export function XWebViewView({ config: configProp }: { config?: XWebViewConfig }
const [progress, setProgress] = useState(0)
const [showProgress, setShowProgress] = useState(false)
const [canGoBack, setCanGoBack] = useState(false)
const [canGoForward, setCanGoForward] = useState(false)
const [currentUrl, setCurrentUrl] = useState(url ?? '')
const [currentTitle, setCurrentTitle] = useState(initialTitle)
const [loadError, setLoadError] = useState<string | null>(null)
const handlePermissionRequest = useXWebViewPermissionRequest(onPermissionRequest)
useEffect(() => {
// 内联模式不注册全局 controller,避免与全屏 XWebView 实例互相覆盖。
@ -168,15 +173,27 @@ export function XWebViewView({ config: configProp }: { config?: XWebViewConfig }
copyUrl: () => Clipboard.setString(currentUrl),
postMessageToWeb: (js: string) => webViewRef.current?.injectJavaScript(js),
getTitle: () => currentTitle,
getNavigationState: () => ({ canGoBack, canGoForward, title: currentTitle, url: currentUrl }),
})
return () => setXWebViewController(null)
}, [inline, currentUrl, currentTitle, onClose])
}, [canGoBack, canGoForward, inline, currentUrl, currentTitle, onClose])
const handleNavigationStateChange = useCallback((state: WebViewNavigation) => {
setCanGoBack(state.canGoBack)
if (state.url) setCurrentUrl(state.url)
if (state.title) setCurrentTitle(state.title)
}, [])
const handleNavigationStateChange = useCallback(
(state: WebViewNavigation) => {
const navigationState = {
canGoBack: state.canGoBack,
canGoForward: state.canGoForward,
title: state.title ?? '',
url: state.url ?? '',
}
setCanGoBack(navigationState.canGoBack)
setCanGoForward(navigationState.canGoForward)
if (navigationState.url) setCurrentUrl(navigationState.url)
if (navigationState.title) setCurrentTitle(navigationState.title)
onNavigationStateChange?.(navigationState)
},
[onNavigationStateChange],
)
const handleLoadProgress = useCallback((event: WebViewProgressEvent) => {
const p = event.nativeEvent.progress
@ -312,26 +329,6 @@ export function XWebViewView({ config: configProp }: { config?: XWebViewConfig }
setShowProgress(false)
}, [])
const handlePermissionRequest = useCallback(
(request: {
nativeEvent: {
origin: string
resources: string[]
grant: (r: string[]) => void
deny: () => void
}
}) => {
if (!onPermissionRequest) return
onPermissionRequest({
origin: request.nativeEvent.origin,
resources: request.nativeEvent.resources,
grant: (res?: string[]) => request.nativeEvent.grant(res ?? request.nativeEvent.resources),
deny: () => request.nativeEvent.deny(),
})
},
[onPermissionRequest],
)
const handleOpenWindow = useCallback((event: WebViewOpenWindowEvent) => {
const targetUrl = event.nativeEvent.targetUrl
if (targetUrl) {
@ -342,7 +339,7 @@ export function XWebViewView({ config: configProp }: { config?: XWebViewConfig }
}, [])
const source = url
? ({ uri: url } as const)
? ({ uri: url, headers } as const)
: ({ html: content ?? '<html><body></body></html>' } as const)
const injected = DIALOG_OVERRIDE_JS + '\n' + (injectedJavaScript ?? '')
@ -378,20 +375,7 @@ export function XWebViewView({ config: configProp }: { config?: XWebViewConfig }
onNavigationStateChange={handleNavigationStateChange}
onMessage={handleMessage}
onShouldStartLoadWithRequest={handleShouldStartLoad}
onPermissionRequest={
onPermissionRequest
? handlePermissionRequest
: (request: {
nativeEvent: {
origin: string
resources: string[]
grant: (r: string[]) => void
deny: () => void
}
}) => {
request.nativeEvent.grant(request.nativeEvent.resources)
}
}
onPermissionRequest={handlePermissionRequest}
injectedJavaScript={injected}
onOpenWindow={handleOpenWindow}
javaScriptEnabled

查看文件

@ -0,0 +1,28 @@
export type WebPermissionKind = 'camera' | 'microphone'
/** 将不同 WebView 内核返回的资源名称归一为稳定的业务权限。 */
export function webPermissionKind(resource: string): WebPermissionKind | null {
const normalized = resource.toUpperCase()
if (normalized.includes('VIDEO_CAPTURE') || normalized.includes('CAMERA')) {
return 'camera'
}
if (
normalized.includes('AUDIO_CAPTURE') ||
normalized.includes('MICROPHONE') ||
normalized.includes('RECORD_AUDIO')
) {
return 'microphone'
}
return null
}
/** 只向 WebView 放行已取得系统授权的受控资源;未知资源保持原生默认语义。 */
export function allowedWebPermissionResources(
resources: string[],
grantedKinds: ReadonlySet<WebPermissionKind>,
): string[] {
return resources.filter(resource => {
const kind = webPermissionKind(resource)
return kind === null || grantedKinds.has(kind)
})
}

查看文件

@ -0,0 +1,28 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { allowedWebPermissionResources, webPermissionKind } from '../src/permissionPolicy'
test('WebView camera and microphone resource names use one canonical mapping', () => {
assert.equal(webPermissionKind('android.webkit.resource.VIDEO_CAPTURE'), 'camera')
assert.equal(webPermissionKind('android.webkit.resource.AUDIO_CAPTURE'), 'microphone')
assert.equal(webPermissionKind('camera'), 'camera')
assert.equal(webPermissionKind('microphone'), 'microphone')
assert.equal(webPermissionKind('protected-media-id'), null)
})
test('only system-authorized camera or microphone resources are granted', () => {
const resources = [
'android.webkit.resource.VIDEO_CAPTURE',
'android.webkit.resource.AUDIO_CAPTURE',
'protected-media-id',
]
assert.deepEqual(allowedWebPermissionResources(resources, new Set(['camera'])), [
resources[0],
resources[2],
])
assert.deepEqual(allowedWebPermissionResources(resources, new Set(['microphone'])), [
resources[1],
resources[2],
])
})