feat(xwebview): 收敛 WebView 宿主与桥接

这个提交包含在:
XuqmGroup 2026-07-26 19:41:03 +08:00
父节点 d009c3b616
当前提交 10977341d1
共有 24 个文件被更改,包括 1764 次插入1457 次删除

查看文件

@ -1,6 +1,6 @@
# RN SDK 重构实施接管文档
> 状态更新时间2026-07-19
> 状态更新时间2026-07-26
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
> 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。
@ -13,6 +13,28 @@
5. update SDK 统一承担插件 manifest、兼容性、原子激活、启动确认、崩溃回滚和内嵌恢复;宿主不得再实现平行版本管理器。
6. Android 整包更新必须具备应用内下载、SHA-256 校验、安装权限处理、进度、取消、重试和确定性错误码。
7. 宿主通过最少配置完成插件创建、开发运行、打包、随 APK 内嵌、发布和回滚。
8. XWebView 收敛为 `XWebViewHost + openWebView` 的 SDK 自管页面栈;宿主不再注册路由或传 navigate 回调。
## 0. 2026-07-26 / XWebView 1.0 实施状态
- 已取得用户实施授权,目标为 `@xuqm/rn-xwebview@1.0.0`
- 旧 `openXWebView(navigate, config)`、common 内全局 `_config/_controller` 和公开
`XWebViewScreen` 已删除,不保留转发兼容层;迁移规则已写入包 README。
- 新公开入口固定为根部一次挂载 `XWebViewHost`、业务直接调用
`openWebView(config)`,并返回页面级句柄和 `closed` Promise。
- 配置改为 `source/navigationBar/statusBar/behavior/permissions/downloads/bridge`
分组强类型结构;URL 与 HTML 互斥。
- 页面栈、Host 等待/卸载、页面级动态导航、系统返回、外链、下载、权限和 Bridge
异常已收口到 xwebview;common 中错误归属的 WebView 状态与业务 Bridge 已删除。
- H5 媒体权限由页面白名单、可选宿主决策和 Android 系统权限三层控制;SDK 不向
Manifest 注入摄像头或麦克风权限。
- 下载自动复用 URI 首屏鉴权 headers;Bridge 原始 payload 不写日志。
- `react-native-svg` 已收敛为 peer;宿主必须直接声明这一原生依赖以参与 autolink,
禁止由 xwebview 私自安装第二份。App4 运行验收曾据此捕获并修复
`RNSVGSvgViewAndroid` 未注册问题。
- xwebview 类型检查和 7 项独立测试已通过。App4 在 Android 36.1
`emulator-5554` 通过“移动报销”真实 H5 页面验证页面打开、SDK 初始化、
`userInfo`、右上角菜单和关闭返回均正常,窗口日志无 Java/React/Fabric FATAL。
## 2. 已冻结的设计约束
@ -41,7 +63,7 @@
- `pnpm validate`:通过。
- common20 个测试通过。
- bugcollect5 个独立测试通过。
- xwebview4 个独立测试通过。
- xwebview7 个独立测试通过。
- update CLI/Metro11 个测试通过;release-set6 个测试通过。
- IM 现有测试3 个通过;IM 不在本轮开发范围。
- 所有 workspace TypeScript typecheck通过。
@ -73,17 +95,17 @@
## 4. 实施进度
| 工作项 | 状态 | 说明 |
| ----------------------------- | -------- | ------------------------------------------------------ |
| 基线审计与门禁 | 完成 | 2026-07-17 本地 validate 全通过 |
| 实时接管文档 | 进行中 | 本文件为唯一实施状态入口 |
| common 公共上下文收敛 | 完成 | 公共基础、共享生命周期与安全 API 诊断 19 测试 |
| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本、策略与 HTTP 脱敏已统一,5 测试 |
| xwebview 文件/权限能力与测试 | 完成 | 文件下沉 common,H5 摄像头/麦克风统一授权,4 测试通过 |
| update release set 与原生事务 | 进行中 | Android Snapshot、Bridge、App4 构建通过;待事务 E2E |
| package 内容校验 | 完成 | update 包 42 个发布文件已校验 |
| Jenkins alpha 发布 | 完成 | `#59/#60` 已发布 common、xwebview、update |
| App4 接入 | 完成构建 | 精确 alpha 已接入,六 Hermes 插件与 debug APK 构建通过 |
| 工作项 | 状态 | 说明 |
| ----------------------------- | ------ | ----------------------------------------------------- |
| 基线审计与门禁 | 完成 | 2026-07-17 本地 validate 全通过 |
| 实时接管文档 | 进行中 | 本文件为唯一实施状态入口 |
| common 公共上下文收敛 | 完成 | 公共基础、共享生命周期与安全 API 诊断 19 测试 |
| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本、策略与 HTTP 脱敏已统一,5 测试 |
| xwebview 1.0 Host/权限/下载 | 完成 | 页面栈、分组配置、7 项测试及 App4 Android 运行通过 |
| update release set 与原生事务 | 进行中 | Android Snapshot、Bridge、App4 构建通过;待事务 E2E |
| package 内容校验 | 完成 | update 包 42 个发布文件已校验 |
| Jenkins alpha 发布 | 完成 | `#59/#60` 已发布 common、xwebview、update |
| App4 接入 | 完成 | V5 Host、8 模块、Debug APK 与真实 H5 Android 链路通过 |
## 5. 下一步操作
@ -274,8 +296,12 @@ pnpm --dir packages/update test
- 设备 DNS 失败复测发现 Android 系统英文错误页会在 `onError` 后再次触发加载事件,旧实现因此清空自定义错误状态;同时错误分支卸载 WebView,按钮持有的 ref 已为空,重试实际无效。
- `XWebViewView` 现在始终保留 WebView,并以白底中文错误层覆盖系统页;刷新、前进、后退和重试统一先清理错误状态再操作同一控制器。错误页不再由原生事件先后顺序随机决定。
- 删除 `XWebViewScreen` 内 600 余行重复的 WebView、下载、Bridge、权限和错误处理。Screen 现在只组合统一导航栏、系统返回与 `XWebViewView`;全屏和内嵌场景共享同一个浏览器内核实现,不再修复一处、遗漏另一处。
- xwebview 类型检查与 4 项下载/权限测试通过;RNSDK 全量 `pnpm validate` 同步通过。App4 已刷新相邻 `file:` 依赖并完成 457 tasks 构建:首次 DNS 失败与点击重试后都稳定显示统一中文错误层,截图为 `/tmp/app4-xwebview-error-unified.png`、`/tmp/app4-xwebview-retry-final.png`,日志无 Java/React FATAL。
- 删除 `XWebViewScreen` 内 600 余行重复的 WebView、下载、Bridge、权限和错误处理,
并最终删除该公开入口。全屏页面由 `XWebViewHost` 组合统一导航栏、系统返回与
`XWebViewView`;全屏和内嵌场景共享同一个浏览器内核。
- 当时的 xwebview 类型检查与 4 项下载/权限测试、RNSDK 全量门禁和 App4 457 tasks
构建均通过;该历史结果不能替代 1.0.0 当前实现的最终验证。当前 1.0.0 已扩展为
7 项独立测试,Android 运行验证仍待执行。
### 2026-07-20 / Android 内嵌 Bundle 启动短路

查看文件

@ -46,7 +46,9 @@ await XuqmSDK.logout()
## 扩展包
- `@xuqm/rn-update``UpdateSDK`、`XuqmRuntime`、`definePlugin` 和 `xuqm-rn` CLI。
- `@xuqm/rn-xwebview``XWebViewView`、`XWebViewScreen`、JSBridge 与统一下载。
- `@xuqm/rn-xwebview`:根部一次挂载 `XWebViewHost`,业务使用
`openWebView(config)` 打开全屏页面;`XWebViewView` 只用于内嵌浏览器。业务
JSBridge 通过 `config.bridge` 注入,不属于 XWebView 自身协议。
- `@xuqm/rn-bugcollect``BugCollect` 错误、事件、漏斗和队列上报。
- `@xuqm/rn-im`:在 common 会话含 `userSig` 且服务启用时连接。
- `@xuqm/rn-push`:在 common 会话变化时同步原生推送绑定。

查看文件

@ -1,211 +1,52 @@
# XWebView JSBridge 标准接口
# XWebView Bridge 边界
> 最后更新2026-06-15
> 最后更新2026-07-26
---
XWebView 不是业务 JSBridge 协议。它只定义一个通用适配接口:
## 概述
XWebView 内置 JSBridge,支持 H5 页面与 React Native 宿主之间的双向通信。
**通信机制**
- H5 → RN`window.ReactNativeWebView.postMessage(JSON.stringify(data))`
- RN → H5`webViewRef.injectJavaScript(jsString)`
---
## 一、H5 → RN 消息协议
### 1.1 内置消息__xwv 前缀)
XWebView 自动拦截以下浏览器行为,通过 `postMessage` 发送给 RN
| 消息类型 | 触发场景 | 数据格式 |
|---------|---------|---------|
| `alert` | `window.alert(msg)` | `{__xwv: 'alert', msg: string}` |
| `confirm` | `window.confirm(msg)` | `{__xwv: 'confirm', msg: string}` |
| `prompt` | `window.prompt(msg, def)` | `{__xwv: 'prompt', msg: string, def: string}` |
| `download` | 点击下载链接 | `{__xwv: 'download', url: string, filename: string}` |
| `blobdownload` | blob URL 下载 | `{__xwv: 'blobdownload', url: string, filename: string, data: base64}` |
| `bloberror` | blob 读取失败 | `{__xwv: 'bloberror', msg: string}` |
| `log` | 调试日志 | `{__xwv: 'log', msg: string}` |
### 1.2 自定义消息
H5 发送非 `__xwv` 前缀的消息时,XWebView 将其转发给 `onMessage` 回调:
```javascript
// H5 端
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'customEvent',
payload: { key: 'value' }
}));
```ts
type XWebViewBridge = {
injectedJavaScriptBeforeContentLoaded?: string
onMessage(raw: string, context: XWebViewBridgePageContext): boolean | Promise<boolean>
onPageClosed?(pageId: string): void
}
```
```typescript
// RN 端
<XWebViewView
onMessage={(event) => {
const data = JSON.parse(event.nativeEvent.data);
if (data.type === 'customEvent') {
// 处理自定义消息
}
}}
/>
```
## 页面上下文
---
业务协议实现可以:
## 二、RN → H5 通信
- 关闭当前页;
- 向当前页执行 JavaScript;
- 判断当前页是否为页面栈顶;
- 取得真实状态栏高度;
- 修改当前页导航栏标题/背景;
- 修改当前页导航栏与状态栏显隐。
### 2.1 注入 JavaScript
协议实现不能:
```typescript
// 通过 controller API
controller.postMessageToWeb('window.dispatchEvent(new CustomEvent("rnMessage", {detail: {type: "update", data: "hello"}}))');
- 访问或修改其他 WebView 页面;
- 从 XWebView 读取用户、token、平台、域名或厂商;
- 直接控制宿主导航器;
- 输出原始 Bridge payload;
- 在 common 中注册全局业务 handler。
// 通过 ref
webViewRef.injectJavaScript('window.handleRNMessage("hello")');
```
## 消息所有权
### 2.2 Controller API
XWebView 内部只消费 `__xwv` 命名空间:
通过 `setXWebViewController` 获取的 controller 对象提供以下方法:
- `download` / `blobdownload` / `bloberror`
- `permission`
| 方法 | 说明 |
|------|------|
| `refresh()` | 刷新当前页面 |
| `close()` | 关闭 WebView |
| `goBack()` | 返回上一页 |
| `goForward()` | 前进 |
| `copyUrl()` | 复制当前 URL 到剪贴板 |
| `postMessageToWeb(jsString)` | 向 H5 注入并执行 JS |
| `getTitle()` | 获取当前页面标题 |
其他消息先交给 `config.bridge`。Bridge 返回 `false` 时才继续交给
`behavior.onMessage`。Bridge 抛错由 `behavior.onBridgeError` 接收,不产生未处理
Promise。
---
## 业务协议接入
## 三、下载处理
业务协议必须在自己的 SDK/插件中维护唯一方法表、请求解析、响应结构、错误码和兼容
测试,然后以 `XWebViewBridge` 适配到容器。XWebView 不提供 `getSession`、`navigate`、
`showToast`、签名、支付或任何假定宿主业务的内置方法。
### 3.1 自动下载拦截
XWebView 自动拦截以下文件扩展名的链接点击:
```
.pdf .zip .rar .tar .gz .apk .ipa .doc .docx .xls .xlsx .ppt .pptx .mp4 .mp3 .mov .exe .dmg
```
### 3.2 下载回调
```typescript
<XWebViewView
autoDownload={true}
onDownloadStart={(request) => {
// request: { url, suggestedFilename, mimeType?, fileSize? }
console.log('Download started:', request.suggestedFilename);
}}
onDownloadProgress={(progress) => {
// progress: { url, filename, received, total, percentage }
console.log(`Download: ${progress.percentage}%`);
}}
onDownloadComplete={(result) => {
// result: { url, filename, filePath, fileSize }
console.log('Download complete:', result.filePath);
}}
onDownloadError={(url, error) => {
console.error('Download failed:', url, error);
}}
onDownloadDecide={(request) => {
// 返回下载决策
return { allowed: true, filename: request.suggestedFilename };
}}
downloadConflict="rename" // 'rename' | 'overwrite'
/>
```
### 3.3 Blob URL 下载
XWebView 自动处理 blob URL 下载:
1. 拦截 `<a href="blob:...">` 点击
2. 通过 `FileReader` 读取 blob 数据为 base64
3. 通过 `saveBase64File` 保存到本地
---
## 四、内置 Dialog 覆盖
XWebView 注入 `DIALOG_OVERRIDE_JS`,覆盖浏览器原生对话框:
| 原生方法 | 覆盖行为 |
|---------|---------|
| `window.alert(msg)` | 发送 `{__xwv: 'alert', msg}` 消息 |
| `window.confirm(msg)` | 发送 `{__xwv: 'confirm', msg}` 消息,始终返回 `true` |
| `window.prompt(msg, def)` | 发送 `{__xwv: 'prompt', msg, def}` 消息,返回默认值 |
| `window.open(url)` | 发送日志消息,然后 `window.location.href = url` |
RN 端收到这些消息后,可以展示原生 Alert/Confirm/Prompt 对话框。
---
## 五、标准 Bridge 接口(宿主实现)
以下接口由宿主应用通过 `bridgeHandlers` 实现,H5 通过 `xuqm.call(method, params)` 调用:
### 5.1 会话管理
| 接口 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `getSession` | — | `{token, userId}` | 获取当前登录会话 |
| `getUserInfo` | — | `{userId, name, phone, avatar}` | 获取用户信息 |
### 5.2 导航
| 接口 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `navigate` | `{screen, params?}` | — | 跳转宿主页面 |
| `close` | — | — | 关闭当前 WebView |
| `goBack` | — | — | 返回上一页 |
### 5.2 UI 交互
| 接口 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `showToast` | `{message}` | — | 显示 Toast |
| `showAlert` | `{title?, message}` | — | 显示 Alert 对话框 |
| `showConfirm` | `{title?, message, confirmText?, cancelText?}` | `{confirmed: boolean}` | 显示确认对话框 |
### 5.3 文件操作
| 接口 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `uploadFile` | `{accept?}` | `{url, name, size}` | 选择并上传文件 |
| `openCamera` | — | `{url, base64}` | 打开相机拍照 |
| `scanQR` | — | `{result}` | 扫描二维码 |
### 5.4 设备信息
| 接口 | 参数 | 返回值 | 说明 |
|------|------|--------|------|
| `getDeviceInfo` | — | `{platform, version, model}` | 获取设备信息 |
| `getLocation` | — | `{latitude, longitude}` | 获取当前位置 |
---
## 六、H5 端调用示例
```javascript
// 获取会话
xuqm.call('getSession').then(session => {
console.log('Token:', session.token);
});
// 跳转宿主页面
xuqm.call('navigate', { screen: 'Settings' });
// 显示 Toast
xuqm.call('showToast', { message: '操作成功' });
// 上传文件
xuqm.call('uploadFile', { accept: 'image/*' }).then(file => {
console.log('Uploaded:', file.url);
});
```
医网信 H5 场景使用 `@szyx/sdk-h5-app/host`;其 V5 线协议不属于
`@xuqm/rn-xwebview` 的公开契约。

查看文件

@ -1,7 +1,8 @@
export class RequestError<TData = unknown> extends Error {
constructor(
message: string,
public readonly type: 'Cancel' | 'ValidationError' | 'AxiosError' | 'OtherError',
public readonly type:
'Cancel' | 'ValidationError' | 'BusinessError' | 'AxiosError' | 'OtherError',
public readonly cause?: unknown,
public readonly response?: { data?: TData; status?: number },
) {

查看文件

@ -16,7 +16,7 @@ import {
import { RequestError } from './errors'
import { type RequestOptions, useRequest, ValidationError } from './useRequest'
const SUCCESS_STATUS = /^0$/
const SUCCESS_STATUS = '0'
type ApiResponse<T> = {
status: string
@ -43,12 +43,14 @@ export const useApi = <S extends z.ZodTypeAny, T extends z.infer<S> = z.infer<S>
requestInterceptors: AxiosInterceptorManager<AxiosRequestConfig<unknown>>
responseInterceptors: AxiosInterceptorManager<AxiosResponse<ApiResponse<T>, unknown>>
} => {
// schema 只校验响应结构,不强制 status==='0';
// 业务状态码非 '0' 属于正常业务响应,由 transformedFetchAsync 统一处理。
const responseSchema = useMemo(
() =>
z.object({
status: z.string().regex(SUCCESS_STATUS),
status: z.string(),
message: z.string(),
data: validationSchema ?? z.unknown(),
data: (validationSchema ?? z.unknown()).optional(),
}),
[validationSchema],
)
@ -107,12 +109,6 @@ export const useApi = <S extends z.ZodTypeAny, T extends z.infer<S> = z.infer<S>
}
const firstIssue = e.issues[0]
if (firstIssue.path.length > 0 && firstIssue.path[0] === 'status') {
return Promise.reject(
new RequestError(e.response.data?.message ?? '接口返回失败', 'ValidationError', e),
)
}
return Promise.reject(new RequestError(firstIssue.message, 'ValidationError', e))
}
@ -143,7 +139,13 @@ export const useApi = <S extends z.ZodTypeAny, T extends z.infer<S> = z.infer<S>
async (nextConfig?: AxiosRequestConfig<D>) => {
try {
const requestResponse = await fetchAsync(nextConfig)
return (requestResponse as unknown as AxiosResponse<ApiResponse<T>>).data.data
const body = (requestResponse as unknown as AxiosResponse<ApiResponse<T>>).data
// 业务状态码非 '0' 时,抛出 RequestError 而非 ValidationError,
// 避免业务错误被误报为"校验失败"并影响调用方。
if (body.status !== SUCCESS_STATUS) {
throw new RequestError(body.message || '接口返回失败', 'BusinessError', body)
}
return body.data
} catch (requestError) {
return Promise.reject(requestError)
}

查看文件

@ -52,30 +52,6 @@ export {
satisfiesVersion,
} from './version'
export { ScaledImage } from './components/ScaledImage'
export {
XWebViewControl,
getXWebViewConfig,
openXWebView,
setXWebViewController,
setXWebViewNavigationRef,
setXWebViewScanQRCodeHandler,
processJSBridgeMessage,
} from './xwebview/XWebViewBridge'
export type { XWebViewNavigationRef, ScanQRCodeHandler } from './xwebview/XWebViewBridge'
export type {
XWebViewClickMenu,
XWebViewConfig,
XWebViewControllerAPI,
XWebViewNavigationState,
XWebViewDownloadDecision,
XWebViewDownloadDestination,
XWebViewDownloadProgress,
XWebViewDownloadRequest,
XWebViewDownloadResult,
XWebViewMessageEvent,
XWebViewPermissionRequest,
} from './xwebview/types'
// api/ 子模块
export {
RequestError,

查看文件

@ -1,222 +0,0 @@
import { Alert, Platform, ToastAndroid } from 'react-native'
import { getDeviceInfo } from '../device'
import { getUserInfo } from '../config'
import { _getAccessToken } from '../http'
import type { XWebViewConfig, XWebViewControllerAPI } from './types'
export type { XWebViewControllerAPI } from './types'
// ─── Navigation ref (宿主注入) ─────────────────────────────────────────────────
export type XWebViewNavigationRef = {
navigate: (route: string, params?: Record<string, unknown>) => void
goBack: () => void
}
let _navigationRef: XWebViewNavigationRef | null = null
/**
* 宿 App
* JSBridge `xuqm.openNativePage` / `xuqm.closeWebView` 使
*/
export function setXWebViewNavigationRef(ref: XWebViewNavigationRef | null): void {
_navigationRef = ref
}
// ─── ScanQRCode handler (宿主注入) ────────────────────────────────────────────
export type ScanQRCodeHandler = () => Promise<string>
let _scanQRCodeHandler: ScanQRCodeHandler | null = null
/**
* 宿 App
* JSBridge `xuqm.scanQRCode` 使
* handler
*/
export function setXWebViewScanQRCodeHandler(handler: ScanQRCodeHandler | null): void {
_scanQRCodeHandler = handler
}
// ─── JSBridge response helpers ─────────────────────────────────────────────────
type BridgeResponse = { code: 0; data: unknown } | { code: -1; message: string }
function ok(data: unknown): BridgeResponse {
return { code: 0, data }
}
function fail(message: string): BridgeResponse {
return { code: -1, message }
}
function showToast(message: string): void {
if (Platform.OS === 'android') {
ToastAndroid.show(message, ToastAndroid.SHORT)
} else {
Alert.alert('', message)
}
}
// ─── JSBridge message processor ───────────────────────────────────────────────
/**
* WebView JSBridge action `xuqm.`
*
* WebView `onMessage` bridge
* `true` userOnMessage `false`
*
* @param rawData WebViewMessageEvent.nativeEvent.data
* @param postMessageToWeb WebView injectJavaScript
*/
export async function processJSBridgeMessage(
rawData: string,
postMessageToWeb: (js: string) => void,
): Promise<boolean> {
let parsed: {
__xuqm?: string
id?: string | number
params?: Record<string, unknown>
}
try {
parsed = JSON.parse(rawData)
} catch {
return false
}
const action = parsed.__xuqm
if (typeof action !== 'string' || !action.startsWith('xuqm.')) {
return false
}
const id = parsed.id
const params = parsed.params ?? {}
function respond(result: BridgeResponse): void {
const js = `
(function(){
var e = new CustomEvent('xuqm_bridge_response_${String(id ?? '')}', { detail: ${JSON.stringify(result)} });
window.dispatchEvent(e);
if (window.__xuqmBridgeCallback) {
window.__xuqmBridgeCallback(${JSON.stringify(String(id ?? ''))}, ${JSON.stringify(result)});
}
})();
true;`.trim()
postMessageToWeb(js)
}
try {
switch (action) {
case 'xuqm.getDeviceInfo': {
const info = await getDeviceInfo()
respond(ok(info))
break
}
case 'xuqm.getToken': {
const token = await _getAccessToken()
respond(ok(token))
break
}
case 'xuqm.getUserInfo': {
const info = getUserInfo()
respond(ok(info))
break
}
case 'xuqm.openNativePage': {
const route = params.route
if (typeof route !== 'string') {
respond(fail('params.route is required'))
break
}
const navParams = params.params as Record<string, unknown> | undefined
if (!_navigationRef) {
respond(fail('navigation not available — call setXWebViewNavigationRef() in host app'))
break
}
_navigationRef.navigate(route, navParams)
respond(ok(null))
break
}
case 'xuqm.closeWebView': {
if (!_navigationRef) {
respond(fail('navigation not available — call setXWebViewNavigationRef() in host app'))
break
}
_navigationRef.goBack()
respond(ok(null))
break
}
case 'xuqm.showToast': {
const message = params.message
if (typeof message !== 'string') {
respond(fail('params.message is required'))
break
}
showToast(message)
respond(ok(null))
break
}
case 'xuqm.scanQRCode': {
if (!_scanQRCodeHandler) {
respond(
fail('scanQRCode not available — call setXWebViewScanQRCodeHandler() in host app'),
)
break
}
const result = await _scanQRCodeHandler()
respond(ok(result))
break
}
default:
// Unknown xuqm.* action — not handled by built-in bridge
return false
}
} catch (err) {
respond(fail(err instanceof Error ? err.message : String(err)))
}
return true
}
// ─── Config + Controller ───────────────────────────────────────────────────────
let _config: XWebViewConfig = {}
let _controller: XWebViewControllerAPI | null = null
export function openXWebView(navigate: (pluginId: string) => void, config: XWebViewConfig) {
_config = { ...config }
navigate('xwebview')
}
export function getXWebViewConfig(): XWebViewConfig {
return _config
}
export function setXWebViewController(controller: XWebViewControllerAPI | null) {
_controller = controller
}
export const XWebViewControl: XWebViewControllerAPI = {
refresh: () => _controller?.refresh(),
close: () => _controller?.close(),
goBack: () => _controller?.goBack(),
goForward: () => _controller?.goForward(),
copyUrl: () => _controller?.copyUrl(),
postMessageToWeb: js => _controller?.postMessageToWeb(js),
getTitle: () => _controller?.getTitle() ?? '',
getNavigationState: () =>
_controller?.getNavigationState() ?? {
canGoBack: false,
canGoForward: false,
title: '',
url: '',
},
}

查看文件

@ -1,99 +0,0 @@
import type React from 'react'
export type XWebViewClickMenu = {
view?: React.ReactNode
onClick: () => void
}
export type XWebViewMessageEvent = {
nativeEvent: { data: string }
}
export type XWebViewPermissionRequest = {
origin: string
resources: string[]
grant: (resources?: string[]) => void
deny: () => void
}
export type XWebViewDownloadRequest = {
url: string
suggestedFilename: string
mimeType?: string
fileSize?: number
}
export type XWebViewDownloadDecision = {
allowed: boolean
filename?: string
savePath?: string
}
export type XWebViewDownloadProgress = {
url: string
filename: string
received: number
total: number
percentage: number
}
export type XWebViewDownloadResult = {
url: string
filename: string
filePath: string
fileSize: number
}
export type XWebViewNavigationState = {
canGoBack: boolean
canGoForward: boolean
title: string
url: string
}
export type XWebViewControllerAPI = {
refresh: () => void
close: () => void
goBack: () => void
goForward: () => void
copyUrl: () => void
postMessageToWeb: (jsString: string) => void
getTitle: () => string
getNavigationState: () => XWebViewNavigationState
}
export type XWebViewDownloadDestination = 'sandbox' | 'publicDownloads'
export type XWebViewConfig = {
showTopBar?: boolean
showStatusBar?: boolean
doubleBackExit?: boolean
title?: string
showTitle?: boolean
autoTitle?: boolean
showMenu?: boolean
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
onDownloadStart?: (request: XWebViewDownloadRequest) => void
onDownloadProgress?: (progress: XWebViewDownloadProgress) => void
onDownloadComplete?: (result: XWebViewDownloadResult) => void
onDownloadError?: (url: string, error: string) => void
onDownloadDecide?: (
request: XWebViewDownloadRequest,
) => XWebViewDownloadDecision | Promise<XWebViewDownloadDecision>
downloadConflict?: 'rename' | 'overwrite'
/** 下载保存位置;与原生 Android SDK FileDownloadDestination 对齐。 */
downloadDestination?: XWebViewDownloadDestination
/** 非空时显示状态栏下载进度通知。 */
downloadNotificationTitle?: string
onClose?: () => void
}

查看文件

@ -1,53 +1,122 @@
# @xuqm/rn-xwebview
`XWebViewView` 是 WebView、Bridge、下载、权限和加载错误的唯一内核;`XWebViewScreen` 只组合全屏导航栏与系统返回。两者不得分别维护请求、下载、权限或错误状态机。
通用 React Native WebView 容器。`1.0.0` 只有三项正式入口:
## 摄像头与麦克风权限
- `<XWebViewHost />`:应用根部挂载一次的全屏页面 Host。
- `openWebView(config)`:从任意业务服务打开页面,返回该页面自己的 Handle。
- `<XWebViewView config={...} />`:不需要全屏导航栏时使用的内嵌浏览器内核。
Android 宿主按实际业务在 `AndroidManifest.xml` 声明权限:
SDK 不包含任何医网信、厂商、用户、平台、域名或业务 Bridge 定义。
## 安装
```bash
pnpm add @xuqm/rn-common @xuqm/rn-xwebview \
react-native-webview react-native-svg \
@react-native-clipboard/clipboard react-native-safe-area-context
```
`react-native-svg` 含原生 ViewManager,必须由宿主直接声明并完成 React Native
autolink;它不能只作为 `@xuqm/rn-xwebview` 的传递依赖。缺少该直接依赖时,使用
SDK 内置导航图标的页面会在运行期报告找不到 `RNSVGSvgViewAndroid`
`@react-native-camera-roll/camera-roll` 是可选 peer;只有需要把下载图片登记到系统相册
时才安装。
## 最小集成
```tsx
import { XWebViewHost, openWebView } from '@xuqm/rn-xwebview'
export function App() {
return (
<>
<RootNavigation />
<XWebViewHost />
</>
)
}
const page = openWebView({
source: { uri: 'https://example.com' },
navigationBar: {
title: { text: '页面标题', mode: 'web' },
},
statusBar: {
translucent: true,
},
})
page.reload()
page.postMessage('hello')
page.close()
const result = await page.closed
```
Host 尚未挂载时请求最多等待 3 秒,超时以 `host_unavailable` 关闭。全局只能挂载一个
Host;Host 卸载会以 `host_unmounted` 关闭其全部页面。
## 分组配置
`openWebView``XWebViewView` 使用同一个 `XWebViewConfig`
- `source``{uri, headers?}` 与 `{html, baseUrl?}` 二选一。
- `navigationBar`:标题、返回、关闭、菜单、纯色/渐变/图片背景或完整自定义渲染。
- `statusBar`:显隐、透明沉浸、背景和前景模式。
- `behavior`Cookie、混合内容、外链决策、JS 注入、消息与生命周期回调。
- `permissions`:摄像头/麦克风白名单与可选宿主决策。
- `downloads`:自动/人工决策、目标目录、鉴权头、进度、完成和失败回调。
- `bridge`由具体业务协议包提供;XWebView 只负责转发与页面上下文。
禁止恢复扁平的 `url/title/showTopBar` 双轨配置。
## 页面栈与系统返回
每次 `openWebView` 都生成独立页面和 Handle。页面栈只显示顶层页面,关闭后恢复下一层
页面及其导航状态。Android 系统返回先执行网页历史返回,没有历史才关闭当前页。
导航标题和显隐修改属于当前页面,不能污染下层页面。H5 隐藏原生导航栏但保留状态栏
时,Bridge 可以从页面上下文取得真实安全区高度。
## 权限
SDK 不向 Manifest/Info.plist 注入敏感权限。宿主按启用能力声明,例如 Android
```xml
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
```
H5 请求摄像头或麦克风时,XWebView 默认统一申请 Android 运行时权限,只向 WebView 放行系统已授权的摄像头或麦克风资源。无法识别的 WebView 资源一律拒绝,不因 Android 权限恰好已授予而扩大授权范围。宿主无需在每个页面重复实现。需要额外来源校验或自定义提示时可提供 `onPermissionRequest`,此时授权决策完全由宿主回调负责。
H5 调用 `getUserMedia` 时,XWebView 先检查页面配置和宿主决策,再在 Android 请求系统
权限,最后才继续 WebView 媒体请求。未识别资源和 3 秒内未完成的自定义决策均拒绝。
统一 WebView、JSBridge 与文件下载模块。它复用 `@xuqm/rn-common` 的配置、会话、文件和 MIME 能力,不提供第二套网络或用户状态。
## 下载与安全默认值
## 安装
- 默认保存到 App 沙盒;`publicDownloads` 在 Android 使用沙盒落盘后登记到下载列表。
- URI 首屏 headers 自动复用于 HEAD 与文件下载,也可由 `downloads.headers` 补充。
- Cookie 默认共享,第三方 Cookie 默认关闭。
- 混合内容默认 `never`;生产页面不得改为 `always`
- `http/https/about:blank` 在容器内打开;`tel/sms/mailto` 交给系统;`file/javascript/intent`
和未知 scheme 默认拒绝。
- Bridge 异常不会形成未处理 Promise;宿主可通过 `behavior.onBridgeError` 接收不含原始
payload 的错误信息。
## 迁移到 1.0.0
旧的 `XWebViewScreen`、`XWebViewControl`、`openXWebView(navigate, flatConfig)` 和
common 内的 WebView Bridge 已删除,不提供兼容壳。
迁移原则:
1. 根部挂载一次 `<XWebViewHost />`
2. 扁平参数转换为上述分组配置。
3. 全局 Control 改为保存本次 `openWebView` 返回的页面 Handle。
4. 业务 Bridge 单独作为 `config.bridge` 注入,不进入 common 或 XWebView。
5. 内嵌页面直接使用 `<XWebViewView />`,不再直接包装 `react-native-webview`
## 验证
```bash
pnpm add @xuqm/rn-common @xuqm/rn-xwebview \
react-native-webview react-native-blob-util react-native-svg
pnpm --dir packages/xwebview typecheck
pnpm --dir packages/xwebview test
```
## 使用
```tsx
import { XWebViewView, openXWebView } from '@xuqm/rn-xwebview'
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: '页面',
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 当前会话。

查看文件

@ -1,6 +1,6 @@
{
"name": "@xuqm/rn-xwebview",
"version": "0.3.0-alpha.2",
"version": "1.0.0",
"description": "XuqmGroup RN SDK — XWebView module",
"license": "UNLICENSED",
"main": "src/index.ts",
@ -18,17 +18,16 @@
"typecheck": "tsc --noEmit -p tsconfig.json"
},
"dependencies": {
"react-native-svg": "^15.15.4",
"react-native-webview": "^14.0.1"
},
"peerDependencies": {
"@xuqm/rn-common": "workspace:>=0.6.0-alpha.9 <1.0.0",
"react": ">=18.0.0",
"react-native": ">=0.76.0",
"@react-native-camera-roll/camera-roll": ">=7.0.0",
"@react-native-clipboard/clipboard": ">=1.16.0",
"@react-navigation/native": ">=7.0.0",
"react-native-safe-area-context": ">=4.0.0",
"@xuqm/rn-common": "workspace:>=0.6.0-alpha.4"
"react-native-svg": ">=15.0.0"
},
"peerDependenciesMeta": {
"@react-native-camera-roll/camera-roll": {
@ -37,9 +36,9 @@
},
"devDependencies": {
"@types/react": "^19.0.0",
"@react-navigation/native": "^7.0.0",
"@react-native-clipboard/clipboard": "^1.16.3",
"react-native-safe-area-context": "^5.4.0",
"react-native-svg": "^15.15.4",
"tsx": "^4.23.1",
"typescript": "catalog:"
}

查看文件

@ -1,4 +1,4 @@
import type { XWebViewDownloadRequest } from '@xuqm/rn-common'
import type { XWebViewDownloadRequest } from './types'
import {
fileNameFromUrl,
parseContentDispositionFileName,
@ -14,10 +14,11 @@ function normalizedSize(value: string | null): number | undefined {
export async function fetchDownloadInfo(
url: string,
hintFilename?: string,
headers?: Record<string, string>,
): Promise<XWebViewDownloadRequest> {
const hint = hintFilename?.trim()
try {
const response = await fetch(url, { method: 'HEAD' })
const response = await fetch(url, { headers, method: 'HEAD' })
const disposition = response.headers.get('content-disposition') ?? ''
const mimeType = response.headers.get('content-type')?.split(';')[0].trim() || undefined
const suggestedFilename =

查看文件

@ -1,22 +1,18 @@
import { Platform } from 'react-native'
import type {
XWebViewDownloadDecision,
XWebViewDownloadDestination,
XWebViewDownloadProgress,
XWebViewDownloadRequest,
XWebViewDownloadResult,
} from '@xuqm/rn-common'
} from './types'
import {
downloadFileToPath,
getFileDirectories,
inferMimeType,
openLocalFile,
registerAndroidDownloadedFile,
resolveAvailableFilePath,
writeBase64File,
} from '@xuqm/rn-common'
import { fetchDownloadInfo } from './DownloadMetadata'
export { fetchDownloadInfo } from './DownloadMetadata'
@ -50,18 +46,6 @@ async function saveToGallery(filePath: string): Promise<boolean> {
}
}
/**
* Android: actionViewIntentiOS: openDocument
* best-effort
*/
export async function openFile(filePath: string, filenameOrMime?: string): Promise<boolean> {
const mime =
filenameOrMime && filenameOrMime.includes('/')
? filenameOrMime
: inferMimeType(filenameOrMime ?? filePath)
return openLocalFile(filePath, mime)
}
export async function saveBase64File(
base64: string,
filename: string,
@ -105,73 +89,7 @@ export async function saveBase64File(
return filePath
}
export type DownloadHandle = { cancel: () => void }
/**
*
*
* fetchDownloadInfo onDownloadDecide startDownload
* XWebViewScreen / XWebViewView 使
*
* @returns DownloadHandle null
*/
export async function handleDownloadRequest(
url: string,
hintFilename: string | undefined,
options: {
autoDownload?: boolean
downloadConflict?: 'rename' | 'overwrite'
savePath?: string
downloadDestination?: XWebViewDownloadDestination
downloadNotificationTitle?: string
onDownloadDecide?: (
request: XWebViewDownloadRequest,
) => XWebViewDownloadDecision | Promise<XWebViewDownloadDecision>
onDownloadStart?: (request: XWebViewDownloadRequest) => void
onDownloadProgress?: (progress: XWebViewDownloadProgress) => void
onDownloadComplete?: (result: XWebViewDownloadResult) => void
onDownloadError?: (url: string, error: string) => void
} = {},
): Promise<DownloadHandle | null> {
const {
autoDownload = true,
downloadConflict = 'rename',
savePath,
downloadDestination = 'sandbox',
downloadNotificationTitle,
onDownloadDecide,
onDownloadStart,
onDownloadProgress,
onDownloadComplete,
onDownloadError,
} = options
const info = await fetchDownloadInfo(url, hintFilename)
let finalFilename = info.suggestedFilename
let finalSavePath = savePath
// 非自动下载模式:需要宿主决定是否允许
if (!autoDownload) {
if (!onDownloadDecide) return null
const decision = await Promise.resolve(onDownloadDecide(info))
if (!decision.allowed) return null
if (decision.filename) finalFilename = decision.filename
if (decision.savePath) finalSavePath = decision.savePath
}
onDownloadStart?.({ ...info, suggestedFilename: finalFilename })
return startDownload(
url,
finalFilename,
finalSavePath,
downloadConflict,
p => onDownloadProgress?.(p),
r => onDownloadComplete?.(r),
err => onDownloadError?.(url, err),
{ destination: downloadDestination, notificationTitle: downloadNotificationTitle },
)
}
type DownloadHandle = { cancel: () => void }
export function startDownload(
url: string,
@ -183,10 +101,11 @@ export function startDownload(
onError: (error: string) => void,
options: {
destination?: XWebViewDownloadDestination
headers?: Record<string, string>
notificationTitle?: string
} = {},
): DownloadHandle {
const { destination = 'sandbox', notificationTitle } = options
const { destination = 'sandbox', headers, notificationTitle } = options
const dirs = getFileDirectories()
// 公共 DownloadsAndroid下载到沙盒保留 JS 进度回调),完成后通过 DownloadManager
@ -202,6 +121,7 @@ export function startDownload(
.then(cachePath => {
if (cancelled) return
const task = downloadFileToPath(url, cachePath, {
headers,
onProgress: progress => {
onProgress({
url,
@ -259,6 +179,7 @@ export function startDownload(
if (cancelled) return
const task = downloadFileToPath(url, filePath, {
headers,
onProgress: progress => {
onProgress({
url,

查看文件

@ -0,0 +1,403 @@
import React, { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
import { ImageBackground, 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'
import {
closeActivePage,
getXWebViewSnapshot,
mountXWebViewHost,
subscribeXWebViewRuntime,
type XWebViewPage,
} from './runtime'
import type {
XWebViewMenuItem,
XWebViewNavigationBackground,
XWebViewNavigationRenderContext,
} from './types'
import { XWebViewView } from './XWebViewView'
import IconBack from './icons/IconBack'
import IconClose from './icons/IconClose'
import IconMenu from './icons/IconMenu'
const NAVIGATION_HEIGHT = 48
const HIT_SLOP = { bottom: 10, left: 10, right: 10, top: 10 }
function isLightText(color: string): boolean {
const value = color.trim().replace('#', '')
if (!/^[0-9a-f]{6}$/i.test(value)) return false
const red = Number.parseInt(value.slice(0, 2), 16)
const green = Number.parseInt(value.slice(2, 4), 16)
const blue = Number.parseInt(value.slice(4, 6), 16)
return red * 0.299 + green * 0.587 + blue * 0.114 > 170
}
function gradientPoints(angle: number) {
const radians = ((angle % 360) * Math.PI) / 180
const x = Math.sin(radians)
const y = Math.cos(radians)
return {
x1: `${(0.5 - x / 2) * 100}%`,
x2: `${(0.5 + x / 2) * 100}%`,
y1: `${(0.5 - y / 2) * 100}%`,
y2: `${(0.5 + y / 2) * 100}%`,
}
}
function NavigationBackground({
background,
children,
}: {
background: XWebViewNavigationBackground
children: React.ReactNode
}) {
const content = (
<>
{background.gradient && background.gradient.length >= 2 ? (
<Svg style={StyleSheet.absoluteFill}>
<Defs>
<LinearGradient
id="xwv-navigation-gradient"
{...gradientPoints(background.gradientAngle ?? 0)}
>
{background.gradient.map((color, index, values) => (
<Stop
key={`${color}-${index}`}
offset={`${(index / (values.length - 1)) * 100}%`}
stopColor={color}
/>
))}
</LinearGradient>
</Defs>
<Rect width="100%" height="100%" fill="url(#xwv-navigation-gradient)" />
</Svg>
) : null}
{children}
</>
)
if (background.image) {
return (
<ImageBackground
source={{ uri: background.image }}
resizeMode="cover"
style={[styles.navigationBackground, { backgroundColor: background.color ?? '#ffffff' }]}
>
{content}
</ImageBackground>
)
}
return (
<View style={[styles.navigationBackground, { backgroundColor: background.color ?? '#ffffff' }]}>
{content}
</View>
)
}
function DefaultMenu({
items,
onDismiss,
topOffset,
visible,
}: {
items: Array<{
id: string
icon?: React.ReactNode
label: string
onPress(): void | Promise<void>
}>
onDismiss(): void
topOffset: number
visible: boolean
}) {
return (
<Modal
animationType="fade"
navigationBarTranslucent
onRequestClose={onDismiss}
statusBarTranslucent
transparent
visible={visible}
>
<View style={styles.menuLayer}>
<Pressable style={StyleSheet.absoluteFill} onPress={onDismiss} />
<View style={[styles.menuPanel, { marginTop: topOffset }]}>
{items.map(item => (
<Pressable
key={item.id}
onPress={() => {
onDismiss()
void item.onPress()
}}
style={styles.menuItem}
>
{item.icon}
<Text style={styles.menuText}>{item.label}</Text>
</Pressable>
))}
</View>
</View>
</Modal>
)
}
function NavigationBar({ page, topInset }: { 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 ?? true,
}
const closeButton = {
color: navigationBar.closeButton?.color ?? presentation.titleColor,
visible: navigationBar.closeButton?.visible ?? true,
}
const menuContext = useMemo(
() => ({
close: () => handle.close('api'),
controller: handle,
navigationState,
}),
[handle, navigationState],
)
const menuItems = useMemo<XWebViewMenuItem[]>(
() =>
navigationBar.menu?.items ?? [
{
id: 'reload',
label: '刷新',
onPress: () => handle.reload(),
},
{
id: 'copy-url',
label: '复制链接',
onPress: () => Clipboard.setString(navigationState.url),
},
],
[handle, navigationBar.menu?.items, navigationState.url],
)
const renderContext: XWebViewNavigationRenderContext = {
...menuContext,
background: presentation.background,
backButton,
closeButton,
title: {
color: presentation.titleColor,
mode: navigationBar.title?.mode,
text: presentation.titleText,
},
}
if (navigationBar.render) {
return <>{navigationBar.render(renderContext)}</>
}
return (
<NavigationBackground background={presentation.background}>
<View style={{ height: topInset }} />
<View style={styles.navigationRow}>
<View style={styles.navigationSide}>
{backButton.visible ? (
<Pressable
hitSlop={HIT_SLOP}
onPress={() => {
if (navigationState.canGoBack) handle.goBack()
else handle.close('back')
}}
style={styles.navigationButton}
>
<IconBack color={backButton.color} size={24} />
</Pressable>
) : null}
</View>
<Text
numberOfLines={1}
style={[styles.navigationTitle, { color: presentation.titleColor }]}
>
{presentation.titleText}
</Text>
<View style={[styles.navigationSide, styles.navigationRight]}>
{(navigationBar.menu?.visible ?? true) ? (
<Pressable
hitSlop={HIT_SLOP}
onPress={() => setMenuVisible(true)}
style={styles.navigationButton}
>
<IconMenu color={presentation.titleColor} size={22} />
</Pressable>
) : null}
{closeButton.visible ? (
<Pressable
hitSlop={HIT_SLOP}
onPress={() => handle.close('api')}
style={styles.navigationButton}
>
<IconClose color={closeButton.color} size={24} />
</Pressable>
) : null}
</View>
</View>
<DefaultMenu
items={menuItems.map(item => ({
...item,
onPress: async () => item.onPress(menuContext),
}))}
onDismiss={() => setMenuVisible(false)}
topOffset={topInset + NAVIGATION_HEIGHT}
visible={menuVisible}
/>
</NavigationBackground>
)
}
function HostPage({ active, page }: { active: boolean; page: XWebViewPage }) {
const insets = useSafeAreaInsets()
const { config, presentation } = page
const configuredStyle = config.statusBar?.contentStyle ?? 'auto'
const barStyle =
configuredStyle === 'auto'
? isLightText(presentation.titleColor)
? 'light-content'
: 'dark-content'
: configuredStyle
return (
<View
pointerEvents={active ? 'auto' : 'none'}
style={[styles.page, !active && styles.hiddenPage]}
>
{active ? (
<StatusBar
animated
backgroundColor={config.statusBar?.backgroundColor ?? 'transparent'}
barStyle={barStyle}
hidden={!presentation.statusBarVisible}
translucent={config.statusBar?.translucent ?? true}
/>
) : null}
{presentation.navigationBarVisible ? (
<NavigationBar page={page} topInset={presentation.statusBarVisible ? insets.top : 0} />
) : null}
<XWebViewView active={active} config={config} pageId={page.id} statusBarHeight={insets.top} />
</View>
)
}
/**
* WebView 宿 openWebView
*/
export function XWebViewHost() {
const snapshot = useSyncExternalStore(
subscribeXWebViewRuntime,
getXWebViewSnapshot,
getXWebViewSnapshot,
)
useEffect(() => mountXWebViewHost(), [])
const handleRequestClose = useCallback(() => {
const page = snapshot.pages.at(-1)
if (!page) return
if (page.navigationState.canGoBack) page.handle.goBack()
else closeActivePage('back')
}, [snapshot.pages])
return (
<Modal
animationType="slide"
navigationBarTranslucent
onRequestClose={handleRequestClose}
presentationStyle="fullScreen"
statusBarTranslucent
visible={snapshot.pages.length > 0}
>
<SafeAreaProvider>
<View style={styles.host}>
{snapshot.pages.map((page, index) => (
<HostPage key={page.id} active={index === snapshot.pages.length - 1} page={page} />
))}
</View>
</SafeAreaProvider>
</Modal>
)
}
const styles = StyleSheet.create({
hiddenPage: {
display: 'none',
},
host: {
flex: 1,
backgroundColor: '#ffffff',
},
menuItem: {
alignItems: 'center',
flexDirection: 'row',
gap: 10,
minHeight: 44,
paddingHorizontal: 16,
},
menuLayer: {
backgroundColor: 'rgba(0,0,0,0.16)',
flex: 1,
},
menuPanel: {
alignSelf: 'flex-end',
backgroundColor: '#ffffff',
borderRadius: 8,
elevation: 8,
marginRight: 12,
minWidth: 150,
paddingVertical: 4,
shadowColor: '#000000',
shadowOffset: { height: 2, width: 0 },
shadowOpacity: 0.18,
shadowRadius: 8,
},
menuText: {
color: '#111827',
fontSize: 15,
},
navigationBackground: {
overflow: 'visible',
},
navigationButton: {
alignItems: 'center',
height: NAVIGATION_HEIGHT,
justifyContent: 'center',
minWidth: 36,
},
navigationRight: {
justifyContent: 'flex-end',
},
navigationRow: {
alignItems: 'center',
flexDirection: 'row',
height: NAVIGATION_HEIGHT,
paddingHorizontal: 8,
},
navigationSide: {
alignItems: 'center',
flexDirection: 'row',
minWidth: 80,
},
navigationTitle: {
flex: 1,
fontSize: 17,
fontWeight: '600',
textAlign: 'center',
},
page: {
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0,
backgroundColor: '#ffffff',
},
})

查看文件

@ -1,80 +1,68 @@
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
}
}
import type { XWebViewPermissionRequest, XWebViewPermissionsConfig } from './types'
import type { WebPermissionKind } from './permissionPolicy'
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)
function requestFromHost(
config: XWebViewPermissionsConfig,
origin: string,
resources: WebPermissionKind[],
): Promise<boolean> {
if (!config.onRequest) {
return Promise.resolve(resources.every(resource => config[resource] === true))
}
if (requestedKinds.size === 0) return requestedKinds
return new Promise(resolve => {
let settled = false
let timer: ReturnType<typeof setTimeout>
const finish = (allowed: boolean) => {
if (settled) return
settled = true
clearTimeout(timer)
resolve(allowed)
}
const request: XWebViewPermissionRequest = {
deny: () => finish(false),
grant: granted => {
const selected = granted ?? resources
finish(resources.every(resource => selected.includes(resource)))
},
origin,
resources,
}
timer = setTimeout(() => finish(false), 3_000)
config.onRequest?.(request)
})
}
async function requestAndroidPermissions(resources: WebPermissionKind[]): Promise<boolean> {
const uniqueResources = [...new Set(resources)]
const results = await PermissionsAndroid.requestMultiple(
[...requestedKinds].map(kind => ANDROID_PERMISSIONS[kind]),
uniqueResources.map(resource => ANDROID_PERMISSIONS[resource]),
)
return new Set(
[...requestedKinds].filter(
kind => results[ANDROID_PERMISSIONS[kind]] === PermissionsAndroid.RESULTS.GRANTED,
),
return uniqueResources.every(
resource => results[ANDROID_PERMISSIONS[resource]] === PermissionsAndroid.RESULTS.GRANTED,
)
}
/**
* XWebView 宿宿使
* 宿 WebView getUserMedia
* SDK Manifest 宿
*/
export function useXWebViewPermissionRequest(
onPermissionRequest?: (request: XWebViewPermissionRequest) => void,
) {
export function useXWebViewPermissionDecision(config: XWebViewPermissionsConfig = {}) {
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)
async (origin: string, resources: WebPermissionKind[]) => {
if (resources.length === 0) return false
if (!(await requestFromHost(config, origin, resources))) return false
if (Platform.OS !== 'android') return true
return requestAndroidPermissions(resources)
},
[onPermissionRequest],
[config],
)
}

查看文件

@ -1,230 +0,0 @@
import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
import {
ActionSheetIOS,
Alert,
BackHandler,
Linking,
Platform,
Pressable,
StatusBar,
StyleSheet,
Text,
ToastAndroid,
View,
} from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import { SafeAreaView } from 'react-native-safe-area-context'
import { useNavigation } from '@react-navigation/native'
import {
getXWebViewConfig,
XWebViewControl,
type XWebViewConfig,
type XWebViewNavigationState,
} from '@xuqm/rn-common'
import { XWebViewView } from './XWebViewView'
import IconBack from './icons/IconBack'
import IconClose from './icons/IconClose'
import IconMenu from './icons/IconMenu'
const HIT_SLOP = { top: 10, bottom: 10, left: 10, right: 10 }
function HeaderBackClose({
canGoBack,
onBack,
onClose,
}: {
canGoBack: boolean
onBack: () => void
onClose: () => void
}) {
return (
<View style={styles.headerLeft}>
{canGoBack ? (
<Pressable style={styles.headerBtn} onPress={onBack} hitSlop={HIT_SLOP}>
<IconBack size={22} color="#222222" />
</Pressable>
) : null}
<Pressable style={styles.headerBtn} onPress={onClose} hitSlop={HIT_SLOP}>
<IconClose size={24} color="#222222" />
</Pressable>
</View>
)
}
function MenuButton({ config, currentUrl }: { config: XWebViewConfig; currentUrl: string }) {
const openDefaultMenu = useCallback(() => {
const actions: { label: string; handler: () => void }[] = [
{ label: '刷新', handler: XWebViewControl.refresh },
{ label: '复制链接', handler: () => Clipboard.setString(currentUrl) },
]
if (config.openForBrowser && currentUrl) {
actions.push({ label: '浏览器打开', handler: () => void Linking.openURL(currentUrl) })
}
if (Platform.OS === 'ios') {
ActionSheetIOS.showActionSheetWithOptions(
{
options: [...actions.map(action => action.label), '取消'],
cancelButtonIndex: actions.length,
},
index => {
if (index < actions.length) actions[index].handler()
},
)
return
}
Alert.alert(
'操作',
undefined,
[
...actions.map(action => ({ text: action.label, onPress: action.handler })),
{ text: '取消', style: 'cancel' as const },
],
{ cancelable: true },
)
}, [config.openForBrowser, currentUrl])
if (config.clickMenu) {
return (
<Pressable style={styles.headerBtn} onPress={config.clickMenu.onClick} hitSlop={HIT_SLOP}>
{config.clickMenu.view ?? <IconMenu size={22} />}
</Pressable>
)
}
return (
<Pressable style={styles.headerBtn} onPress={openDefaultMenu} hitSlop={HIT_SLOP}>
<IconMenu size={22} />
</Pressable>
)
}
/**
* WebViewBridge
* XWebViewView Screen/View
*/
export function XWebViewScreen() {
const navigation = useNavigation()
const config = getXWebViewConfig()
const {
autoTitle = false,
doubleBackExit = false,
showMenu = true,
showStatusBar = true,
showTitle = true,
showTopBar = true,
title: initialTitle = '',
} = config
const [navigationState, setNavigationState] = useState<XWebViewNavigationState>({
canGoBack: false,
canGoForward: false,
title: initialTitle,
url: config.url ?? '',
})
const lastBackPressRef = useRef(0)
const close = useCallback(() => {
config.onClose?.()
navigation.goBack()
}, [config, navigation])
const handleNavigationStateChange = useCallback(
(state: XWebViewNavigationState) => {
setNavigationState(state)
config.onNavigationStateChange?.(state)
},
[config],
)
const screenConfig = useMemo(
() => ({
...config,
onClose: close,
onNavigationStateChange: handleNavigationStateChange,
}),
[close, config, handleNavigationStateChange],
)
useLayoutEffect(() => {
navigation.setOptions({ headerShown: false })
}, [navigation])
useEffect(() => {
if (Platform.OS !== 'android') return
const subscription = BackHandler.addEventListener('hardwareBackPress', () => {
if (navigationState.canGoBack) {
XWebViewControl.goBack()
return true
}
if (doubleBackExit) {
const now = Date.now()
if (now - lastBackPressRef.current > 2000) {
lastBackPressRef.current = now
ToastAndroid.show('再按一次退出', ToastAndroid.SHORT)
return true
}
}
close()
return true
})
return () => subscription.remove()
}, [close, doubleBackExit, navigationState.canGoBack])
const shouldShowStatusBar = showTopBar || showStatusBar
const ContentContainer = shouldShowStatusBar ? SafeAreaView : View
const displayedTitle = autoTitle ? navigationState.title : initialTitle
return (
<ContentContainer style={styles.container}>
<StatusBar hidden={!shouldShowStatusBar} />
{showTopBar ? (
<View style={styles.topBar}>
<HeaderBackClose
canGoBack={navigationState.canGoBack}
onBack={XWebViewControl.goBack}
onClose={close}
/>
<Text style={styles.title} numberOfLines={1}>
{showTitle ? displayedTitle : ''}
</Text>
{showMenu ? (
<MenuButton config={config} currentUrl={navigationState.url} />
) : (
<View style={styles.headerBtnPlaceholder} />
)}
</View>
) : null}
<XWebViewView config={screenConfig} manageSystemBars={false} registerController />
</ContentContainer>
)
}
const styles = StyleSheet.create({
container: { flex: 1, backgroundColor: '#ffffff' },
topBar: {
height: 48,
paddingHorizontal: 10,
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'space-between',
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: '#e2e8f0',
backgroundColor: '#ffffff',
},
title: {
flex: 1,
marginHorizontal: 10,
fontSize: 16,
fontWeight: '700',
color: '#111827',
textAlign: 'center',
},
headerLeft: { flexDirection: 'row', alignItems: 'center', gap: 5 },
headerBtn: {
paddingHorizontal: 0,
paddingVertical: 0,
justifyContent: 'center',
alignItems: 'center',
minWidth: 22,
},
headerBtnPlaceholder: { minWidth: 22 },
})

查看文件

@ -1,7 +1,6 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Alert, Platform, Pressable, StatusBar, StyleSheet, Text, View } from 'react-native'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Image, Linking, Pressable, StyleSheet, Text, View } from 'react-native'
import Clipboard from '@react-native-clipboard/clipboard'
import { SafeAreaView } from 'react-native-safe-area-context'
import WebView from 'react-native-webview'
import type {
ShouldStartLoadRequest,
@ -12,178 +11,221 @@ import type {
WebViewProgressEvent,
} from 'react-native-webview/lib/WebViewTypes'
import { getXWebViewConfig, setXWebViewController, type XWebViewConfig } from '@xuqm/rn-common'
import XWebViewProgress from './XWebViewProgress'
import { resolveLinkPolicy } from './linkPolicy'
import {
closePageById,
isActivePage,
registerPageController,
updatePageNavigation,
updatePageNavigationState,
updatePageWindow,
} from './runtime'
import type {
XWebViewBridgePageContext,
XWebViewConfig,
XWebViewController,
XWebViewNavigationState,
XWebViewSource,
} from './types'
import { fetchDownloadInfo, saveBase64File, startDownload } from './XWebViewDownload'
import { useXWebViewPermissionRequest } from './XWebViewPermissions'
import { useXWebViewPermissionDecision } from './XWebViewPermissions'
import XWebViewProgress from './XWebViewProgress'
const WebViewAny = WebView as unknown as React.ComponentType<any>
const DOWNLOAD_EXTENSIONS = [
'.pdf',
'.zip',
'.rar',
'.tar',
'.gz',
'.apk',
'.ipa',
'.doc',
'.docx',
'.xls',
'.xlsx',
'.ppt',
'.pptx',
'.mp4',
'.mp3',
'.mov',
'.exe',
'.dmg',
]
function isDownloadUrl(url: string): boolean {
const lower = url.toLowerCase().split('?')[0]
return DOWNLOAD_EXTENSIONS.some(ext => lower.endsWith(ext))
}
const DOWNLOAD_EXT_RE =
/\.(exe|apk|ipa|zip|rar|tar|gz|dmg|pkg|deb|rpm|msi|pdf|doc|docx|xls|xlsx|ppt|pptx|mp4|mp3|mov|avi|mkv)(\?|#|$)/i
const DIALOG_OVERRIDE_JS = `
const DOWNLOAD_INTERCEPT_SCRIPT = `
(function() {
function post(obj) {
window.ReactNativeWebView.postMessage(JSON.stringify(obj));
function post(value) {
if (window.ReactNativeWebView &&
typeof window.ReactNativeWebView.postMessage === 'function') {
window.ReactNativeWebView.postMessage(JSON.stringify(value));
}
}
window.alert = function(msg) { post({ __xwv: 'alert', msg: String(msg) }); };
window.confirm = function(msg) { post({ __xwv: 'confirm', msg: String(msg) }); return true; };
window.prompt = function(msg, def) { post({ __xwv: 'prompt', msg: String(msg), def: def || '' }); return def || ''; };
window.open = function(url) {
post({ __xwv: 'log', msg: '[XWV] window.open: ' + url });
if (url) { window.location.href = url; }
return null;
};
URL.revokeObjectURL = function() {};
function readBlobAndPost(blobUrl, filename) {
function readBlob(blobUrl, filename) {
fetch(blobUrl)
.then(function(r) { return r.blob(); })
.then(function(response) { return response.blob(); })
.then(function(blob) {
var reader = new FileReader();
reader.onloadend = function() {
var b64 = reader.result.split(',')[1];
post({ __xwv: 'blobdownload', url: blobUrl, filename: filename, data: b64 });
var value = String(reader.result || '');
var comma = value.indexOf(',');
post({
__xwv: 'blobdownload',
data: comma >= 0 ? value.slice(comma + 1) : value,
filename: filename || 'download',
url: blobUrl
});
};
reader.readAsDataURL(blob);
})
.catch(function(err) {
post({ __xwv: 'bloberror', msg: String(err) });
.catch(function() {
post({ __xwv: 'bloberror', url: blobUrl });
});
}
var DL_RE = ${JSON.stringify(DOWNLOAD_EXT_RE.source)};
var dlRe = new RegExp(DL_RE, 'i');
var downloadPattern = new RegExp(${JSON.stringify(DOWNLOAD_EXT_RE.source)}, 'i');
function tryInterceptAnchor(el, e) {
if (!el || el.tagName !== 'A') return false;
var href = el.href || el.getAttribute('href') || '';
if (!href || href.indexOf('javascript') === 0) return false;
var hasDownloadAttr = el.hasAttribute('download');
var dlName = el.getAttribute('download') || '';
var isDL = hasDownloadAttr || dlRe.test(href);
post({ __xwv: 'log', msg: '[XWV] anchor href=' + href + ' download=' + hasDownloadAttr + ' dlName=' + dlName + ' isDL=' + isDL });
if (isDL) {
if (e) { e.preventDefault(); e.stopPropagation(); }
if (href.startsWith('blob:')) {
readBlobAndPost(href, dlName);
} else {
post({ __xwv: 'download', url: href, filename: dlName });
}
return true;
function intercept(anchor, event) {
if (!anchor || anchor.tagName !== 'A') return false;
var href = anchor.href || anchor.getAttribute('href') || '';
if (!href || href.indexOf('javascript:') === 0) return false;
var filename = anchor.getAttribute('download') || '';
var download = anchor.hasAttribute('download') ||
downloadPattern.test(href);
if (!download) return false;
if (event) {
event.preventDefault();
event.stopPropagation();
}
return false;
if (href.indexOf('blob:') === 0) {
readBlob(href, filename);
} else {
post({ __xwv: 'download', filename: filename, url: href });
}
return true;
}
document.addEventListener('click', function(e) {
var el = e.target;
while (el && el.tagName !== 'A') { el = el.parentElement; }
tryInterceptAnchor(el, e);
document.addEventListener('click', function(event) {
var element = event.target;
while (element && element.tagName !== 'A') {
element = element.parentElement;
}
intercept(element, event);
}, true);
var _origClick = HTMLAnchorElement.prototype.click;
var originalClick = HTMLAnchorElement.prototype.click;
HTMLAnchorElement.prototype.click = function() {
post({ __xwv: 'log', msg: '[XWV] HTMLAnchorElement.click() href=' + this.href });
if (!tryInterceptAnchor(this, null)) {
_origClick.call(this);
}
if (!intercept(this, null)) originalClick.call(this);
};
post({ __xwv: 'log', msg: '[XWV] injected scripts ready' });
})();
true;
`
`.trim()
const MEDIA_PERMISSION_SCRIPT = `
(function() {
var mediaDevices = navigator.mediaDevices;
if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function' ||
mediaDevices.__xwvPermissionWrapped) return;
var originalGetUserMedia = mediaDevices.getUserMedia.bind(mediaDevices);
var pending = {};
var sequence = 0;
window.__XWV_PERMISSION_REPLY = function(id, granted) {
var item = pending[id];
if (!item) return;
delete pending[id];
if (!granted) {
item.reject(new DOMException('Permission denied by host', 'NotAllowedError'));
return;
}
originalGetUserMedia(item.constraints).then(item.resolve, item.reject);
};
mediaDevices.getUserMedia = function(constraints) {
var resources = [];
if (constraints && constraints.video) resources.push('camera');
if (constraints && constraints.audio) resources.push('microphone');
if (resources.length === 0) return originalGetUserMedia(constraints);
sequence += 1;
var id = 'xwv-permission-' + Date.now() + '-' + sequence;
return new Promise(function(resolve, reject) {
pending[id] = { constraints: constraints, reject: reject, resolve: resolve };
window.ReactNativeWebView.postMessage(JSON.stringify({
__xwv: 'permission',
id: id,
origin: location.origin,
resources: resources
}));
});
};
mediaDevices.__xwvPermissionWrapped = true;
})();
true;
`.trim()
function isDownloadUrl(url: string): boolean {
return DOWNLOAD_EXT_RE.test(url)
}
function joinScripts(...scripts: Array<string | undefined>): string {
return scripts.filter(Boolean).join('\n')
}
function isUriSource(source: XWebViewSource): source is Extract<XWebViewSource, { uri: string }> {
return typeof source.uri === 'string'
}
function validateImageSource(uri: string): Promise<void> {
return new Promise((resolve, reject) => {
Image.getSize(
uri,
() => resolve(),
() => reject(new Error('导航栏背景图片加载失败')),
)
})
}
export type XWebViewViewProps = {
active?: boolean
config: XWebViewConfig
pageId?: string
statusBarHeight?: number
}
/**
* XWebView
*
* Host Bridge
* react-native-webview
*/
export function XWebViewView({
config: configProp,
manageSystemBars = true,
registerController,
}: {
config?: XWebViewConfig
/** 外层 Screen 自己管理安全区和状态栏时关闭。 */
manageSystemBars?: boolean
/** 组合式 Screen 传入 config 时仍可显式注册全局控制器。 */
registerController?: boolean
} = {}) {
active = true,
config,
pageId,
statusBarHeight = 0,
}: XWebViewViewProps) {
const embeddedId = useRef(
`xwv-embedded-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
)
const resolvedPageId = pageId ?? embeddedId.current
const webViewRef = useRef<WebView>(null)
// configProp 用于内联嵌入(不依赖全局单例 config,可同屏多实例
// 不传时回退到 openXWebView 设置的全局 config全屏/miniapp 用法)。
const inline = configProp != null
const shouldRegisterController = registerController ?? !inline
const config = configProp ?? getXWebViewConfig()
const {
showStatusBar = true,
title: initialTitle = '',
url,
headers,
content,
injectedJavaScript,
onMessage: userOnMessage,
onNavigationStateChange,
onPermissionRequest,
autoDownload = true,
downloadConflict = 'rename',
downloadDestination = 'sandbox',
downloadNotificationTitle,
onDownloadStart,
onDownloadProgress,
onDownloadComplete,
onDownloadError,
onDownloadDecide,
onClose,
} = config
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)
const [navigationState, setNavigationState] = useState<XWebViewNavigationState>({
canGoBack: false,
canGoForward: false,
title: config.navigationBar?.title?.text ?? '',
url: isUriSource(config.source) ? config.source.uri : '',
})
const decidePermission = useXWebViewPermissionDecision(config.permissions)
const downloads = config.downloads ?? {}
const downloadHeaders = useMemo(
() => ({
...(isUriSource(config.source) ? config.source.headers : {}),
...(downloads.headers ?? {}),
}),
[config.source, downloads.headers],
)
const reload = useCallback(() => {
setLoadError(null)
setShowProgress(true)
webViewRef.current?.reload()
}, [])
useEffect(() => {
// 内联模式不注册全局 controller,避免与全屏 XWebView 实例互相覆盖。
if (!shouldRegisterController) return
setXWebViewController({
refresh: reload,
close: () => onClose?.(),
const controller = useMemo<XWebViewController>(
() => ({
copyUrl: () => Clipboard.setString(navigationState.url),
evaluateJavaScript: script => webViewRef.current?.injectJavaScript(script),
getNavigationState: () => navigationState,
goBack: () => {
setLoadError(null)
webViewRef.current?.goBack()
@ -192,225 +234,336 @@ export function XWebViewView({
setLoadError(null)
webViewRef.current?.goForward()
},
copyUrl: () => Clipboard.setString(currentUrl),
postMessageToWeb: (js: string) => webViewRef.current?.injectJavaScript(js),
getTitle: () => currentTitle,
getNavigationState: () => ({ canGoBack, canGoForward, title: currentTitle, url: currentUrl }),
})
return () => setXWebViewController(null)
}, [canGoBack, canGoForward, currentUrl, currentTitle, onClose, reload, shouldRegisterController])
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],
postMessage: message => webViewRef.current?.postMessage(message),
reload,
}),
[navigationState, reload],
)
const handleLoadProgress = useCallback((event: WebViewProgressEvent) => {
const p = event.nativeEvent.progress
setProgress(p)
if (p >= 0.96) setShowProgress(false)
}, [])
useEffect(() => {
if (pageId) registerPageController(pageId, controller)
else config.behavior?.onControllerReady?.(controller)
return () => {
if (pageId) registerPageController(pageId, null)
else config.behavior?.onControllerReady?.(null)
}
}, [config.behavior, controller, pageId])
const bridgeContext = useMemo<XWebViewBridgePageContext>(
() => ({
close: (reason = 'h5_close', data) => {
if (pageId) {
closePageById(pageId, reason, data)
} else {
config.behavior?.onClose?.({ data, reason })
}
},
evaluateJavaScript: script => webViewRef.current?.injectJavaScript(script),
getStatusBarHeight: () => statusBarHeight,
isActive: () => (pageId ? isActivePage(pageId) : active),
pageId: resolvedPageId,
updateNavigation: async patch => {
if (patch.background?.image) {
await validateImageSource(patch.background.image)
}
if (pageId) updatePageNavigation(pageId, patch)
},
updateWindow: patch => {
if (pageId) updatePageWindow(pageId, patch)
},
}),
[active, config.behavior, pageId, resolvedPageId, statusBarHeight],
)
const processBlobDownload = useCallback(
async (blobUrl: string, hintFilename: string, base64: string) => {
const info = { url: blobUrl, suggestedFilename: hintFilename || 'download' }
let finalFilename = info.suggestedFilename
let finalSavePath: string | undefined
if (!autoDownload) {
if (!onDownloadDecide) return
const decision = await Promise.resolve(onDownloadDecide(info))
const info = {
suggestedFilename: hintFilename || 'download',
url: blobUrl,
}
let filename = info.suggestedFilename
let savePath: string | undefined
if (downloads.auto === false) {
if (!downloads.onDecide) return
const decision = await Promise.resolve(downloads.onDecide(info))
if (!decision.allowed) return
if (decision.filename) finalFilename = decision.filename
if (decision.savePath) finalSavePath = decision.savePath
filename = decision.filename ?? filename
savePath = decision.savePath
}
onDownloadStart?.({ ...info, suggestedFilename: finalFilename })
downloads.onStart?.({ ...info, suggestedFilename: filename })
try {
const filePath = await saveBase64File(
base64,
finalFilename,
finalSavePath,
downloadConflict,
downloadDestination,
filename,
savePath,
downloads.conflict ?? 'rename',
downloads.destination ?? 'sandbox',
)
onDownloadComplete?.({ url: blobUrl, filename: finalFilename, filePath, fileSize: 0 })
} catch (err) {
onDownloadError?.(blobUrl, String(err))
downloads.onComplete?.({
filePath,
fileSize: 0,
filename,
url: blobUrl,
})
} catch (error) {
downloads.onError?.(blobUrl, String(error))
}
},
[
autoDownload,
downloadConflict,
downloadDestination,
onDownloadDecide,
onDownloadStart,
onDownloadComplete,
onDownloadError,
],
[downloads],
)
const processDownload = useCallback(
async (downloadUrl: string, hintFilename?: string) => {
const info = await fetchDownloadInfo(downloadUrl, hintFilename)
let finalFilename = info.suggestedFilename
let finalSavePath: string | undefined
try {
const info = await fetchDownloadInfo(downloadUrl, hintFilename, downloadHeaders)
let filename = info.suggestedFilename
let savePath: string | undefined
if (downloads.auto === false) {
if (!downloads.onDecide) return
const decision = await Promise.resolve(downloads.onDecide(info))
if (!decision.allowed) return
filename = decision.filename ?? filename
savePath = decision.savePath
}
if (!autoDownload) {
if (!onDownloadDecide) return
const decision = await Promise.resolve(onDownloadDecide(info))
if (!decision.allowed) return
if (decision.filename) finalFilename = decision.filename
if (decision.savePath) finalSavePath = decision.savePath
downloads.onStart?.({ ...info, suggestedFilename: filename })
startDownload(
downloadUrl,
filename,
savePath,
downloads.conflict ?? 'rename',
value => downloads.onProgress?.(value),
value => downloads.onComplete?.(value),
error => downloads.onError?.(downloadUrl, error),
{
destination: downloads.destination ?? 'sandbox',
headers: downloadHeaders,
notificationTitle: downloads.notificationTitle,
},
)
} catch (error) {
downloads.onError?.(downloadUrl, String(error))
}
onDownloadStart?.({ ...info, suggestedFilename: finalFilename })
startDownload(
downloadUrl,
finalFilename,
finalSavePath,
downloadConflict,
p => onDownloadProgress?.(p),
r => onDownloadComplete?.(r),
err => onDownloadError?.(downloadUrl, err),
{ destination: downloadDestination, notificationTitle: downloadNotificationTitle },
)
},
[
autoDownload,
downloadConflict,
downloadDestination,
downloadNotificationTitle,
onDownloadDecide,
onDownloadStart,
onDownloadProgress,
onDownloadComplete,
onDownloadError,
],
[downloadHeaders, downloads],
)
const handleMessage = useCallback(
(event: WebViewMessageEvent) => {
const raw = event.nativeEvent.data
try {
const parsed = JSON.parse(raw)
const parsed = JSON.parse(raw) as Record<string, unknown>
if (parsed.__xwv === 'download') {
processDownload(String(parsed.url), parsed.filename ? String(parsed.filename) : undefined)
void processDownload(
String(parsed.url ?? ''),
parsed.filename ? String(parsed.filename) : undefined,
)
return
}
if (parsed.__xwv === 'blobdownload') {
processBlobDownload(
String(parsed.url),
void processBlobDownload(
String(parsed.url ?? ''),
String(parsed.filename ?? 'download'),
String(parsed.data ?? ''),
)
return
}
if (parsed.__xwv === 'bloberror') {
onDownloadError?.(String(parsed.url ?? ''), String(parsed.msg ?? 'blob read failed'))
downloads.onError?.(String(parsed.url ?? ''), '无法读取网页生成的文件')
return
}
if (parsed.__xwv === 'alert' || parsed.__xwv === 'confirm' || parsed.__xwv === 'prompt') {
Alert.alert('提示', String(parsed.msg ?? ''))
if (parsed.__xwv === 'permission') {
const id = String(parsed.id ?? '')
const origin = String(parsed.origin ?? '')
const resources = Array.isArray(parsed.resources)
? parsed.resources.filter(
(value): value is 'camera' | 'microphone' =>
value === 'camera' || value === 'microphone',
)
: []
void decidePermission(origin, resources)
.then(granted => {
webViewRef.current?.injectJavaScript(
`window.__XWV_PERMISSION_REPLY&&window.__XWV_PERMISSION_REPLY(${JSON.stringify(id)},${granted});true;`,
)
})
.catch(() => {
webViewRef.current?.injectJavaScript(
`window.__XWV_PERMISSION_REPLY&&window.__XWV_PERMISSION_REPLY(${JSON.stringify(id)},false);true;`,
)
})
return
}
} catch {
// not internal
// 非 XWebView 内部消息,继续交给业务 Bridge。
}
userOnMessage?.(event)
const bridge = config.bridge
if (!bridge) {
config.behavior?.onMessage?.(event)
return
}
void Promise.resolve(bridge.onMessage(raw, bridgeContext))
.then(handled => {
if (!handled) config.behavior?.onMessage?.(event)
})
.catch(error => {
config.behavior?.onBridgeError?.(
error instanceof Error ? error.message : 'Bridge 调用失败',
)
})
},
[processDownload, processBlobDownload, onDownloadError, userOnMessage],
[
bridgeContext,
config.behavior,
config.bridge,
decidePermission,
downloads,
processBlobDownload,
processDownload,
],
)
const openExternal = useCallback(async (url: string) => {
if (await Linking.canOpenURL(url)) {
await Linking.openURL(url)
}
}, [])
const handleShouldStartLoad = useCallback(
(request: ShouldStartLoadRequest): boolean => {
if (isDownloadUrl(request.url)) {
processDownload(request.url)
void processDownload(request.url)
return false
}
return true
const policy = resolveLinkPolicy(request.url)
if (policy.action === 'allow') return true
if (policy.action === 'external') {
void openExternal(request.url)
return false
}
const decide = config.behavior?.onExternalUrl
if (decide) {
void Promise.resolve(decide(request.url)).then(decision => {
if (decision === 'external') void openExternal(request.url)
if (decision === 'allow') {
webViewRef.current?.injectJavaScript(
`window.location.href=${JSON.stringify(request.url)};true;`,
)
}
})
}
return false
},
[processDownload],
[config.behavior, openExternal, processDownload],
)
const handleError = useCallback((event: WebViewErrorEvent) => {
setLoadError(event.nativeEvent.description || '页面加载失败')
setShowProgress(false)
}, [])
const handleOpenWindow = useCallback(
(event: WebViewOpenWindowEvent) => {
const targetUrl = event.nativeEvent.targetUrl
if (!targetUrl) return
const policy = resolveLinkPolicy(targetUrl)
if (policy.action === 'allow') {
webViewRef.current?.injectJavaScript(
`window.location.href=${JSON.stringify(targetUrl)};true;`,
)
} else if (policy.action === 'external') {
void openExternal(targetUrl)
}
},
[openExternal],
)
const handleOpenWindow = useCallback((event: WebViewOpenWindowEvent) => {
const targetUrl = event.nativeEvent.targetUrl
if (targetUrl) {
webViewRef.current?.injectJavaScript(
`window.location.href = ${JSON.stringify(targetUrl)}; true;`,
)
}
}, [])
const handleNavigationStateChange = useCallback(
(state: WebViewNavigation) => {
const value: XWebViewNavigationState = {
canGoBack: state.canGoBack,
canGoForward: state.canGoForward,
title: state.title ?? '',
url: state.url ?? '',
}
setNavigationState(value)
if (pageId) updatePageNavigationState(pageId, value)
else config.behavior?.onNavigationStateChange?.(value)
},
[config.behavior, pageId],
)
const source = url
? ({ uri: url, headers } as const)
: ({ html: content ?? '<html><body></body></html>' } as const)
const source = isUriSource(config.source)
? { headers: config.source.headers, uri: config.source.uri }
: { baseUrl: config.source.baseUrl, html: config.source.html }
const injected = DIALOG_OVERRIDE_JS + '\n' + (injectedJavaScript ?? '')
const ContentContainer = manageSystemBars && showStatusBar ? SafeAreaView : View
const injectedBefore = joinScripts(
MEDIA_PERMISSION_SCRIPT,
config.bridge?.injectedJavaScriptBeforeContentLoaded,
config.behavior?.injectedJavaScriptBeforeContentLoaded,
)
const injected = joinScripts(
MEDIA_PERMISSION_SCRIPT,
DOWNLOAD_INTERCEPT_SCRIPT,
config.behavior?.injectedJavaScript,
)
return (
<ContentContainer style={styles.container}>
{manageSystemBars ? <StatusBar hidden={!showStatusBar} /> : null}
<View style={styles.container}>
<XWebViewProgress progress={progress} visible={showProgress} />
<View style={styles.content}>
<WebViewAny
ref={webViewRef}
source={source}
style={styles.webview}
onLoadStart={() => {
setShowProgress(true)
}}
onLoadProgress={handleLoadProgress}
onLoadEnd={() => setShowProgress(false)}
onError={handleError}
onNavigationStateChange={handleNavigationStateChange}
onMessage={handleMessage}
onShouldStartLoadWithRequest={handleShouldStartLoad}
onPermissionRequest={handlePermissionRequest}
injectedJavaScript={injected}
onOpenWindow={handleOpenWindow}
javaScriptEnabled
domStorageEnabled
allowFileAccess
allowsInlineMediaPlayback
sharedCookiesEnabled
mediaPlaybackRequiresUserAction={false}
setSupportMultipleWindows={false}
webviewDebuggingEnabled={__DEV__}
/>
{loadError ? (
<View style={styles.errorContainer}>
<Text style={styles.errorTitle}></Text>
<Text style={styles.errorMessage}>{loadError}</Text>
<Pressable
onPress={reload}
style={({ pressed }) => [styles.retryBtn, pressed && { opacity: 0.7 }]}
>
<Text style={styles.retryText}></Text>
</Pressable>
<WebViewAny
ref={webViewRef}
source={source}
style={styles.webview}
onLoadStart={() => {
setLoadError(null)
setShowProgress(true)
}}
onLoadProgress={(event: WebViewProgressEvent) => {
const value = event.nativeEvent.progress
setProgress(value)
if (value >= 0.96) setShowProgress(false)
}}
onLoadEnd={() => setShowProgress(false)}
onError={(event: WebViewErrorEvent) => {
setLoadError(event.nativeEvent.description || '页面加载失败')
setShowProgress(false)
}}
onNavigationStateChange={handleNavigationStateChange}
onMessage={handleMessage}
onShouldStartLoadWithRequest={handleShouldStartLoad}
injectedJavaScriptBeforeContentLoaded={injectedBefore}
injectedJavaScript={injected}
onOpenWindow={handleOpenWindow}
javaScriptEnabled
domStorageEnabled
allowFileAccess={false}
allowsInlineMediaPlayback
sharedCookiesEnabled={config.behavior?.cookies?.shared ?? true}
thirdPartyCookiesEnabled={config.behavior?.cookies?.thirdParty ?? false}
mixedContentMode={config.behavior?.mixedContent ?? 'never'}
mediaPlaybackRequiresUserAction={false}
setSupportMultipleWindows={false}
webviewDebuggingEnabled={__DEV__}
/>
{loadError ? (
<View style={styles.errorContainer}>
<View>
<WebViewError message={loadError} onRetry={reload} />
</View>
) : null}
</View>
</ContentContainer>
</View>
) : null}
</View>
)
}
function WebViewError({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<View style={styles.errorContent}>
<Text style={styles.errorTitle}></Text>
<Text style={styles.errorMessage}>{message}</Text>
<Pressable onPress={onRetry} style={styles.retryButton}>
<Text style={styles.retryText}></Text>
</Pressable>
</View>
)
}
@ -419,45 +572,45 @@ const styles = StyleSheet.create({
flex: 1,
backgroundColor: '#ffffff',
},
webview: {
flex: 1,
},
content: {
flex: 1,
},
errorContainer: {
position: 'absolute',
top: 0,
right: 0,
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0,
alignItems: 'center',
backgroundColor: '#ffffff',
justifyContent: 'center',
paddingHorizontal: 32,
},
errorContent: {
alignItems: 'center',
gap: 12,
},
errorTitle: {
fontSize: 18,
fontWeight: '800',
color: '#b91c1c',
},
errorMessage: {
fontSize: 13,
color: '#7f1d1d',
color: '#6b7280',
fontSize: 14,
lineHeight: 21,
textAlign: 'center',
lineHeight: 20,
},
retryBtn: {
marginTop: 8,
backgroundColor: '#3b82f6',
errorTitle: {
color: '#111827',
fontSize: 18,
fontWeight: '700',
},
retryButton: {
backgroundColor: '#1677ff',
borderRadius: 8,
marginTop: 8,
paddingHorizontal: 24,
paddingVertical: 10,
},
retryText: {
color: '#ffffff',
fontWeight: '700',
fontSize: 15,
fontWeight: '600',
},
webview: {
flex: 1,
},
})

查看文件

@ -1,34 +1,33 @@
import '@xuqm/rn-common/internal'
export {
getXWebViewConfig,
openXWebView,
setXWebViewController,
setXWebViewScanQRCodeHandler,
XWebViewControl,
} from '@xuqm/rn-common'
export { XWebViewHost } from './XWebViewHost'
export { XWebViewView } from './XWebViewView'
export { openWebView } from './runtime'
export { resolveLinkPolicy } from './linkPolicy'
export type {
ScanQRCodeHandler,
XWebViewClickMenu,
XWebViewBehaviorConfig,
XWebViewBridge,
XWebViewBridgePageContext,
XWebViewConfig,
XWebViewControllerAPI,
XWebViewController,
XWebViewDownloadDecision,
XWebViewDownloadDestination,
XWebViewDownloadProgress,
XWebViewDownloadRequest,
XWebViewDownloadResult,
XWebViewDownloadsConfig,
XWebViewExternalUrlDecision,
XWebViewMenuContext,
XWebViewMenuItem,
XWebViewMessageEvent,
XWebViewNavigationBackground,
XWebViewNavigationBarConfig,
XWebViewNavigationRenderContext,
XWebViewNavigationState,
XWebViewPageClosedResult,
XWebViewPageHandle,
XWebViewPermissionRequest,
} from '@xuqm/rn-common'
export { default as XWebViewProgress } from './XWebViewProgress'
export { XWebViewView } from './XWebViewView'
export { XWebViewScreen } from './XWebViewScreen'
export {
handleDownloadRequest,
fetchDownloadInfo,
saveBase64File,
startDownload,
openFile,
} from './XWebViewDownload'
export type { DownloadHandle } from './XWebViewDownload'
XWebViewPermissionsConfig,
XWebViewSource,
XWebViewStatusBarConfig,
} from './types'

查看文件

@ -0,0 +1,25 @@
export type LinkPolicyResult = { action: 'allow' } | { action: 'deny' } | { action: 'external' }
const EXTERNAL_SCHEMES = new Set(['tel:', 'sms:', 'mailto:'])
const DENIED_SCHEMES = new Set(['javascript:', 'file:', 'intent:'])
/**
* WebView URL Scheme 宿
*/
export function resolveLinkPolicy(url: string): LinkPolicyResult {
const normalized = url.trim().toLowerCase()
if (
normalized.startsWith('http://') ||
normalized.startsWith('https://') ||
normalized === 'about:blank'
) {
return { action: 'allow' }
}
const colon = normalized.indexOf(':')
if (colon <= 0) return { action: 'deny' }
const scheme = normalized.slice(0, colon + 1)
if (EXTERNAL_SCHEMES.has(scheme)) return { action: 'external' }
if (DENIED_SCHEMES.has(scheme)) return { action: 'deny' }
return { action: 'deny' }
}

查看文件

@ -0,0 +1,237 @@
import type {
XWebViewConfig,
XWebViewController,
XWebViewNavigationPatch,
XWebViewNavigationState,
XWebViewPageClosedResult,
XWebViewPageHandle,
XWebViewPresentation,
XWebViewWindowPatch,
} from './types'
const HOST_WAIT_MS = 3_000
const DEFAULT_NAVIGATION_STATE: XWebViewNavigationState = {
canGoBack: false,
canGoForward: false,
title: '',
url: '',
}
export type XWebViewPage = {
config: XWebViewConfig
controller: XWebViewController | null
handle: XWebViewPageHandle
id: string
navigationState: XWebViewNavigationState
presentation: XWebViewPresentation
}
type InternalPage = XWebViewPage & {
resolveClosed: (result: XWebViewPageClosedResult) => void
timeout: ReturnType<typeof setTimeout> | null
}
type Snapshot = {
hostMounted: boolean
pages: readonly XWebViewPage[]
}
let sequence = 0
let hostMounted = false
let pages: InternalPage[] = []
let snapshot: Snapshot = { hostMounted, pages }
const listeners = new Set<() => void>()
function emit(): void {
snapshot = { hostMounted, pages: [...pages] }
for (const listener of listeners) listener()
}
function pageById(pageId: string): InternalPage | undefined {
return pages.find(page => page.id === pageId)
}
function activePage(): InternalPage | undefined {
return pages.at(-1)
}
function defaultPresentation(config: XWebViewConfig): XWebViewPresentation {
return {
background: {
color: config.navigationBar?.background?.color ?? '#ffffff',
gradient: config.navigationBar?.background?.gradient,
gradientAngle: config.navigationBar?.background?.gradientAngle,
image: config.navigationBar?.background?.image,
},
navigationBarVisible: config.navigationBar?.visible ?? true,
statusBarVisible: config.statusBar?.visible ?? true,
titleColor: config.navigationBar?.title?.color ?? '#111827',
titleText: config.navigationBar?.title?.text ?? '',
}
}
function controllerCall(pageId: string, call: (controller: XWebViewController) => void): void {
const controller = pageById(pageId)?.controller
if (controller) call(controller)
}
function closePage(pageId: string, result: XWebViewPageClosedResult): void {
const page = pageById(pageId)
if (!page) return
if (page.timeout) clearTimeout(page.timeout)
pages = pages.filter(item => item.id !== pageId)
page.config.bridge?.onPageClosed?.(pageId)
page.config.behavior?.onClose?.(result)
page.resolveClosed(result)
emit()
}
export function closePageById(pageId: string, reason: string = 'api', data?: unknown): void {
closePage(pageId, { data, reason })
}
export function openWebView(config: XWebViewConfig): XWebViewPageHandle {
sequence += 1
const id = `xwv-${Date.now().toString(36)}-${sequence.toString(36)}`
let resolveClosed!: (result: XWebViewPageClosedResult) => void
const closed = new Promise<XWebViewPageClosedResult>(resolve => {
resolveClosed = resolve
})
const handle: XWebViewPageHandle = {
id,
closed,
close: (reason = 'api', data) => closePage(id, { data, reason }),
copyUrl: () => controllerCall(id, controller => controller.copyUrl()),
evaluateJavaScript: script =>
controllerCall(id, controller => controller.evaluateJavaScript(script)),
getNavigationState: () => pageById(id)?.navigationState ?? DEFAULT_NAVIGATION_STATE,
goBack: () => controllerCall(id, controller => controller.goBack()),
goForward: () => controllerCall(id, controller => controller.goForward()),
postMessage: message => controllerCall(id, controller => controller.postMessage(message)),
reload: () => controllerCall(id, controller => controller.reload()),
}
const page: InternalPage = {
config,
controller: null,
handle,
id,
navigationState: {
...DEFAULT_NAVIGATION_STATE,
title: config.navigationBar?.title?.text ?? '',
url: typeof config.source.uri === 'string' ? config.source.uri : '',
},
presentation: defaultPresentation(config),
resolveClosed,
timeout: null,
}
if (!hostMounted) {
page.timeout = setTimeout(() => {
closePage(id, { reason: 'host_unavailable' })
}, HOST_WAIT_MS)
}
pages = [...pages, page]
emit()
return handle
}
export function subscribeXWebViewRuntime(listener: () => void): () => void {
listeners.add(listener)
return () => listeners.delete(listener)
}
export function getXWebViewSnapshot(): Snapshot {
return snapshot
}
export function mountXWebViewHost(): () => void {
if (hostMounted) {
throw new Error('全局只能挂载一个 XWebViewHost')
}
hostMounted = true
for (const page of pages) {
if (page.timeout) clearTimeout(page.timeout)
page.timeout = null
}
emit()
return () => {
if (!hostMounted) return
hostMounted = false
const openPages = [...pages]
for (const page of openPages) {
closePage(page.id, { reason: 'host_unmounted' })
}
emit()
}
}
export function registerPageController(
pageId: string,
controller: XWebViewController | null,
): void {
const page = pageById(pageId)
if (!page) return
page.controller = controller
page.config.behavior?.onControllerReady?.(controller)
}
export function updatePageNavigationState(pageId: string, value: XWebViewNavigationState): void {
const page = pageById(pageId)
if (!page) return
page.navigationState = value
if (page.config.navigationBar?.title?.mode === 'web' && value.title) {
page.presentation = {
...page.presentation,
titleText: value.title,
}
}
page.config.behavior?.onNavigationStateChange?.(value)
emit()
}
export function updatePageNavigation(pageId: string, patch: XWebViewNavigationPatch): void {
const page = pageById(pageId)
if (!page) return
page.presentation = {
...page.presentation,
background: {
...page.presentation.background,
...patch.background,
},
titleColor: patch.title?.color ?? page.presentation.titleColor,
titleText: patch.title?.text ?? page.presentation.titleText,
}
emit()
}
export function updatePageWindow(pageId: string, patch: XWebViewWindowPatch): void {
const page = pageById(pageId)
if (!page) return
page.presentation = {
...page.presentation,
navigationBarVisible: patch.navigationBarVisible,
statusBarVisible: patch.statusBarVisible,
}
emit()
}
export function isActivePage(pageId: string): boolean {
return activePage()?.id === pageId
}
export function closeActivePage(reason: string = 'back', data?: unknown): void {
const page = activePage()
if (page) closePage(page.id, { data, reason })
}
export function resetXWebViewRuntimeForTests(): void {
for (const page of pages) {
if (page.timeout) clearTimeout(page.timeout)
}
pages = []
hostMounted = false
emit()
}

查看文件

@ -0,0 +1,232 @@
import type React from 'react'
export type XWebViewSource =
| {
headers?: Record<string, string>
uri: string
html?: never
}
| {
baseUrl?: string
html: string
uri?: never
}
export type XWebViewNavigationState = {
canGoBack: boolean
canGoForward: boolean
title: string
url: string
}
export type XWebViewController = {
copyUrl(): void
evaluateJavaScript(script: string): void
getNavigationState(): XWebViewNavigationState
goBack(): void
goForward(): void
postMessage(message: string): void
reload(): void
}
export type XWebViewPageCloseReason =
'api' | 'back' | 'h5_close' | 'host_unavailable' | 'host_unmounted' | 'replaced'
export type XWebViewPageClosedResult = {
data?: unknown
reason: XWebViewPageCloseReason | string
}
export type XWebViewPageHandle = XWebViewController & {
readonly closed: Promise<XWebViewPageClosedResult>
readonly id: string
close(reason?: XWebViewPageCloseReason | string, data?: unknown): void
}
export type XWebViewNavigationBackground = {
color?: string
gradient?: string[]
gradientAngle?: number
image?: string
}
export type XWebViewNavigationTitle = {
color?: string
mode?: 'fixed' | 'web'
text?: string
}
export type XWebViewNavigationButton = {
color?: string
visible?: boolean
}
export type XWebViewMenuContext = {
close(): void
controller: XWebViewPageHandle
navigationState: XWebViewNavigationState
}
export type XWebViewMenuItem = {
id: string
icon?: React.ReactNode
label: string
onPress(context: XWebViewMenuContext): void | Promise<void>
}
export type XWebViewNavigationRenderContext = XWebViewMenuContext & {
background: XWebViewNavigationBackground
closeButton: XWebViewNavigationButton
backButton: XWebViewNavigationButton
title: Required<Pick<XWebViewNavigationTitle, 'color' | 'text'>> &
Pick<XWebViewNavigationTitle, 'mode'>
}
export type XWebViewNavigationBarConfig = {
backButton?: XWebViewNavigationButton
background?: XWebViewNavigationBackground
closeButton?: XWebViewNavigationButton
menu?: {
items?: XWebViewMenuItem[]
visible?: boolean
}
render?: (context: XWebViewNavigationRenderContext) => React.ReactNode
title?: XWebViewNavigationTitle
visible?: boolean
}
export type XWebViewStatusBarConfig = {
backgroundColor?: string
contentStyle?: 'auto' | 'dark-content' | 'light-content'
translucent?: boolean
visible?: boolean
}
export type XWebViewMessageEvent = {
nativeEvent: { data: string }
}
export type XWebViewPermissionRequest = {
deny(): void
grant(resources?: string[]): void
origin: string
resources: string[]
}
export type XWebViewPermissionsConfig = {
camera?: boolean
microphone?: boolean
onRequest?: (request: XWebViewPermissionRequest) => void
}
export type XWebViewDownloadRequest = {
fileSize?: number
mimeType?: string
suggestedFilename: string
url: string
}
export type XWebViewDownloadDecision = {
allowed: boolean
filename?: string
savePath?: string
}
export type XWebViewDownloadProgress = {
filename: string
percentage: number
received: number
total: number
url: string
}
export type XWebViewDownloadResult = {
filePath: string
fileSize: number
filename: string
url: string
}
export type XWebViewDownloadDestination = 'sandbox' | 'publicDownloads'
export type XWebViewDownloadsConfig = {
auto?: boolean
conflict?: 'rename' | 'overwrite'
destination?: XWebViewDownloadDestination
headers?: Record<string, string>
notificationTitle?: string
onComplete?: (result: XWebViewDownloadResult) => void
onDecide?: (
request: XWebViewDownloadRequest,
) => XWebViewDownloadDecision | Promise<XWebViewDownloadDecision>
onError?: (url: string, error: string) => void
onProgress?: (progress: XWebViewDownloadProgress) => void
onStart?: (request: XWebViewDownloadRequest) => void
}
export type XWebViewNavigationPatch = {
background?: XWebViewNavigationBackground
title?: {
color?: string
text?: string
}
}
export type XWebViewWindowPatch = {
navigationBarVisible: boolean
statusBarVisible: boolean
}
export type XWebViewBridgePageContext = {
close(reason?: string, data?: unknown): void
evaluateJavaScript(script: string): void
getStatusBarHeight(): number
isActive(): boolean
pageId: string
updateNavigation(patch: XWebViewNavigationPatch): void | Promise<void>
updateWindow(patch: XWebViewWindowPatch): void
}
export type XWebViewBridge = {
injectedJavaScriptBeforeContentLoaded?: string
onMessage(raw: string, context: XWebViewBridgePageContext): boolean | Promise<boolean>
onPageClosed?(pageId: string): void
}
export type XWebViewExternalUrlDecision = 'allow' | 'deny' | 'external'
export type XWebViewBehaviorConfig = {
cookies?: {
shared?: boolean
thirdParty?: boolean
}
injectedJavaScript?: string
injectedJavaScriptBeforeContentLoaded?: string
mixedContent?: 'never' | 'compatibility' | 'always'
onBridgeError?: (error: string) => void
onClose?: (result: XWebViewPageClosedResult) => void
onControllerReady?: (controller: XWebViewController | null) => void
onExternalUrl?: (
url: string,
) => XWebViewExternalUrlDecision | Promise<XWebViewExternalUrlDecision>
onMessage?: (event: XWebViewMessageEvent) => void
onNavigationStateChange?: (state: XWebViewNavigationState) => void
}
export type XWebViewConfig = {
behavior?: XWebViewBehaviorConfig
bridge?: XWebViewBridge
downloads?: XWebViewDownloadsConfig
navigationBar?: XWebViewNavigationBarConfig
permissions?: XWebViewPermissionsConfig
source: XWebViewSource
statusBar?: XWebViewStatusBarConfig
}
export type XWebViewPresentation = {
background: XWebViewNavigationBackground
navigationBarVisible: boolean
statusBarVisible: boolean
titleColor: string
titleText: string
}

查看文件

@ -0,0 +1,22 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { resolveLinkPolicy } from '../src/linkPolicy'
test('link policy keeps web links inside and routes only safe system schemes outside', () => {
assert.deepEqual(resolveLinkPolicy('https://example.test'), {
action: 'allow',
})
assert.deepEqual(resolveLinkPolicy('tel:10086'), {
action: 'external',
})
assert.deepEqual(resolveLinkPolicy('javascript:alert(1)'), {
action: 'deny',
})
assert.deepEqual(resolveLinkPolicy('intent://unsafe'), {
action: 'deny',
})
assert.deepEqual(resolveLinkPolicy('custom://unknown'), {
action: 'deny',
})
})

查看文件

@ -0,0 +1,56 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import {
getXWebViewSnapshot,
mountXWebViewHost,
openWebView,
resetXWebViewRuntimeForTests,
updatePageNavigation,
} from '../src/runtime'
test('openWebView owns an independent page stack and restores the lower page', async () => {
resetXWebViewRuntimeForTests()
const unmount = mountXWebViewHost()
const first = openWebView({
source: { uri: 'https://first.test' },
})
const second = openWebView({
source: { uri: 'https://second.test' },
})
assert.deepEqual(
getXWebViewSnapshot().pages.map(page => page.id),
[first.id, second.id],
)
second.close()
assert.deepEqual(
getXWebViewSnapshot().pages.map(page => page.id),
[first.id],
)
assert.equal((await second.closed).reason, 'api')
unmount()
})
test('dynamic navigation state belongs only to its page', () => {
resetXWebViewRuntimeForTests()
const unmount = mountXWebViewHost()
const first = openWebView({
navigationBar: { title: { text: 'first' } },
source: { uri: 'https://first.test' },
})
const second = openWebView({
navigationBar: { title: { text: 'second' } },
source: { uri: 'https://second.test' },
})
updatePageNavigation(second.id, {
title: { color: '#ffffff', text: 'changed' },
})
const pages = getXWebViewSnapshot().pages
assert.equal(pages[0].presentation.titleText, 'first')
assert.equal(pages[1].presentation.titleText, 'changed')
first.close()
second.close()
unmount()
})

144
pnpm-lock.yaml 自动生成的
查看文件

@ -140,9 +140,6 @@ importers:
packages/xwebview:
dependencies:
react-native-svg:
specifier: ^15.15.4
version: 15.15.5(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
react-native-webview:
specifier: ^14.0.1
version: 14.0.1(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
@ -150,15 +147,15 @@ importers:
'@react-native-clipboard/clipboard':
specifier: ^1.16.3
version: 1.16.3(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
'@react-navigation/native':
specifier: ^7.0.0
version: 7.3.8(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
'@types/react':
specifier: ^19.0.0
version: 19.2.17
react-native-safe-area-context:
specifier: ^5.4.0
version: 5.8.0(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
react-native-svg:
specifier: ^15.15.4
version: 15.15.5(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
tsx:
specifier: ^4.23.1
version: 4.23.1
@ -524,20 +521,6 @@ packages:
'@types/react':
optional: true
'@react-navigation/core@7.21.5':
resolution: {integrity: sha512-3hpV7uR41LBW+GHDoLhztZCb/i5ySRJISZ/rez4d7DCHSZo6ej4gNxYclaS6LRguoLiKG7SOCNa6O390AQklZQ==}
peerDependencies:
react: '>= 18.2.0'
'@react-navigation/native@7.3.8':
resolution: {integrity: sha512-zHmQcxWBT8GOwsofEOmHqpdM5twkwE/esa9JFGlW4hpXeQTTe/dRcPSLjwsvePtolbDKz0YZbK+I5KrW3j63LQ==}
peerDependencies:
react: '>= 18.2.0'
react-native: '*'
'@react-navigation/routers@7.6.0':
resolution: {integrity: sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow==}
'@sinclair/typebox@0.27.10':
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
@ -688,6 +671,14 @@ packages:
cpu: [x64]
os: [win32]
'@xuqm/rn-common@0.6.0-alpha.9':
resolution: {integrity: sha512-NvSYr92sRV8xKVCTrO7sPFXypUYfxMozvHWaNlIk+4bnvGlhWGGUvmvjm2QxF0rBzTO8b0HzhXhb9S/CIx629Q==}
peerDependencies:
'@react-native-async-storage/async-storage': '>=1.21.0'
axios: '>=1.0.0'
react: '>=18.0.0'
react-native: '>=0.76.0'
abort-controller@3.0.0:
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
engines: {node: '>=6.5'}
@ -867,10 +858,6 @@ packages:
supports-color:
optional: true
decode-uri-component@0.2.2:
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
engines: {node: '>=0.10'}
delayed-stream@1.0.0:
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
engines: {node: '>=0.4.0'}
@ -967,9 +954,6 @@ packages:
exponential-backoff@3.1.3:
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fb-dotslash@0.5.8:
resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==}
engines: {node: '>=20'}
@ -994,10 +978,6 @@ packages:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'}
filter-obj@1.1.0:
resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
engines: {node: '>=0.10.0'}
finalhandler@1.1.2:
resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==}
engines: {node: '>= 0.8'}
@ -1311,11 +1291,6 @@ packages:
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
nanoid@3.3.16:
resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==}
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
hasBin: true
negotiator@1.0.0:
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
engines: {node: '>= 0.6'}
@ -1388,10 +1363,6 @@ packages:
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
engines: {node: '>=10'}
query-string@7.1.3:
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
engines: {node: '>=6'}
queue@6.0.2:
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
@ -1408,9 +1379,6 @@ packages:
react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
react-is@19.2.7:
resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==}
react-native-blob-util@0.24.10:
resolution: {integrity: sha512-4yazgoCstXgt2/CRURtowGP3furAzbWdfMQ4jyUPDo5xtLx7KE2F/xMz3oU3shLwfL4id0iEKsBywoxvJSHPzQ==}
peerDependencies:
@ -1520,10 +1488,6 @@ packages:
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
engines: {node: '>=0.10.0'}
split-on-first@1.1.0:
resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==}
engines: {node: '>=6'}
sql-escape-string@1.1.0:
resolution: {integrity: sha512-/kqO4pLZSLfV0KsBM2xkVh2S3GbjJJone37d7gYwLyP0c+REh3vnmkhQ7VwNrX76igC0OhJWpTg0ukkdef9vvA==}
@ -1534,9 +1498,6 @@ packages:
resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==}
engines: {node: '>=6'}
standard-navigation@0.0.7:
resolution: {integrity: sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==}
statuses@1.5.0:
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
engines: {node: '>= 0.6'}
@ -1545,10 +1506,6 @@ packages:
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
engines: {node: '>= 0.8'}
strict-uri-encode@2.0.0:
resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==}
engines: {node: '>=4'}
string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
@ -1621,16 +1578,6 @@ packages:
peerDependencies:
browserslist: '>= 4.21.0'
use-latest-callback@0.2.6:
resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==}
peerDependencies:
react: '>=16.8'
use-sync-external-store@1.6.0:
resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==}
peerDependencies:
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
utils-merge@1.0.1:
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
engines: {node: '>= 0.4.0'}
@ -2016,33 +1963,6 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.17
'@react-navigation/core@7.21.5(react@19.2.7)':
dependencies:
'@react-navigation/routers': 7.6.0
escape-string-regexp: 4.0.0
fast-deep-equal: 3.1.3
nanoid: 3.3.16
query-string: 7.1.3
react: 19.2.7
react-is: 19.2.7
use-latest-callback: 0.2.6(react@19.2.7)
use-sync-external-store: 1.6.0(react@19.2.7)
'@react-navigation/native@7.3.8(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)':
dependencies:
'@react-navigation/core': 7.21.5(react@19.2.7)
escape-string-regexp: 4.0.0
fast-deep-equal: 3.1.3
nanoid: 3.3.16
react: 19.2.7
react-native: 0.86.0(@types/react@19.2.17)(react@19.2.7)
standard-navigation: 0.0.7
use-latest-callback: 0.2.6(react@19.2.7)
'@react-navigation/routers@7.6.0':
dependencies:
nanoid: 3.3.16
'@sinclair/typebox@0.27.10': {}
'@types/istanbul-lib-coverage@2.0.6': {}
@ -2135,6 +2055,17 @@ snapshots:
'@typescript/typescript-win32-x64@7.0.2':
optional: true
'@xuqm/rn-common@0.6.0-alpha.9(@react-native-async-storage/async-storage@3.1.1(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(axios@1.18.1)(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)':
dependencies:
'@noble/ciphers': 2.2.0
'@noble/hashes': 2.2.0
'@react-native-async-storage/async-storage': 3.1.1(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
axios: 1.18.1
react: 19.2.7
react-native: 0.86.0(@types/react@19.2.17)(react@19.2.7)
react-native-blob-util: 0.24.10(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)
zod: 4.4.3
abort-controller@3.0.0:
dependencies:
event-target-shim: 5.0.1
@ -2313,8 +2244,6 @@ snapshots:
dependencies:
ms: 2.1.3
decode-uri-component@0.2.2: {}
delayed-stream@1.0.0: {}
depd@2.0.0: {}
@ -2417,8 +2346,6 @@ snapshots:
exponential-backoff@3.1.3: {}
fast-deep-equal@3.1.3: {}
fb-dotslash@0.5.8: {}
fb-watchman@2.0.2:
@ -2435,8 +2362,6 @@ snapshots:
dependencies:
to-regex-range: 5.0.1
filter-obj@1.1.0: {}
finalhandler@1.1.2:
dependencies:
debug: 2.6.9
@ -2851,8 +2776,6 @@ snapshots:
ms@2.1.3: {}
nanoid@3.3.16: {}
negotiator@1.0.0: {}
node-int64@0.4.0: {}
@ -2911,13 +2834,6 @@ snapshots:
proxy-from-env@2.1.0: {}
query-string@7.1.3:
dependencies:
decode-uri-component: 0.2.2
filter-obj: 1.1.0
split-on-first: 1.1.0
strict-uri-encode: 2.0.0
queue@6.0.2:
dependencies:
inherits: 2.0.4
@ -2936,8 +2852,6 @@ snapshots:
react-is@18.3.1: {}
react-is@19.2.7: {}
react-native-blob-util@0.24.10(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7):
dependencies:
base-64: 1.0.0
@ -3076,8 +2990,6 @@ snapshots:
source-map@0.6.1: {}
split-on-first@1.1.0: {}
sql-escape-string@1.1.0: {}
stackframe@1.3.4: {}
@ -3086,14 +2998,10 @@ snapshots:
dependencies:
type-fest: 0.7.1
standard-navigation@0.0.7: {}
statuses@1.5.0: {}
statuses@2.0.2: {}
strict-uri-encode@2.0.0: {}
string-width@4.2.3:
dependencies:
emoji-regex: 8.0.0
@ -3179,14 +3087,6 @@ snapshots:
escalade: 3.2.0
picocolors: 1.1.1
use-latest-callback@0.2.6(react@19.2.7):
dependencies:
react: 19.2.7
use-sync-external-store@1.6.0(react@19.2.7):
dependencies:
react: 19.2.7
utils-merge@1.0.1: {}
vlq@1.0.1: {}

查看文件

@ -25,28 +25,33 @@ export type {
} from '@xuqm/rn-update'
export { BugCollect } from '@xuqm/rn-bugcollect'
export {
XWebViewControl,
XWebViewScreen,
XWebViewHost,
XWebViewView,
getXWebViewConfig,
openXWebView,
setXWebViewController,
setXWebViewScanQRCodeHandler,
handleDownloadRequest,
fetchDownloadInfo,
saveBase64File,
startDownload,
openWebView,
} from '@xuqm/rn-xwebview'
export type {
ScanQRCodeHandler,
XWebViewClickMenu,
XWebViewBridge,
XWebViewBridgePageContext,
XWebViewConfig,
XWebViewControllerAPI,
XWebViewController,
XWebViewDownloadDecision,
XWebViewDownloadDestination,
XWebViewDownloadProgress,
XWebViewDownloadRequest,
XWebViewDownloadResult,
XWebViewDownloadsConfig,
XWebViewExternalUrlDecision,
XWebViewMenuContext,
XWebViewMenuItem,
XWebViewMessageEvent,
XWebViewNavigationBackground,
XWebViewNavigationBarConfig,
XWebViewNavigationRenderContext,
XWebViewNavigationState,
XWebViewPageClosedResult,
XWebViewPermissionRequest,
DownloadHandle,
XWebViewPageHandle,
XWebViewPermissionsConfig,
XWebViewSource,
XWebViewStatusBarConfig,
} from '@xuqm/rn-xwebview'