feat(xwebview): 收敛 WebView 宿主与桥接
这个提交包含在:
父节点
d009c3b616
当前提交
10977341d1
@ -1,6 +1,6 @@
|
|||||||
# RN SDK 重构实施接管文档
|
# RN SDK 重构实施接管文档
|
||||||
|
|
||||||
> 状态更新时间:2026-07-19
|
> 状态更新时间:2026-07-26
|
||||||
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
|
> 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview`
|
||||||
> 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。
|
> 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。
|
||||||
|
|
||||||
@ -13,6 +13,28 @@
|
|||||||
5. update SDK 统一承担插件 manifest、兼容性、原子激活、启动确认、崩溃回滚和内嵌恢复;宿主不得再实现平行版本管理器。
|
5. update SDK 统一承担插件 manifest、兼容性、原子激活、启动确认、崩溃回滚和内嵌恢复;宿主不得再实现平行版本管理器。
|
||||||
6. Android 整包更新必须具备应用内下载、SHA-256 校验、安装权限处理、进度、取消、重试和确定性错误码。
|
6. Android 整包更新必须具备应用内下载、SHA-256 校验、安装权限处理、进度、取消、重试和确定性错误码。
|
||||||
7. 宿主通过最少配置完成插件创建、开发运行、打包、随 APK 内嵌、发布和回滚。
|
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. 已冻结的设计约束
|
## 2. 已冻结的设计约束
|
||||||
|
|
||||||
@ -41,7 +63,7 @@
|
|||||||
- `pnpm validate`:通过。
|
- `pnpm validate`:通过。
|
||||||
- common:20 个测试通过。
|
- common:20 个测试通过。
|
||||||
- bugcollect:5 个独立测试通过。
|
- bugcollect:5 个独立测试通过。
|
||||||
- xwebview:4 个独立测试通过。
|
- xwebview:7 个独立测试通过。
|
||||||
- update CLI/Metro:11 个测试通过;release-set:6 个测试通过。
|
- update CLI/Metro:11 个测试通过;release-set:6 个测试通过。
|
||||||
- IM 现有测试:3 个通过;IM 不在本轮开发范围。
|
- IM 现有测试:3 个通过;IM 不在本轮开发范围。
|
||||||
- 所有 workspace TypeScript typecheck:通过。
|
- 所有 workspace TypeScript typecheck:通过。
|
||||||
@ -73,17 +95,17 @@
|
|||||||
|
|
||||||
## 4. 实施进度
|
## 4. 实施进度
|
||||||
|
|
||||||
| 工作项 | 状态 | 说明 |
|
| 工作项 | 状态 | 说明 |
|
||||||
| ----------------------------- | -------- | ------------------------------------------------------ |
|
| ----------------------------- | ------ | ----------------------------------------------------- |
|
||||||
| 基线审计与门禁 | 完成 | 2026-07-17 本地 validate 全通过 |
|
| 基线审计与门禁 | 完成 | 2026-07-17 本地 validate 全通过 |
|
||||||
| 实时接管文档 | 进行中 | 本文件为唯一实施状态入口 |
|
| 实时接管文档 | 进行中 | 本文件为唯一实施状态入口 |
|
||||||
| common 公共上下文收敛 | 完成 | 公共基础、共享生命周期与安全 API 诊断 19 测试 |
|
| common 公共上下文收敛 | 完成 | 公共基础、共享生命周期与安全 API 诊断 19 测试 |
|
||||||
| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本、策略与 HTTP 脱敏已统一,5 测试 |
|
| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本、策略与 HTTP 脱敏已统一,5 测试 |
|
||||||
| xwebview 文件/权限能力与测试 | 完成 | 文件下沉 common,H5 摄像头/麦克风统一授权,4 测试通过 |
|
| xwebview 1.0 Host/权限/下载 | 完成 | 页面栈、分组配置、7 项测试及 App4 Android 运行通过 |
|
||||||
| update release set 与原生事务 | 进行中 | Android Snapshot、Bridge、App4 构建通过;待事务 E2E |
|
| update release set 与原生事务 | 进行中 | Android Snapshot、Bridge、App4 构建通过;待事务 E2E |
|
||||||
| package 内容校验 | 完成 | update 包 42 个发布文件已校验 |
|
| package 内容校验 | 完成 | update 包 42 个发布文件已校验 |
|
||||||
| Jenkins alpha 发布 | 完成 | `#59/#60` 已发布 common、xwebview、update |
|
| Jenkins alpha 发布 | 完成 | `#59/#60` 已发布 common、xwebview、update |
|
||||||
| App4 接入 | 完成构建 | 精确 alpha 已接入,六 Hermes 插件与 debug APK 构建通过 |
|
| App4 接入 | 完成 | V5 Host、8 模块、Debug APK 与真实 H5 Android 链路通过 |
|
||||||
|
|
||||||
## 5. 下一步操作
|
## 5. 下一步操作
|
||||||
|
|
||||||
@ -274,8 +296,12 @@ pnpm --dir packages/update test
|
|||||||
|
|
||||||
- 设备 DNS 失败复测发现 Android 系统英文错误页会在 `onError` 后再次触发加载事件,旧实现因此清空自定义错误状态;同时错误分支卸载 WebView,按钮持有的 ref 已为空,重试实际无效。
|
- 设备 DNS 失败复测发现 Android 系统英文错误页会在 `onError` 后再次触发加载事件,旧实现因此清空自定义错误状态;同时错误分支卸载 WebView,按钮持有的 ref 已为空,重试实际无效。
|
||||||
- `XWebViewView` 现在始终保留 WebView,并以白底中文错误层覆盖系统页;刷新、前进、后退和重试统一先清理错误状态再操作同一控制器。错误页不再由原生事件先后顺序随机决定。
|
- `XWebViewView` 现在始终保留 WebView,并以白底中文错误层覆盖系统页;刷新、前进、后退和重试统一先清理错误状态再操作同一控制器。错误页不再由原生事件先后顺序随机决定。
|
||||||
- 删除 `XWebViewScreen` 内 600 余行重复的 WebView、下载、Bridge、权限和错误处理。Screen 现在只组合统一导航栏、系统返回与 `XWebViewView`;全屏和内嵌场景共享同一个浏览器内核实现,不再修复一处、遗漏另一处。
|
- 删除 `XWebViewScreen` 内 600 余行重复的 WebView、下载、Bridge、权限和错误处理,
|
||||||
- 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。
|
并最终删除该公开入口。全屏页面由 `XWebViewHost` 组合统一导航栏、系统返回与
|
||||||
|
`XWebViewView`;全屏和内嵌场景共享同一个浏览器内核。
|
||||||
|
- 当时的 xwebview 类型检查与 4 项下载/权限测试、RNSDK 全量门禁和 App4 457 tasks
|
||||||
|
构建均通过;该历史结果不能替代 1.0.0 当前实现的最终验证。当前 1.0.0 已扩展为
|
||||||
|
7 项独立测试,Android 运行验证仍待执行。
|
||||||
|
|
||||||
### 2026-07-20 / Android 内嵌 Bundle 启动短路
|
### 2026-07-20 / Android 内嵌 Bundle 启动短路
|
||||||
|
|
||||||
|
|||||||
@ -46,7 +46,9 @@ await XuqmSDK.logout()
|
|||||||
## 扩展包
|
## 扩展包
|
||||||
|
|
||||||
- `@xuqm/rn-update`:`UpdateSDK`、`XuqmRuntime`、`definePlugin` 和 `xuqm-rn` CLI。
|
- `@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-bugcollect`:`BugCollect` 错误、事件、漏斗和队列上报。
|
||||||
- `@xuqm/rn-im`:在 common 会话含 `userSig` 且服务启用时连接。
|
- `@xuqm/rn-im`:在 common 会话含 `userSig` 且服务启用时连接。
|
||||||
- `@xuqm/rn-push`:在 common 会话变化时同步原生推送绑定。
|
- `@xuqm/rn-push`:在 common 会话变化时同步原生推送绑定。
|
||||||
|
|||||||
@ -1,211 +1,52 @@
|
|||||||
# XWebView JSBridge 标准接口
|
# XWebView Bridge 边界
|
||||||
|
|
||||||
> 最后更新:2026-06-15
|
> 最后更新:2026-07-26
|
||||||
|
|
||||||
---
|
XWebView 不是业务 JSBridge 协议。它只定义一个通用适配接口:
|
||||||
|
|
||||||
## 概述
|
```ts
|
||||||
|
type XWebViewBridge = {
|
||||||
XWebView 内置 JSBridge,支持 H5 页面与 React Native 宿主之间的双向通信。
|
injectedJavaScriptBeforeContentLoaded?: string
|
||||||
|
onMessage(raw: string, context: XWebViewBridgePageContext): boolean | Promise<boolean>
|
||||||
**通信机制**:
|
onPageClosed?(pageId: string): void
|
||||||
- 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' }
|
|
||||||
}));
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```typescript
|
## 页面上下文
|
||||||
// RN 端
|
|
||||||
<XWebViewView
|
|
||||||
onMessage={(event) => {
|
|
||||||
const data = JSON.parse(event.nativeEvent.data);
|
|
||||||
if (data.type === 'customEvent') {
|
|
||||||
// 处理自定义消息
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
业务协议实现可以:
|
||||||
|
|
||||||
## 二、RN → H5 通信
|
- 关闭当前页;
|
||||||
|
- 向当前页执行 JavaScript;
|
||||||
|
- 判断当前页是否为页面栈顶;
|
||||||
|
- 取得真实状态栏高度;
|
||||||
|
- 修改当前页导航栏标题/背景;
|
||||||
|
- 修改当前页导航栏与状态栏显隐。
|
||||||
|
|
||||||
### 2.1 注入 JavaScript
|
协议实现不能:
|
||||||
|
|
||||||
```typescript
|
- 访问或修改其他 WebView 页面;
|
||||||
// 通过 controller API
|
- 从 XWebView 读取用户、token、平台、域名或厂商;
|
||||||
controller.postMessageToWeb('window.dispatchEvent(new CustomEvent("rnMessage", {detail: {type: "update", data: "hello"}}))');
|
- 直接控制宿主导航器;
|
||||||
|
- 输出原始 Bridge payload;
|
||||||
|
- 在 common 中注册全局业务 handler。
|
||||||
|
|
||||||
// 通过 ref
|
## 消息所有权
|
||||||
webViewRef.injectJavaScript('window.handleRNMessage("hello")');
|
|
||||||
```
|
|
||||||
|
|
||||||
### 2.2 Controller API
|
XWebView 内部只消费 `__xwv` 命名空间:
|
||||||
|
|
||||||
通过 `setXWebViewController` 获取的 controller 对象提供以下方法:
|
- `download` / `blobdownload` / `bloberror`
|
||||||
|
- `permission`
|
||||||
|
|
||||||
| 方法 | 说明 |
|
其他消息先交给 `config.bridge`。Bridge 返回 `false` 时才继续交给
|
||||||
|------|------|
|
`behavior.onMessage`。Bridge 抛错由 `behavior.onBridgeError` 接收,不产生未处理
|
||||||
| `refresh()` | 刷新当前页面 |
|
Promise。
|
||||||
| `close()` | 关闭 WebView |
|
|
||||||
| `goBack()` | 返回上一页 |
|
|
||||||
| `goForward()` | 前进 |
|
|
||||||
| `copyUrl()` | 复制当前 URL 到剪贴板 |
|
|
||||||
| `postMessageToWeb(jsString)` | 向 H5 注入并执行 JS |
|
|
||||||
| `getTitle()` | 获取当前页面标题 |
|
|
||||||
|
|
||||||
---
|
## 业务协议接入
|
||||||
|
|
||||||
## 三、下载处理
|
业务协议必须在自己的 SDK/插件中维护唯一方法表、请求解析、响应结构、错误码和兼容
|
||||||
|
测试,然后以 `XWebViewBridge` 适配到容器。XWebView 不提供 `getSession`、`navigate`、
|
||||||
|
`showToast`、签名、支付或任何假定宿主业务的内置方法。
|
||||||
|
|
||||||
### 3.1 自动下载拦截
|
医网信 H5 场景使用 `@szyx/sdk-h5-app/host`;其 V5 线协议不属于
|
||||||
|
`@xuqm/rn-xwebview` 的公开契约。
|
||||||
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);
|
|
||||||
});
|
|
||||||
```
|
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
export class RequestError<TData = unknown> extends Error {
|
export class RequestError<TData = unknown> extends Error {
|
||||||
constructor(
|
constructor(
|
||||||
message: string,
|
message: string,
|
||||||
public readonly type: 'Cancel' | 'ValidationError' | 'AxiosError' | 'OtherError',
|
public readonly type:
|
||||||
|
'Cancel' | 'ValidationError' | 'BusinessError' | 'AxiosError' | 'OtherError',
|
||||||
public readonly cause?: unknown,
|
public readonly cause?: unknown,
|
||||||
public readonly response?: { data?: TData; status?: number },
|
public readonly response?: { data?: TData; status?: number },
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -16,7 +16,7 @@ import {
|
|||||||
import { RequestError } from './errors'
|
import { RequestError } from './errors'
|
||||||
import { type RequestOptions, useRequest, ValidationError } from './useRequest'
|
import { type RequestOptions, useRequest, ValidationError } from './useRequest'
|
||||||
|
|
||||||
const SUCCESS_STATUS = /^0$/
|
const SUCCESS_STATUS = '0'
|
||||||
|
|
||||||
type ApiResponse<T> = {
|
type ApiResponse<T> = {
|
||||||
status: string
|
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>>
|
requestInterceptors: AxiosInterceptorManager<AxiosRequestConfig<unknown>>
|
||||||
responseInterceptors: AxiosInterceptorManager<AxiosResponse<ApiResponse<T>, unknown>>
|
responseInterceptors: AxiosInterceptorManager<AxiosResponse<ApiResponse<T>, unknown>>
|
||||||
} => {
|
} => {
|
||||||
|
// schema 只校验响应结构,不强制 status==='0';
|
||||||
|
// 业务状态码非 '0' 属于正常业务响应,由 transformedFetchAsync 统一处理。
|
||||||
const responseSchema = useMemo(
|
const responseSchema = useMemo(
|
||||||
() =>
|
() =>
|
||||||
z.object({
|
z.object({
|
||||||
status: z.string().regex(SUCCESS_STATUS),
|
status: z.string(),
|
||||||
message: z.string(),
|
message: z.string(),
|
||||||
data: validationSchema ?? z.unknown(),
|
data: (validationSchema ?? z.unknown()).optional(),
|
||||||
}),
|
}),
|
||||||
[validationSchema],
|
[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]
|
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))
|
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>) => {
|
async (nextConfig?: AxiosRequestConfig<D>) => {
|
||||||
try {
|
try {
|
||||||
const requestResponse = await fetchAsync(nextConfig)
|
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) {
|
} catch (requestError) {
|
||||||
return Promise.reject(requestError)
|
return Promise.reject(requestError)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -52,30 +52,6 @@ export {
|
|||||||
satisfiesVersion,
|
satisfiesVersion,
|
||||||
} from './version'
|
} from './version'
|
||||||
export { ScaledImage } from './components/ScaledImage'
|
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/ 子模块
|
// api/ 子模块
|
||||||
export {
|
export {
|
||||||
RequestError,
|
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
|
# @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
|
```xml
|
||||||
<uses-permission android:name="android.permission.CAMERA" />
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
<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
|
```bash
|
||||||
pnpm add @xuqm/rn-common @xuqm/rn-xwebview \
|
pnpm --dir packages/xwebview typecheck
|
||||||
react-native-webview react-native-blob-util react-native-svg
|
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",
|
"name": "@xuqm/rn-xwebview",
|
||||||
"version": "0.3.0-alpha.2",
|
"version": "1.0.0",
|
||||||
"description": "XuqmGroup RN SDK — XWebView module",
|
"description": "XuqmGroup RN SDK — XWebView module",
|
||||||
"license": "UNLICENSED",
|
"license": "UNLICENSED",
|
||||||
"main": "src/index.ts",
|
"main": "src/index.ts",
|
||||||
@ -18,17 +18,16 @@
|
|||||||
"typecheck": "tsc --noEmit -p tsconfig.json"
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"react-native-svg": "^15.15.4",
|
|
||||||
"react-native-webview": "^14.0.1"
|
"react-native-webview": "^14.0.1"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
|
"@xuqm/rn-common": "workspace:>=0.6.0-alpha.9 <1.0.0",
|
||||||
"react": ">=18.0.0",
|
"react": ">=18.0.0",
|
||||||
"react-native": ">=0.76.0",
|
"react-native": ">=0.76.0",
|
||||||
"@react-native-camera-roll/camera-roll": ">=7.0.0",
|
"@react-native-camera-roll/camera-roll": ">=7.0.0",
|
||||||
"@react-native-clipboard/clipboard": ">=1.16.0",
|
"@react-native-clipboard/clipboard": ">=1.16.0",
|
||||||
"@react-navigation/native": ">=7.0.0",
|
|
||||||
"react-native-safe-area-context": ">=4.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": {
|
"peerDependenciesMeta": {
|
||||||
"@react-native-camera-roll/camera-roll": {
|
"@react-native-camera-roll/camera-roll": {
|
||||||
@ -37,9 +36,9 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/react": "^19.0.0",
|
"@types/react": "^19.0.0",
|
||||||
"@react-navigation/native": "^7.0.0",
|
|
||||||
"@react-native-clipboard/clipboard": "^1.16.3",
|
"@react-native-clipboard/clipboard": "^1.16.3",
|
||||||
"react-native-safe-area-context": "^5.4.0",
|
"react-native-safe-area-context": "^5.4.0",
|
||||||
|
"react-native-svg": "^15.15.4",
|
||||||
"tsx": "^4.23.1",
|
"tsx": "^4.23.1",
|
||||||
"typescript": "catalog:"
|
"typescript": "catalog:"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
import type { XWebViewDownloadRequest } from '@xuqm/rn-common'
|
import type { XWebViewDownloadRequest } from './types'
|
||||||
import {
|
import {
|
||||||
fileNameFromUrl,
|
fileNameFromUrl,
|
||||||
parseContentDispositionFileName,
|
parseContentDispositionFileName,
|
||||||
@ -14,10 +14,11 @@ function normalizedSize(value: string | null): number | undefined {
|
|||||||
export async function fetchDownloadInfo(
|
export async function fetchDownloadInfo(
|
||||||
url: string,
|
url: string,
|
||||||
hintFilename?: string,
|
hintFilename?: string,
|
||||||
|
headers?: Record<string, string>,
|
||||||
): Promise<XWebViewDownloadRequest> {
|
): Promise<XWebViewDownloadRequest> {
|
||||||
const hint = hintFilename?.trim()
|
const hint = hintFilename?.trim()
|
||||||
try {
|
try {
|
||||||
const response = await fetch(url, { method: 'HEAD' })
|
const response = await fetch(url, { headers, method: 'HEAD' })
|
||||||
const disposition = response.headers.get('content-disposition') ?? ''
|
const disposition = response.headers.get('content-disposition') ?? ''
|
||||||
const mimeType = response.headers.get('content-type')?.split(';')[0].trim() || undefined
|
const mimeType = response.headers.get('content-type')?.split(';')[0].trim() || undefined
|
||||||
const suggestedFilename =
|
const suggestedFilename =
|
||||||
|
|||||||
@ -1,22 +1,18 @@
|
|||||||
import { Platform } from 'react-native'
|
import { Platform } from 'react-native'
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
XWebViewDownloadDecision,
|
|
||||||
XWebViewDownloadDestination,
|
XWebViewDownloadDestination,
|
||||||
XWebViewDownloadProgress,
|
XWebViewDownloadProgress,
|
||||||
XWebViewDownloadRequest,
|
|
||||||
XWebViewDownloadResult,
|
XWebViewDownloadResult,
|
||||||
} from '@xuqm/rn-common'
|
} from './types'
|
||||||
import {
|
import {
|
||||||
downloadFileToPath,
|
downloadFileToPath,
|
||||||
getFileDirectories,
|
getFileDirectories,
|
||||||
inferMimeType,
|
inferMimeType,
|
||||||
openLocalFile,
|
|
||||||
registerAndroidDownloadedFile,
|
registerAndroidDownloadedFile,
|
||||||
resolveAvailableFilePath,
|
resolveAvailableFilePath,
|
||||||
writeBase64File,
|
writeBase64File,
|
||||||
} from '@xuqm/rn-common'
|
} from '@xuqm/rn-common'
|
||||||
import { fetchDownloadInfo } from './DownloadMetadata'
|
|
||||||
|
|
||||||
export { fetchDownloadInfo } from './DownloadMetadata'
|
export { fetchDownloadInfo } from './DownloadMetadata'
|
||||||
|
|
||||||
@ -50,18 +46,6 @@ async function saveToGallery(filePath: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 用系统查看器打开已下载文件(Android: actionViewIntent;iOS: 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(
|
export async function saveBase64File(
|
||||||
base64: string,
|
base64: string,
|
||||||
filename: string,
|
filename: string,
|
||||||
@ -105,73 +89,7 @@ export async function saveBase64File(
|
|||||||
return filePath
|
return filePath
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DownloadHandle = { cancel: () => void }
|
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 },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function startDownload(
|
export function startDownload(
|
||||||
url: string,
|
url: string,
|
||||||
@ -183,10 +101,11 @@ export function startDownload(
|
|||||||
onError: (error: string) => void,
|
onError: (error: string) => void,
|
||||||
options: {
|
options: {
|
||||||
destination?: XWebViewDownloadDestination
|
destination?: XWebViewDownloadDestination
|
||||||
|
headers?: Record<string, string>
|
||||||
notificationTitle?: string
|
notificationTitle?: string
|
||||||
} = {},
|
} = {},
|
||||||
): DownloadHandle {
|
): DownloadHandle {
|
||||||
const { destination = 'sandbox', notificationTitle } = options
|
const { destination = 'sandbox', headers, notificationTitle } = options
|
||||||
const dirs = getFileDirectories()
|
const dirs = getFileDirectories()
|
||||||
|
|
||||||
// 公共 Downloads(Android):下载到沙盒(保留 JS 进度回调),完成后通过 DownloadManager
|
// 公共 Downloads(Android):下载到沙盒(保留 JS 进度回调),完成后通过 DownloadManager
|
||||||
@ -202,6 +121,7 @@ export function startDownload(
|
|||||||
.then(cachePath => {
|
.then(cachePath => {
|
||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
const task = downloadFileToPath(url, cachePath, {
|
const task = downloadFileToPath(url, cachePath, {
|
||||||
|
headers,
|
||||||
onProgress: progress => {
|
onProgress: progress => {
|
||||||
onProgress({
|
onProgress({
|
||||||
url,
|
url,
|
||||||
@ -259,6 +179,7 @@ export function startDownload(
|
|||||||
if (cancelled) return
|
if (cancelled) return
|
||||||
|
|
||||||
const task = downloadFileToPath(url, filePath, {
|
const task = downloadFileToPath(url, filePath, {
|
||||||
|
headers,
|
||||||
onProgress: progress => {
|
onProgress: progress => {
|
||||||
onProgress({
|
onProgress({
|
||||||
url,
|
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 { useCallback } from 'react'
|
||||||
import { PermissionsAndroid, Platform } from 'react-native'
|
import { PermissionsAndroid, Platform } from 'react-native'
|
||||||
|
|
||||||
import type { XWebViewPermissionRequest } from '@xuqm/rn-common'
|
import type { XWebViewPermissionRequest, XWebViewPermissionsConfig } from './types'
|
||||||
|
import type { WebPermissionKind } from './permissionPolicy'
|
||||||
import {
|
|
||||||
allowedWebPermissionResources,
|
|
||||||
webPermissionKind,
|
|
||||||
type WebPermissionKind,
|
|
||||||
} from './permissionPolicy'
|
|
||||||
|
|
||||||
type NativePermissionRequest = {
|
|
||||||
nativeEvent: {
|
|
||||||
origin: string
|
|
||||||
resources: string[]
|
|
||||||
grant: (resources: string[]) => void
|
|
||||||
deny: () => void
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const ANDROID_PERMISSIONS = {
|
const ANDROID_PERMISSIONS = {
|
||||||
camera: PermissionsAndroid.PERMISSIONS.CAMERA,
|
camera: PermissionsAndroid.PERMISSIONS.CAMERA,
|
||||||
microphone: PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
|
microphone: PermissionsAndroid.PERMISSIONS.RECORD_AUDIO,
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
async function requestAndroidPermissions(
|
function requestFromHost(
|
||||||
resources: string[],
|
config: XWebViewPermissionsConfig,
|
||||||
): Promise<ReadonlySet<WebPermissionKind>> {
|
origin: string,
|
||||||
const requestedKinds = new Set<WebPermissionKind>()
|
resources: WebPermissionKind[],
|
||||||
for (const resource of resources) {
|
): Promise<boolean> {
|
||||||
const kind = webPermissionKind(resource)
|
if (!config.onRequest) {
|
||||||
if (kind) requestedKinds.add(kind)
|
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(
|
const results = await PermissionsAndroid.requestMultiple(
|
||||||
[...requestedKinds].map(kind => ANDROID_PERMISSIONS[kind]),
|
uniqueResources.map(resource => ANDROID_PERMISSIONS[resource]),
|
||||||
)
|
)
|
||||||
return new Set(
|
return uniqueResources.every(
|
||||||
[...requestedKinds].filter(
|
resource => results[ANDROID_PERMISSIONS[resource]] === PermissionsAndroid.RESULTS.GRANTED,
|
||||||
kind => results[ANDROID_PERMISSIONS[kind]] === PermissionsAndroid.RESULTS.GRANTED,
|
|
||||||
),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* XWebView 唯一权限处理器:宿主提供回调时完全交由宿主决定;否则使用安全默认策略。
|
* 页面只有同时通过宿主白名单与系统权限,才会继续调用 WebView 的 getUserMedia。
|
||||||
|
* SDK 本身不向 Manifest 注入敏感权限,宿主仍需按实际启用能力声明。
|
||||||
*/
|
*/
|
||||||
export function useXWebViewPermissionRequest(
|
export function useXWebViewPermissionDecision(config: XWebViewPermissionsConfig = {}) {
|
||||||
onPermissionRequest?: (request: XWebViewPermissionRequest) => void,
|
|
||||||
) {
|
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(request: NativePermissionRequest) => {
|
async (origin: string, resources: WebPermissionKind[]) => {
|
||||||
const { nativeEvent } = request
|
if (resources.length === 0) return false
|
||||||
if (onPermissionRequest) {
|
if (!(await requestFromHost(config, origin, resources))) return false
|
||||||
onPermissionRequest({
|
if (Platform.OS !== 'android') return true
|
||||||
deny: nativeEvent.deny,
|
return requestAndroidPermissions(resources)
|
||||||
grant: resources => nativeEvent.grant(resources ?? nativeEvent.resources),
|
|
||||||
origin: nativeEvent.origin,
|
|
||||||
resources: nativeEvent.resources,
|
|
||||||
})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Platform.OS !== 'android') {
|
|
||||||
nativeEvent.grant(nativeEvent.resources)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
void requestAndroidPermissions(nativeEvent.resources)
|
|
||||||
.then(grantedKinds => {
|
|
||||||
const allowed = allowedWebPermissionResources(nativeEvent.resources, grantedKinds)
|
|
||||||
if (allowed.length > 0) nativeEvent.grant(allowed)
|
|
||||||
else nativeEvent.deny()
|
|
||||||
})
|
|
||||||
.catch(nativeEvent.deny)
|
|
||||||
},
|
},
|
||||||
[onPermissionRequest],
|
[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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 带统一导航栏的全屏容器。WebView、下载、权限、Bridge 和错误态全部复用
|
|
||||||
* 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 React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { Alert, Platform, Pressable, StatusBar, StyleSheet, Text, View } from 'react-native'
|
import { Image, Linking, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||||
import Clipboard from '@react-native-clipboard/clipboard'
|
import Clipboard from '@react-native-clipboard/clipboard'
|
||||||
import { SafeAreaView } from 'react-native-safe-area-context'
|
|
||||||
import WebView from 'react-native-webview'
|
import WebView from 'react-native-webview'
|
||||||
import type {
|
import type {
|
||||||
ShouldStartLoadRequest,
|
ShouldStartLoadRequest,
|
||||||
@ -12,178 +11,221 @@ import type {
|
|||||||
WebViewProgressEvent,
|
WebViewProgressEvent,
|
||||||
} from 'react-native-webview/lib/WebViewTypes'
|
} from 'react-native-webview/lib/WebViewTypes'
|
||||||
|
|
||||||
import { getXWebViewConfig, setXWebViewController, type XWebViewConfig } from '@xuqm/rn-common'
|
import { resolveLinkPolicy } from './linkPolicy'
|
||||||
|
import {
|
||||||
import XWebViewProgress from './XWebViewProgress'
|
closePageById,
|
||||||
|
isActivePage,
|
||||||
|
registerPageController,
|
||||||
|
updatePageNavigation,
|
||||||
|
updatePageNavigationState,
|
||||||
|
updatePageWindow,
|
||||||
|
} from './runtime'
|
||||||
|
import type {
|
||||||
|
XWebViewBridgePageContext,
|
||||||
|
XWebViewConfig,
|
||||||
|
XWebViewController,
|
||||||
|
XWebViewNavigationState,
|
||||||
|
XWebViewSource,
|
||||||
|
} from './types'
|
||||||
import { fetchDownloadInfo, saveBase64File, startDownload } from './XWebViewDownload'
|
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 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 =
|
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
|
/\.(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() {
|
||||||
function post(obj) {
|
function post(value) {
|
||||||
window.ReactNativeWebView.postMessage(JSON.stringify(obj));
|
if (window.ReactNativeWebView &&
|
||||||
|
typeof window.ReactNativeWebView.postMessage === 'function') {
|
||||||
|
window.ReactNativeWebView.postMessage(JSON.stringify(value));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.alert = function(msg) { post({ __xwv: 'alert', msg: String(msg) }); };
|
function readBlob(blobUrl, filename) {
|
||||||
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) {
|
|
||||||
fetch(blobUrl)
|
fetch(blobUrl)
|
||||||
.then(function(r) { return r.blob(); })
|
.then(function(response) { return response.blob(); })
|
||||||
.then(function(blob) {
|
.then(function(blob) {
|
||||||
var reader = new FileReader();
|
var reader = new FileReader();
|
||||||
reader.onloadend = function() {
|
reader.onloadend = function() {
|
||||||
var b64 = reader.result.split(',')[1];
|
var value = String(reader.result || '');
|
||||||
post({ __xwv: 'blobdownload', url: blobUrl, filename: filename, data: b64 });
|
var comma = value.indexOf(',');
|
||||||
|
post({
|
||||||
|
__xwv: 'blobdownload',
|
||||||
|
data: comma >= 0 ? value.slice(comma + 1) : value,
|
||||||
|
filename: filename || 'download',
|
||||||
|
url: blobUrl
|
||||||
|
});
|
||||||
};
|
};
|
||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
})
|
})
|
||||||
.catch(function(err) {
|
.catch(function() {
|
||||||
post({ __xwv: 'bloberror', msg: String(err) });
|
post({ __xwv: 'bloberror', url: blobUrl });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
var DL_RE = ${JSON.stringify(DOWNLOAD_EXT_RE.source)};
|
var downloadPattern = new RegExp(${JSON.stringify(DOWNLOAD_EXT_RE.source)}, 'i');
|
||||||
var dlRe = new RegExp(DL_RE, 'i');
|
|
||||||
|
|
||||||
function tryInterceptAnchor(el, e) {
|
function intercept(anchor, event) {
|
||||||
if (!el || el.tagName !== 'A') return false;
|
if (!anchor || anchor.tagName !== 'A') return false;
|
||||||
var href = el.href || el.getAttribute('href') || '';
|
var href = anchor.href || anchor.getAttribute('href') || '';
|
||||||
if (!href || href.indexOf('javascript') === 0) return false;
|
if (!href || href.indexOf('javascript:') === 0) return false;
|
||||||
var hasDownloadAttr = el.hasAttribute('download');
|
var filename = anchor.getAttribute('download') || '';
|
||||||
var dlName = el.getAttribute('download') || '';
|
var download = anchor.hasAttribute('download') ||
|
||||||
var isDL = hasDownloadAttr || dlRe.test(href);
|
downloadPattern.test(href);
|
||||||
post({ __xwv: 'log', msg: '[XWV] anchor href=' + href + ' download=' + hasDownloadAttr + ' dlName=' + dlName + ' isDL=' + isDL });
|
if (!download) return false;
|
||||||
if (isDL) {
|
if (event) {
|
||||||
if (e) { e.preventDefault(); e.stopPropagation(); }
|
event.preventDefault();
|
||||||
if (href.startsWith('blob:')) {
|
event.stopPropagation();
|
||||||
readBlobAndPost(href, dlName);
|
|
||||||
} else {
|
|
||||||
post({ __xwv: 'download', url: href, filename: dlName });
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
}
|
}
|
||||||
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) {
|
document.addEventListener('click', function(event) {
|
||||||
var el = e.target;
|
var element = event.target;
|
||||||
while (el && el.tagName !== 'A') { el = el.parentElement; }
|
while (element && element.tagName !== 'A') {
|
||||||
tryInterceptAnchor(el, e);
|
element = element.parentElement;
|
||||||
|
}
|
||||||
|
intercept(element, event);
|
||||||
}, true);
|
}, true);
|
||||||
|
|
||||||
var _origClick = HTMLAnchorElement.prototype.click;
|
var originalClick = HTMLAnchorElement.prototype.click;
|
||||||
HTMLAnchorElement.prototype.click = function() {
|
HTMLAnchorElement.prototype.click = function() {
|
||||||
post({ __xwv: 'log', msg: '[XWV] HTMLAnchorElement.click() href=' + this.href });
|
if (!intercept(this, null)) originalClick.call(this);
|
||||||
if (!tryInterceptAnchor(this, null)) {
|
|
||||||
_origClick.call(this);
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
post({ __xwv: 'log', msg: '[XWV] injected scripts ready' });
|
|
||||||
})();
|
})();
|
||||||
true;
|
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({
|
export function XWebViewView({
|
||||||
config: configProp,
|
active = true,
|
||||||
manageSystemBars = true,
|
config,
|
||||||
registerController,
|
pageId,
|
||||||
}: {
|
statusBarHeight = 0,
|
||||||
config?: XWebViewConfig
|
}: XWebViewViewProps) {
|
||||||
/** 外层 Screen 自己管理安全区和状态栏时关闭。 */
|
const embeddedId = useRef(
|
||||||
manageSystemBars?: boolean
|
`xwv-embedded-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`,
|
||||||
/** 组合式 Screen 传入 config 时仍可显式注册全局控制器。 */
|
)
|
||||||
registerController?: boolean
|
const resolvedPageId = pageId ?? embeddedId.current
|
||||||
} = {}) {
|
|
||||||
const webViewRef = useRef<WebView>(null)
|
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 [progress, setProgress] = useState(0)
|
||||||
const [showProgress, setShowProgress] = useState(false)
|
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 [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(() => {
|
const reload = useCallback(() => {
|
||||||
setLoadError(null)
|
setLoadError(null)
|
||||||
setShowProgress(true)
|
setShowProgress(true)
|
||||||
webViewRef.current?.reload()
|
webViewRef.current?.reload()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
const controller = useMemo<XWebViewController>(
|
||||||
// 内联模式不注册全局 controller,避免与全屏 XWebView 实例互相覆盖。
|
() => ({
|
||||||
if (!shouldRegisterController) return
|
copyUrl: () => Clipboard.setString(navigationState.url),
|
||||||
setXWebViewController({
|
evaluateJavaScript: script => webViewRef.current?.injectJavaScript(script),
|
||||||
refresh: reload,
|
getNavigationState: () => navigationState,
|
||||||
close: () => onClose?.(),
|
|
||||||
goBack: () => {
|
goBack: () => {
|
||||||
setLoadError(null)
|
setLoadError(null)
|
||||||
webViewRef.current?.goBack()
|
webViewRef.current?.goBack()
|
||||||
@ -192,225 +234,336 @@ export function XWebViewView({
|
|||||||
setLoadError(null)
|
setLoadError(null)
|
||||||
webViewRef.current?.goForward()
|
webViewRef.current?.goForward()
|
||||||
},
|
},
|
||||||
copyUrl: () => Clipboard.setString(currentUrl),
|
postMessage: message => webViewRef.current?.postMessage(message),
|
||||||
postMessageToWeb: (js: string) => webViewRef.current?.injectJavaScript(js),
|
reload,
|
||||||
getTitle: () => currentTitle,
|
}),
|
||||||
getNavigationState: () => ({ canGoBack, canGoForward, title: currentTitle, url: currentUrl }),
|
[navigationState, reload],
|
||||||
})
|
|
||||||
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],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleLoadProgress = useCallback((event: WebViewProgressEvent) => {
|
useEffect(() => {
|
||||||
const p = event.nativeEvent.progress
|
if (pageId) registerPageController(pageId, controller)
|
||||||
setProgress(p)
|
else config.behavior?.onControllerReady?.(controller)
|
||||||
if (p >= 0.96) setShowProgress(false)
|
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(
|
const processBlobDownload = useCallback(
|
||||||
async (blobUrl: string, hintFilename: string, base64: string) => {
|
async (blobUrl: string, hintFilename: string, base64: string) => {
|
||||||
const info = { url: blobUrl, suggestedFilename: hintFilename || 'download' }
|
const info = {
|
||||||
let finalFilename = info.suggestedFilename
|
suggestedFilename: hintFilename || 'download',
|
||||||
let finalSavePath: string | undefined
|
url: blobUrl,
|
||||||
|
}
|
||||||
if (!autoDownload) {
|
let filename = info.suggestedFilename
|
||||||
if (!onDownloadDecide) return
|
let savePath: string | undefined
|
||||||
const decision = await Promise.resolve(onDownloadDecide(info))
|
if (downloads.auto === false) {
|
||||||
|
if (!downloads.onDecide) return
|
||||||
|
const decision = await Promise.resolve(downloads.onDecide(info))
|
||||||
if (!decision.allowed) return
|
if (!decision.allowed) return
|
||||||
if (decision.filename) finalFilename = decision.filename
|
filename = decision.filename ?? filename
|
||||||
if (decision.savePath) finalSavePath = decision.savePath
|
savePath = decision.savePath
|
||||||
}
|
}
|
||||||
|
|
||||||
onDownloadStart?.({ ...info, suggestedFilename: finalFilename })
|
downloads.onStart?.({ ...info, suggestedFilename: filename })
|
||||||
try {
|
try {
|
||||||
const filePath = await saveBase64File(
|
const filePath = await saveBase64File(
|
||||||
base64,
|
base64,
|
||||||
finalFilename,
|
filename,
|
||||||
finalSavePath,
|
savePath,
|
||||||
downloadConflict,
|
downloads.conflict ?? 'rename',
|
||||||
downloadDestination,
|
downloads.destination ?? 'sandbox',
|
||||||
)
|
)
|
||||||
onDownloadComplete?.({ url: blobUrl, filename: finalFilename, filePath, fileSize: 0 })
|
downloads.onComplete?.({
|
||||||
} catch (err) {
|
filePath,
|
||||||
onDownloadError?.(blobUrl, String(err))
|
fileSize: 0,
|
||||||
|
filename,
|
||||||
|
url: blobUrl,
|
||||||
|
})
|
||||||
|
} catch (error) {
|
||||||
|
downloads.onError?.(blobUrl, String(error))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[
|
[downloads],
|
||||||
autoDownload,
|
|
||||||
downloadConflict,
|
|
||||||
downloadDestination,
|
|
||||||
onDownloadDecide,
|
|
||||||
onDownloadStart,
|
|
||||||
onDownloadComplete,
|
|
||||||
onDownloadError,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const processDownload = useCallback(
|
const processDownload = useCallback(
|
||||||
async (downloadUrl: string, hintFilename?: string) => {
|
async (downloadUrl: string, hintFilename?: string) => {
|
||||||
const info = await fetchDownloadInfo(downloadUrl, hintFilename)
|
try {
|
||||||
let finalFilename = info.suggestedFilename
|
const info = await fetchDownloadInfo(downloadUrl, hintFilename, downloadHeaders)
|
||||||
let finalSavePath: string | undefined
|
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) {
|
downloads.onStart?.({ ...info, suggestedFilename: filename })
|
||||||
if (!onDownloadDecide) return
|
startDownload(
|
||||||
const decision = await Promise.resolve(onDownloadDecide(info))
|
downloadUrl,
|
||||||
if (!decision.allowed) return
|
filename,
|
||||||
if (decision.filename) finalFilename = decision.filename
|
savePath,
|
||||||
if (decision.savePath) finalSavePath = decision.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 },
|
|
||||||
)
|
|
||||||
},
|
},
|
||||||
[
|
[downloadHeaders, downloads],
|
||||||
autoDownload,
|
|
||||||
downloadConflict,
|
|
||||||
downloadDestination,
|
|
||||||
downloadNotificationTitle,
|
|
||||||
onDownloadDecide,
|
|
||||||
onDownloadStart,
|
|
||||||
onDownloadProgress,
|
|
||||||
onDownloadComplete,
|
|
||||||
onDownloadError,
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
const handleMessage = useCallback(
|
const handleMessage = useCallback(
|
||||||
(event: WebViewMessageEvent) => {
|
(event: WebViewMessageEvent) => {
|
||||||
const raw = event.nativeEvent.data
|
const raw = event.nativeEvent.data
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(raw)
|
const parsed = JSON.parse(raw) as Record<string, unknown>
|
||||||
|
|
||||||
if (parsed.__xwv === 'download') {
|
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
|
return
|
||||||
}
|
}
|
||||||
if (parsed.__xwv === 'blobdownload') {
|
if (parsed.__xwv === 'blobdownload') {
|
||||||
processBlobDownload(
|
void processBlobDownload(
|
||||||
String(parsed.url),
|
String(parsed.url ?? ''),
|
||||||
String(parsed.filename ?? 'download'),
|
String(parsed.filename ?? 'download'),
|
||||||
String(parsed.data ?? ''),
|
String(parsed.data ?? ''),
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (parsed.__xwv === 'bloberror') {
|
if (parsed.__xwv === 'bloberror') {
|
||||||
onDownloadError?.(String(parsed.url ?? ''), String(parsed.msg ?? 'blob read failed'))
|
downloads.onError?.(String(parsed.url ?? ''), '无法读取网页生成的文件')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (parsed.__xwv === 'alert' || parsed.__xwv === 'confirm' || parsed.__xwv === 'prompt') {
|
if (parsed.__xwv === 'permission') {
|
||||||
Alert.alert('提示', String(parsed.msg ?? ''))
|
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
|
return
|
||||||
}
|
}
|
||||||
} catch {
|
} 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(
|
const handleShouldStartLoad = useCallback(
|
||||||
(request: ShouldStartLoadRequest): boolean => {
|
(request: ShouldStartLoadRequest): boolean => {
|
||||||
if (isDownloadUrl(request.url)) {
|
if (isDownloadUrl(request.url)) {
|
||||||
processDownload(request.url)
|
void processDownload(request.url)
|
||||||
return false
|
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) => {
|
const handleOpenWindow = useCallback(
|
||||||
setLoadError(event.nativeEvent.description || '页面加载失败')
|
(event: WebViewOpenWindowEvent) => {
|
||||||
setShowProgress(false)
|
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 handleNavigationStateChange = useCallback(
|
||||||
const targetUrl = event.nativeEvent.targetUrl
|
(state: WebViewNavigation) => {
|
||||||
if (targetUrl) {
|
const value: XWebViewNavigationState = {
|
||||||
webViewRef.current?.injectJavaScript(
|
canGoBack: state.canGoBack,
|
||||||
`window.location.href = ${JSON.stringify(targetUrl)}; true;`,
|
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
|
const source = isUriSource(config.source)
|
||||||
? ({ uri: url, headers } as const)
|
? { headers: config.source.headers, uri: config.source.uri }
|
||||||
: ({ html: content ?? '<html><body></body></html>' } as const)
|
: { baseUrl: config.source.baseUrl, html: config.source.html }
|
||||||
|
|
||||||
const injected = DIALOG_OVERRIDE_JS + '\n' + (injectedJavaScript ?? '')
|
const injectedBefore = joinScripts(
|
||||||
const ContentContainer = manageSystemBars && showStatusBar ? SafeAreaView : View
|
MEDIA_PERMISSION_SCRIPT,
|
||||||
|
config.bridge?.injectedJavaScriptBeforeContentLoaded,
|
||||||
|
config.behavior?.injectedJavaScriptBeforeContentLoaded,
|
||||||
|
)
|
||||||
|
const injected = joinScripts(
|
||||||
|
MEDIA_PERMISSION_SCRIPT,
|
||||||
|
DOWNLOAD_INTERCEPT_SCRIPT,
|
||||||
|
config.behavior?.injectedJavaScript,
|
||||||
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContentContainer style={styles.container}>
|
<View style={styles.container}>
|
||||||
{manageSystemBars ? <StatusBar hidden={!showStatusBar} /> : null}
|
|
||||||
<XWebViewProgress progress={progress} visible={showProgress} />
|
<XWebViewProgress progress={progress} visible={showProgress} />
|
||||||
<View style={styles.content}>
|
<WebViewAny
|
||||||
<WebViewAny
|
ref={webViewRef}
|
||||||
ref={webViewRef}
|
source={source}
|
||||||
source={source}
|
style={styles.webview}
|
||||||
style={styles.webview}
|
onLoadStart={() => {
|
||||||
onLoadStart={() => {
|
setLoadError(null)
|
||||||
setShowProgress(true)
|
setShowProgress(true)
|
||||||
}}
|
}}
|
||||||
onLoadProgress={handleLoadProgress}
|
onLoadProgress={(event: WebViewProgressEvent) => {
|
||||||
onLoadEnd={() => setShowProgress(false)}
|
const value = event.nativeEvent.progress
|
||||||
onError={handleError}
|
setProgress(value)
|
||||||
onNavigationStateChange={handleNavigationStateChange}
|
if (value >= 0.96) setShowProgress(false)
|
||||||
onMessage={handleMessage}
|
}}
|
||||||
onShouldStartLoadWithRequest={handleShouldStartLoad}
|
onLoadEnd={() => setShowProgress(false)}
|
||||||
onPermissionRequest={handlePermissionRequest}
|
onError={(event: WebViewErrorEvent) => {
|
||||||
injectedJavaScript={injected}
|
setLoadError(event.nativeEvent.description || '页面加载失败')
|
||||||
onOpenWindow={handleOpenWindow}
|
setShowProgress(false)
|
||||||
javaScriptEnabled
|
}}
|
||||||
domStorageEnabled
|
onNavigationStateChange={handleNavigationStateChange}
|
||||||
allowFileAccess
|
onMessage={handleMessage}
|
||||||
allowsInlineMediaPlayback
|
onShouldStartLoadWithRequest={handleShouldStartLoad}
|
||||||
sharedCookiesEnabled
|
injectedJavaScriptBeforeContentLoaded={injectedBefore}
|
||||||
mediaPlaybackRequiresUserAction={false}
|
injectedJavaScript={injected}
|
||||||
setSupportMultipleWindows={false}
|
onOpenWindow={handleOpenWindow}
|
||||||
webviewDebuggingEnabled={__DEV__}
|
javaScriptEnabled
|
||||||
/>
|
domStorageEnabled
|
||||||
{loadError ? (
|
allowFileAccess={false}
|
||||||
<View style={styles.errorContainer}>
|
allowsInlineMediaPlayback
|
||||||
<Text style={styles.errorTitle}>页面加载失败</Text>
|
sharedCookiesEnabled={config.behavior?.cookies?.shared ?? true}
|
||||||
<Text style={styles.errorMessage}>{loadError}</Text>
|
thirdPartyCookiesEnabled={config.behavior?.cookies?.thirdParty ?? false}
|
||||||
<Pressable
|
mixedContentMode={config.behavior?.mixedContent ?? 'never'}
|
||||||
onPress={reload}
|
mediaPlaybackRequiresUserAction={false}
|
||||||
style={({ pressed }) => [styles.retryBtn, pressed && { opacity: 0.7 }]}
|
setSupportMultipleWindows={false}
|
||||||
>
|
webviewDebuggingEnabled={__DEV__}
|
||||||
<Text style={styles.retryText}>点击重试</Text>
|
/>
|
||||||
</Pressable>
|
{loadError ? (
|
||||||
|
<View style={styles.errorContainer}>
|
||||||
|
<View>
|
||||||
|
<WebViewError message={loadError} onRetry={reload} />
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
</View>
|
||||||
</View>
|
) : null}
|
||||||
</ContentContainer>
|
</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,
|
flex: 1,
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
},
|
},
|
||||||
webview: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
content: {
|
|
||||||
flex: 1,
|
|
||||||
},
|
|
||||||
errorContainer: {
|
errorContainer: {
|
||||||
position: 'absolute',
|
|
||||||
top: 0,
|
|
||||||
right: 0,
|
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
position: 'absolute',
|
||||||
|
right: 0,
|
||||||
|
top: 0,
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
backgroundColor: '#ffffff',
|
backgroundColor: '#ffffff',
|
||||||
justifyContent: 'center',
|
justifyContent: 'center',
|
||||||
paddingHorizontal: 32,
|
paddingHorizontal: 32,
|
||||||
|
},
|
||||||
|
errorContent: {
|
||||||
|
alignItems: 'center',
|
||||||
gap: 12,
|
gap: 12,
|
||||||
},
|
},
|
||||||
errorTitle: {
|
|
||||||
fontSize: 18,
|
|
||||||
fontWeight: '800',
|
|
||||||
color: '#b91c1c',
|
|
||||||
},
|
|
||||||
errorMessage: {
|
errorMessage: {
|
||||||
fontSize: 13,
|
color: '#6b7280',
|
||||||
color: '#7f1d1d',
|
fontSize: 14,
|
||||||
|
lineHeight: 21,
|
||||||
textAlign: 'center',
|
textAlign: 'center',
|
||||||
lineHeight: 20,
|
|
||||||
},
|
},
|
||||||
retryBtn: {
|
errorTitle: {
|
||||||
marginTop: 8,
|
color: '#111827',
|
||||||
backgroundColor: '#3b82f6',
|
fontSize: 18,
|
||||||
|
fontWeight: '700',
|
||||||
|
},
|
||||||
|
retryButton: {
|
||||||
|
backgroundColor: '#1677ff',
|
||||||
borderRadius: 8,
|
borderRadius: 8,
|
||||||
|
marginTop: 8,
|
||||||
paddingHorizontal: 24,
|
paddingHorizontal: 24,
|
||||||
paddingVertical: 10,
|
paddingVertical: 10,
|
||||||
},
|
},
|
||||||
retryText: {
|
retryText: {
|
||||||
color: '#ffffff',
|
color: '#ffffff',
|
||||||
fontWeight: '700',
|
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
|
fontWeight: '600',
|
||||||
|
},
|
||||||
|
webview: {
|
||||||
|
flex: 1,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,34 +1,33 @@
|
|||||||
import '@xuqm/rn-common/internal'
|
import '@xuqm/rn-common/internal'
|
||||||
|
|
||||||
export {
|
export { XWebViewHost } from './XWebViewHost'
|
||||||
getXWebViewConfig,
|
export { XWebViewView } from './XWebViewView'
|
||||||
openXWebView,
|
export { openWebView } from './runtime'
|
||||||
setXWebViewController,
|
export { resolveLinkPolicy } from './linkPolicy'
|
||||||
setXWebViewScanQRCodeHandler,
|
|
||||||
XWebViewControl,
|
|
||||||
} from '@xuqm/rn-common'
|
|
||||||
export type {
|
export type {
|
||||||
ScanQRCodeHandler,
|
XWebViewBehaviorConfig,
|
||||||
XWebViewClickMenu,
|
XWebViewBridge,
|
||||||
|
XWebViewBridgePageContext,
|
||||||
XWebViewConfig,
|
XWebViewConfig,
|
||||||
XWebViewControllerAPI,
|
XWebViewController,
|
||||||
XWebViewDownloadDecision,
|
XWebViewDownloadDecision,
|
||||||
XWebViewDownloadDestination,
|
XWebViewDownloadDestination,
|
||||||
XWebViewDownloadProgress,
|
XWebViewDownloadProgress,
|
||||||
XWebViewDownloadRequest,
|
XWebViewDownloadRequest,
|
||||||
XWebViewDownloadResult,
|
XWebViewDownloadResult,
|
||||||
|
XWebViewDownloadsConfig,
|
||||||
|
XWebViewExternalUrlDecision,
|
||||||
|
XWebViewMenuContext,
|
||||||
|
XWebViewMenuItem,
|
||||||
XWebViewMessageEvent,
|
XWebViewMessageEvent,
|
||||||
|
XWebViewNavigationBackground,
|
||||||
|
XWebViewNavigationBarConfig,
|
||||||
|
XWebViewNavigationRenderContext,
|
||||||
|
XWebViewNavigationState,
|
||||||
|
XWebViewPageClosedResult,
|
||||||
|
XWebViewPageHandle,
|
||||||
XWebViewPermissionRequest,
|
XWebViewPermissionRequest,
|
||||||
} from '@xuqm/rn-common'
|
XWebViewPermissionsConfig,
|
||||||
|
XWebViewSource,
|
||||||
export { default as XWebViewProgress } from './XWebViewProgress'
|
XWebViewStatusBarConfig,
|
||||||
export { XWebViewView } from './XWebViewView'
|
} from './types'
|
||||||
export { XWebViewScreen } from './XWebViewScreen'
|
|
||||||
export {
|
|
||||||
handleDownloadRequest,
|
|
||||||
fetchDownloadInfo,
|
|
||||||
saveBase64File,
|
|
||||||
startDownload,
|
|
||||||
openFile,
|
|
||||||
} from './XWebViewDownload'
|
|
||||||
export type { DownloadHandle } from './XWebViewDownload'
|
|
||||||
|
|||||||
@ -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' }
|
||||||
|
}
|
||||||
237
packages/xwebview/src/runtime.ts
普通文件
237
packages/xwebview/src/runtime.ts
普通文件
@ -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()
|
||||||
|
}
|
||||||
232
packages/xwebview/src/types.ts
普通文件
232
packages/xwebview/src/types.ts
普通文件
@ -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
自动生成的
144
pnpm-lock.yaml
自动生成的
@ -140,9 +140,6 @@ importers:
|
|||||||
|
|
||||||
packages/xwebview:
|
packages/xwebview:
|
||||||
dependencies:
|
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:
|
react-native-webview:
|
||||||
specifier: ^14.0.1
|
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)
|
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':
|
'@react-native-clipboard/clipboard':
|
||||||
specifier: ^1.16.3
|
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)
|
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':
|
'@types/react':
|
||||||
specifier: ^19.0.0
|
specifier: ^19.0.0
|
||||||
version: 19.2.17
|
version: 19.2.17
|
||||||
react-native-safe-area-context:
|
react-native-safe-area-context:
|
||||||
specifier: ^5.4.0
|
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)
|
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:
|
tsx:
|
||||||
specifier: ^4.23.1
|
specifier: ^4.23.1
|
||||||
version: 4.23.1
|
version: 4.23.1
|
||||||
@ -524,20 +521,6 @@ packages:
|
|||||||
'@types/react':
|
'@types/react':
|
||||||
optional: true
|
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':
|
'@sinclair/typebox@0.27.10':
|
||||||
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
|
resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==}
|
||||||
|
|
||||||
@ -688,6 +671,14 @@ packages:
|
|||||||
cpu: [x64]
|
cpu: [x64]
|
||||||
os: [win32]
|
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:
|
abort-controller@3.0.0:
|
||||||
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==}
|
||||||
engines: {node: '>=6.5'}
|
engines: {node: '>=6.5'}
|
||||||
@ -867,10 +858,6 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
decode-uri-component@0.2.2:
|
|
||||||
resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==}
|
|
||||||
engines: {node: '>=0.10'}
|
|
||||||
|
|
||||||
delayed-stream@1.0.0:
|
delayed-stream@1.0.0:
|
||||||
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==}
|
||||||
engines: {node: '>=0.4.0'}
|
engines: {node: '>=0.4.0'}
|
||||||
@ -967,9 +954,6 @@ packages:
|
|||||||
exponential-backoff@3.1.3:
|
exponential-backoff@3.1.3:
|
||||||
resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==}
|
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:
|
fb-dotslash@0.5.8:
|
||||||
resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==}
|
resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==}
|
||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
@ -994,10 +978,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
filter-obj@1.1.0:
|
|
||||||
resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==}
|
|
||||||
engines: {node: '>=0.10.0'}
|
|
||||||
|
|
||||||
finalhandler@1.1.2:
|
finalhandler@1.1.2:
|
||||||
resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==}
|
resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
@ -1311,11 +1291,6 @@ packages:
|
|||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
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:
|
negotiator@1.0.0:
|
||||||
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@ -1388,10 +1363,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
|
|
||||||
query-string@7.1.3:
|
|
||||||
resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==}
|
|
||||||
engines: {node: '>=6'}
|
|
||||||
|
|
||||||
queue@6.0.2:
|
queue@6.0.2:
|
||||||
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
|
resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==}
|
||||||
|
|
||||||
@ -1408,9 +1379,6 @@ packages:
|
|||||||
react-is@18.3.1:
|
react-is@18.3.1:
|
||||||
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
|
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:
|
react-native-blob-util@0.24.10:
|
||||||
resolution: {integrity: sha512-4yazgoCstXgt2/CRURtowGP3furAzbWdfMQ4jyUPDo5xtLx7KE2F/xMz3oU3shLwfL4id0iEKsBywoxvJSHPzQ==}
|
resolution: {integrity: sha512-4yazgoCstXgt2/CRURtowGP3furAzbWdfMQ4jyUPDo5xtLx7KE2F/xMz3oU3shLwfL4id0iEKsBywoxvJSHPzQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -1520,10 +1488,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==}
|
||||||
engines: {node: '>=0.10.0'}
|
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:
|
sql-escape-string@1.1.0:
|
||||||
resolution: {integrity: sha512-/kqO4pLZSLfV0KsBM2xkVh2S3GbjJJone37d7gYwLyP0c+REh3vnmkhQ7VwNrX76igC0OhJWpTg0ukkdef9vvA==}
|
resolution: {integrity: sha512-/kqO4pLZSLfV0KsBM2xkVh2S3GbjJJone37d7gYwLyP0c+REh3vnmkhQ7VwNrX76igC0OhJWpTg0ukkdef9vvA==}
|
||||||
|
|
||||||
@ -1534,9 +1498,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==}
|
resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
standard-navigation@0.0.7:
|
|
||||||
resolution: {integrity: sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==}
|
|
||||||
|
|
||||||
statuses@1.5.0:
|
statuses@1.5.0:
|
||||||
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
|
resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@ -1545,10 +1506,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==}
|
||||||
engines: {node: '>= 0.8'}
|
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:
|
string-width@4.2.3:
|
||||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@ -1621,16 +1578,6 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
browserslist: '>= 4.21.0'
|
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:
|
utils-merge@1.0.1:
|
||||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||||
engines: {node: '>= 0.4.0'}
|
engines: {node: '>= 0.4.0'}
|
||||||
@ -2016,33 +1963,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/react': 19.2.17
|
'@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': {}
|
'@sinclair/typebox@0.27.10': {}
|
||||||
|
|
||||||
'@types/istanbul-lib-coverage@2.0.6': {}
|
'@types/istanbul-lib-coverage@2.0.6': {}
|
||||||
@ -2135,6 +2055,17 @@ snapshots:
|
|||||||
'@typescript/typescript-win32-x64@7.0.2':
|
'@typescript/typescript-win32-x64@7.0.2':
|
||||||
optional: true
|
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:
|
abort-controller@3.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
event-target-shim: 5.0.1
|
event-target-shim: 5.0.1
|
||||||
@ -2313,8 +2244,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|
||||||
decode-uri-component@0.2.2: {}
|
|
||||||
|
|
||||||
delayed-stream@1.0.0: {}
|
delayed-stream@1.0.0: {}
|
||||||
|
|
||||||
depd@2.0.0: {}
|
depd@2.0.0: {}
|
||||||
@ -2417,8 +2346,6 @@ snapshots:
|
|||||||
|
|
||||||
exponential-backoff@3.1.3: {}
|
exponential-backoff@3.1.3: {}
|
||||||
|
|
||||||
fast-deep-equal@3.1.3: {}
|
|
||||||
|
|
||||||
fb-dotslash@0.5.8: {}
|
fb-dotslash@0.5.8: {}
|
||||||
|
|
||||||
fb-watchman@2.0.2:
|
fb-watchman@2.0.2:
|
||||||
@ -2435,8 +2362,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
to-regex-range: 5.0.1
|
to-regex-range: 5.0.1
|
||||||
|
|
||||||
filter-obj@1.1.0: {}
|
|
||||||
|
|
||||||
finalhandler@1.1.2:
|
finalhandler@1.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 2.6.9
|
debug: 2.6.9
|
||||||
@ -2851,8 +2776,6 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
nanoid@3.3.16: {}
|
|
||||||
|
|
||||||
negotiator@1.0.0: {}
|
negotiator@1.0.0: {}
|
||||||
|
|
||||||
node-int64@0.4.0: {}
|
node-int64@0.4.0: {}
|
||||||
@ -2911,13 +2834,6 @@ snapshots:
|
|||||||
|
|
||||||
proxy-from-env@2.1.0: {}
|
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:
|
queue@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
@ -2936,8 +2852,6 @@ snapshots:
|
|||||||
|
|
||||||
react-is@18.3.1: {}
|
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):
|
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:
|
dependencies:
|
||||||
base-64: 1.0.0
|
base-64: 1.0.0
|
||||||
@ -3076,8 +2990,6 @@ snapshots:
|
|||||||
|
|
||||||
source-map@0.6.1: {}
|
source-map@0.6.1: {}
|
||||||
|
|
||||||
split-on-first@1.1.0: {}
|
|
||||||
|
|
||||||
sql-escape-string@1.1.0: {}
|
sql-escape-string@1.1.0: {}
|
||||||
|
|
||||||
stackframe@1.3.4: {}
|
stackframe@1.3.4: {}
|
||||||
@ -3086,14 +2998,10 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
type-fest: 0.7.1
|
type-fest: 0.7.1
|
||||||
|
|
||||||
standard-navigation@0.0.7: {}
|
|
||||||
|
|
||||||
statuses@1.5.0: {}
|
statuses@1.5.0: {}
|
||||||
|
|
||||||
statuses@2.0.2: {}
|
statuses@2.0.2: {}
|
||||||
|
|
||||||
strict-uri-encode@2.0.0: {}
|
|
||||||
|
|
||||||
string-width@4.2.3:
|
string-width@4.2.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
emoji-regex: 8.0.0
|
emoji-regex: 8.0.0
|
||||||
@ -3179,14 +3087,6 @@ snapshots:
|
|||||||
escalade: 3.2.0
|
escalade: 3.2.0
|
||||||
picocolors: 1.1.1
|
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: {}
|
utils-merge@1.0.1: {}
|
||||||
|
|
||||||
vlq@1.0.1: {}
|
vlq@1.0.1: {}
|
||||||
|
|||||||
33
src/index.ts
33
src/index.ts
@ -25,28 +25,33 @@ export type {
|
|||||||
} from '@xuqm/rn-update'
|
} from '@xuqm/rn-update'
|
||||||
export { BugCollect } from '@xuqm/rn-bugcollect'
|
export { BugCollect } from '@xuqm/rn-bugcollect'
|
||||||
export {
|
export {
|
||||||
XWebViewControl,
|
XWebViewHost,
|
||||||
XWebViewScreen,
|
|
||||||
XWebViewView,
|
XWebViewView,
|
||||||
getXWebViewConfig,
|
openWebView,
|
||||||
openXWebView,
|
|
||||||
setXWebViewController,
|
|
||||||
setXWebViewScanQRCodeHandler,
|
|
||||||
handleDownloadRequest,
|
|
||||||
fetchDownloadInfo,
|
|
||||||
saveBase64File,
|
|
||||||
startDownload,
|
|
||||||
} from '@xuqm/rn-xwebview'
|
} from '@xuqm/rn-xwebview'
|
||||||
export type {
|
export type {
|
||||||
ScanQRCodeHandler,
|
XWebViewBridge,
|
||||||
XWebViewClickMenu,
|
XWebViewBridgePageContext,
|
||||||
XWebViewConfig,
|
XWebViewConfig,
|
||||||
XWebViewControllerAPI,
|
XWebViewController,
|
||||||
XWebViewDownloadDecision,
|
XWebViewDownloadDecision,
|
||||||
|
XWebViewDownloadDestination,
|
||||||
XWebViewDownloadProgress,
|
XWebViewDownloadProgress,
|
||||||
XWebViewDownloadRequest,
|
XWebViewDownloadRequest,
|
||||||
XWebViewDownloadResult,
|
XWebViewDownloadResult,
|
||||||
|
XWebViewDownloadsConfig,
|
||||||
|
XWebViewExternalUrlDecision,
|
||||||
|
XWebViewMenuContext,
|
||||||
|
XWebViewMenuItem,
|
||||||
XWebViewMessageEvent,
|
XWebViewMessageEvent,
|
||||||
|
XWebViewNavigationBackground,
|
||||||
|
XWebViewNavigationBarConfig,
|
||||||
|
XWebViewNavigationRenderContext,
|
||||||
|
XWebViewNavigationState,
|
||||||
|
XWebViewPageClosedResult,
|
||||||
XWebViewPermissionRequest,
|
XWebViewPermissionRequest,
|
||||||
DownloadHandle,
|
XWebViewPageHandle,
|
||||||
|
XWebViewPermissionsConfig,
|
||||||
|
XWebViewSource,
|
||||||
|
XWebViewStatusBarConfig,
|
||||||
} from '@xuqm/rn-xwebview'
|
} from '@xuqm/rn-xwebview'
|
||||||
|
|||||||
正在加载...
在新工单中引用
屏蔽一个用户