From 10977341d12e70d38bf387c89f3dcf5df4b07c68 Mon Sep 17 00:00:00 2001 From: XuqmGroup Date: Sun, 26 Jul 2026 19:41:03 +0800 Subject: [PATCH] =?UTF-8?q?feat(xwebview):=20=E6=94=B6=E6=95=9B=20WebView?= =?UTF-8?q?=20=E5=AE=BF=E4=B8=BB=E4=B8=8E=E6=A1=A5=E6=8E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/IMPLEMENTATION_HANDOFF.md | 56 +- docs/SDK-API参考.md | 4 +- docs/XWebView-JSBridge.md | 231 +----- packages/common/src/api/errors.ts | 3 +- packages/common/src/api/useApi.ts | 22 +- packages/common/src/index.ts | 24 - .../common/src/xwebview/XWebViewBridge.ts | 222 ----- packages/common/src/xwebview/types.ts | 99 --- packages/xwebview/README.md | 145 +++- packages/xwebview/package.json | 9 +- packages/xwebview/src/DownloadMetadata.ts | 5 +- packages/xwebview/src/XWebViewDownload.ts | 91 +- packages/xwebview/src/XWebViewHost.tsx | 403 +++++++++ packages/xwebview/src/XWebViewPermissions.ts | 104 ++- packages/xwebview/src/XWebViewScreen.tsx | 230 ------ packages/xwebview/src/XWebViewView.tsx | 779 +++++++++++------- packages/xwebview/src/index.ts | 45 +- packages/xwebview/src/linkPolicy.ts | 25 + packages/xwebview/src/runtime.ts | 237 ++++++ packages/xwebview/src/types.ts | 232 ++++++ packages/xwebview/tests/linkPolicy.test.ts | 22 + packages/xwebview/tests/runtime.test.ts | 56 ++ pnpm-lock.yaml | 144 +--- src/index.ts | 33 +- 24 files changed, 1764 insertions(+), 1457 deletions(-) delete mode 100644 packages/common/src/xwebview/XWebViewBridge.ts delete mode 100644 packages/common/src/xwebview/types.ts create mode 100644 packages/xwebview/src/XWebViewHost.tsx delete mode 100644 packages/xwebview/src/XWebViewScreen.tsx create mode 100644 packages/xwebview/src/linkPolicy.ts create mode 100644 packages/xwebview/src/runtime.ts create mode 100644 packages/xwebview/src/types.ts create mode 100644 packages/xwebview/tests/linkPolicy.test.ts create mode 100644 packages/xwebview/tests/runtime.test.ts diff --git a/docs/IMPLEMENTATION_HANDOFF.md b/docs/IMPLEMENTATION_HANDOFF.md index 6234415..e306ebb 100644 --- a/docs/IMPLEMENTATION_HANDOFF.md +++ b/docs/IMPLEMENTATION_HANDOFF.md @@ -1,6 +1,6 @@ # RN SDK 重构实施接管文档 -> 状态更新时间:2026-07-19 +> 状态更新时间:2026-07-26 > 当前实施范围:`@xuqm/rn-common`、`@xuqm/rn-bugcollect`、`@xuqm/rn-update`、`@xuqm/rn-xwebview` > 发布约束:所有 XuqmGroup npm/Maven 制品和服务部署只能通过 `https://jenkins.xuqinmin.com/` 的 Jenkins 完成。 @@ -13,6 +13,28 @@ 5. update SDK 统一承担插件 manifest、兼容性、原子激活、启动确认、崩溃回滚和内嵌恢复;宿主不得再实现平行版本管理器。 6. Android 整包更新必须具备应用内下载、SHA-256 校验、安装权限处理、进度、取消、重试和确定性错误码。 7. 宿主通过最少配置完成插件创建、开发运行、打包、随 APK 内嵌、发布和回滚。 +8. XWebView 收敛为 `XWebViewHost + openWebView` 的 SDK 自管页面栈;宿主不再注册路由或传 navigate 回调。 + +## 0. 2026-07-26 / XWebView 1.0 实施状态 + +- 已取得用户实施授权,目标为 `@xuqm/rn-xwebview@1.0.0`。 +- 旧 `openXWebView(navigate, config)`、common 内全局 `_config/_controller` 和公开 + `XWebViewScreen` 已删除,不保留转发兼容层;迁移规则已写入包 README。 +- 新公开入口固定为根部一次挂载 `XWebViewHost`、业务直接调用 + `openWebView(config)`,并返回页面级句柄和 `closed` Promise。 +- 配置改为 `source/navigationBar/statusBar/behavior/permissions/downloads/bridge` + 分组强类型结构;URL 与 HTML 互斥。 +- 页面栈、Host 等待/卸载、页面级动态导航、系统返回、外链、下载、权限和 Bridge + 异常已收口到 xwebview;common 中错误归属的 WebView 状态与业务 Bridge 已删除。 +- H5 媒体权限由页面白名单、可选宿主决策和 Android 系统权限三层控制;SDK 不向 + Manifest 注入摄像头或麦克风权限。 +- 下载自动复用 URI 首屏鉴权 headers;Bridge 原始 payload 不写日志。 +- `react-native-svg` 已收敛为 peer;宿主必须直接声明这一原生依赖以参与 autolink, + 禁止由 xwebview 私自安装第二份。App4 运行验收曾据此捕获并修复 + `RNSVGSvgViewAndroid` 未注册问题。 +- xwebview 类型检查和 7 项独立测试已通过。App4 在 Android 36.1 + `emulator-5554` 通过“移动报销”真实 H5 页面验证:页面打开、SDK 初始化、 + `userInfo`、右上角菜单和关闭返回均正常,窗口日志无 Java/React/Fabric FATAL。 ## 2. 已冻结的设计约束 @@ -41,7 +63,7 @@ - `pnpm validate`:通过。 - common:20 个测试通过。 - bugcollect:5 个独立测试通过。 -- xwebview:4 个独立测试通过。 +- xwebview:7 个独立测试通过。 - update CLI/Metro:11 个测试通过;release-set:6 个测试通过。 - IM 现有测试:3 个通过;IM 不在本轮开发范围。 - 所有 workspace TypeScript typecheck:通过。 @@ -73,17 +95,17 @@ ## 4. 实施进度 -| 工作项 | 状态 | 说明 | -| ----------------------------- | -------- | ------------------------------------------------------ | -| 基线审计与门禁 | 完成 | 2026-07-17 本地 validate 全通过 | -| 实时接管文档 | 进行中 | 本文件为唯一实施状态入口 | -| common 公共上下文收敛 | 完成 | 公共基础、共享生命周期与安全 API 诊断 19 测试 | -| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本、策略与 HTTP 脱敏已统一,5 测试 | -| xwebview 文件/权限能力与测试 | 完成 | 文件下沉 common,H5 摄像头/麦克风统一授权,4 测试通过 | -| update release set 与原生事务 | 进行中 | Android Snapshot、Bridge、App4 构建通过;待事务 E2E | -| package 内容校验 | 完成 | update 包 42 个发布文件已校验 | -| Jenkins alpha 发布 | 完成 | `#59/#60` 已发布 common、xwebview、update | -| App4 接入 | 完成构建 | 精确 alpha 已接入,六 Hermes 插件与 debug APK 构建通过 | +| 工作项 | 状态 | 说明 | +| ----------------------------- | ------ | ----------------------------------------------------- | +| 基线审计与门禁 | 完成 | 2026-07-17 本地 validate 全通过 | +| 实时接管文档 | 进行中 | 本文件为唯一实施状态入口 | +| common 公共上下文收敛 | 完成 | 公共基础、共享生命周期与安全 API 诊断 19 测试 | +| bugcollect 依赖 common 与测试 | 完成 | 自动初始化、SHA、版本、策略与 HTTP 脱敏已统一,5 测试 | +| xwebview 1.0 Host/权限/下载 | 完成 | 页面栈、分组配置、7 项测试及 App4 Android 运行通过 | +| update release set 与原生事务 | 进行中 | Android Snapshot、Bridge、App4 构建通过;待事务 E2E | +| package 内容校验 | 完成 | update 包 42 个发布文件已校验 | +| Jenkins alpha 发布 | 完成 | `#59/#60` 已发布 common、xwebview、update | +| App4 接入 | 完成 | V5 Host、8 模块、Debug APK 与真实 H5 Android 链路通过 | ## 5. 下一步操作 @@ -274,8 +296,12 @@ pnpm --dir packages/update test - 设备 DNS 失败复测发现 Android 系统英文错误页会在 `onError` 后再次触发加载事件,旧实现因此清空自定义错误状态;同时错误分支卸载 WebView,按钮持有的 ref 已为空,重试实际无效。 - `XWebViewView` 现在始终保留 WebView,并以白底中文错误层覆盖系统页;刷新、前进、后退和重试统一先清理错误状态再操作同一控制器。错误页不再由原生事件先后顺序随机决定。 -- 删除 `XWebViewScreen` 内 600 余行重复的 WebView、下载、Bridge、权限和错误处理。Screen 现在只组合统一导航栏、系统返回与 `XWebViewView`;全屏和内嵌场景共享同一个浏览器内核实现,不再修复一处、遗漏另一处。 -- xwebview 类型检查与 4 项下载/权限测试通过;RNSDK 全量 `pnpm validate` 同步通过。App4 已刷新相邻 `file:` 依赖并完成 457 tasks 构建:首次 DNS 失败与点击重试后都稳定显示统一中文错误层,截图为 `/tmp/app4-xwebview-error-unified.png`、`/tmp/app4-xwebview-retry-final.png`,日志无 Java/React FATAL。 +- 删除 `XWebViewScreen` 内 600 余行重复的 WebView、下载、Bridge、权限和错误处理, + 并最终删除该公开入口。全屏页面由 `XWebViewHost` 组合统一导航栏、系统返回与 + `XWebViewView`;全屏和内嵌场景共享同一个浏览器内核。 +- 当时的 xwebview 类型检查与 4 项下载/权限测试、RNSDK 全量门禁和 App4 457 tasks + 构建均通过;该历史结果不能替代 1.0.0 当前实现的最终验证。当前 1.0.0 已扩展为 + 7 项独立测试,Android 运行验证仍待执行。 ### 2026-07-20 / Android 内嵌 Bundle 启动短路 diff --git a/docs/SDK-API参考.md b/docs/SDK-API参考.md index 280fd51..4afafc6 100644 --- a/docs/SDK-API参考.md +++ b/docs/SDK-API参考.md @@ -46,7 +46,9 @@ await XuqmSDK.logout() ## 扩展包 - `@xuqm/rn-update`:`UpdateSDK`、`XuqmRuntime`、`definePlugin` 和 `xuqm-rn` CLI。 -- `@xuqm/rn-xwebview`:`XWebViewView`、`XWebViewScreen`、JSBridge 与统一下载。 +- `@xuqm/rn-xwebview`:根部一次挂载 `XWebViewHost`,业务使用 + `openWebView(config)` 打开全屏页面;`XWebViewView` 只用于内嵌浏览器。业务 + JSBridge 通过 `config.bridge` 注入,不属于 XWebView 自身协议。 - `@xuqm/rn-bugcollect`:`BugCollect` 错误、事件、漏斗和队列上报。 - `@xuqm/rn-im`:在 common 会话含 `userSig` 且服务启用时连接。 - `@xuqm/rn-push`:在 common 会话变化时同步原生推送绑定。 diff --git a/docs/XWebView-JSBridge.md b/docs/XWebView-JSBridge.md index eae5788..2469841 100644 --- a/docs/XWebView-JSBridge.md +++ b/docs/XWebView-JSBridge.md @@ -1,211 +1,52 @@ -# XWebView JSBridge 标准接口 +# XWebView Bridge 边界 -> 最后更新:2026-06-15 +> 最后更新:2026-07-26 ---- +XWebView 不是业务 JSBridge 协议。它只定义一个通用适配接口: -## 概述 - -XWebView 内置 JSBridge,支持 H5 页面与 React Native 宿主之间的双向通信。 - -**通信机制**: -- H5 → RN:`window.ReactNativeWebView.postMessage(JSON.stringify(data))` -- RN → H5:`webViewRef.injectJavaScript(jsString)` - ---- - -## 一、H5 → RN 消息协议 - -### 1.1 内置消息(__xwv 前缀) - -XWebView 自动拦截以下浏览器行为,通过 `postMessage` 发送给 RN: - -| 消息类型 | 触发场景 | 数据格式 | -|---------|---------|---------| -| `alert` | `window.alert(msg)` | `{__xwv: 'alert', msg: string}` | -| `confirm` | `window.confirm(msg)` | `{__xwv: 'confirm', msg: string}` | -| `prompt` | `window.prompt(msg, def)` | `{__xwv: 'prompt', msg: string, def: string}` | -| `download` | 点击下载链接 | `{__xwv: 'download', url: string, filename: string}` | -| `blobdownload` | blob URL 下载 | `{__xwv: 'blobdownload', url: string, filename: string, data: base64}` | -| `bloberror` | blob 读取失败 | `{__xwv: 'bloberror', msg: string}` | -| `log` | 调试日志 | `{__xwv: 'log', msg: string}` | - -### 1.2 自定义消息 - -H5 发送非 `__xwv` 前缀的消息时,XWebView 将其转发给 `onMessage` 回调: - -```javascript -// H5 端 -window.ReactNativeWebView.postMessage(JSON.stringify({ - type: 'customEvent', - payload: { key: 'value' } -})); +```ts +type XWebViewBridge = { + injectedJavaScriptBeforeContentLoaded?: string + onMessage(raw: string, context: XWebViewBridgePageContext): boolean | Promise + onPageClosed?(pageId: string): void +} ``` -```typescript -// RN 端 - { - const data = JSON.parse(event.nativeEvent.data); - if (data.type === 'customEvent') { - // 处理自定义消息 - } - }} -/> -``` +## 页面上下文 ---- +业务协议实现可以: -## 二、RN → H5 通信 +- 关闭当前页; +- 向当前页执行 JavaScript; +- 判断当前页是否为页面栈顶; +- 取得真实状态栏高度; +- 修改当前页导航栏标题/背景; +- 修改当前页导航栏与状态栏显隐。 -### 2.1 注入 JavaScript +协议实现不能: -```typescript -// 通过 controller API -controller.postMessageToWeb('window.dispatchEvent(new CustomEvent("rnMessage", {detail: {type: "update", data: "hello"}}))'); +- 访问或修改其他 WebView 页面; +- 从 XWebView 读取用户、token、平台、域名或厂商; +- 直接控制宿主导航器; +- 输出原始 Bridge payload; +- 在 common 中注册全局业务 handler。 -// 通过 ref -webViewRef.injectJavaScript('window.handleRNMessage("hello")'); -``` +## 消息所有权 -### 2.2 Controller API +XWebView 内部只消费 `__xwv` 命名空间: -通过 `setXWebViewController` 获取的 controller 对象提供以下方法: +- `download` / `blobdownload` / `bloberror` +- `permission` -| 方法 | 说明 | -|------|------| -| `refresh()` | 刷新当前页面 | -| `close()` | 关闭 WebView | -| `goBack()` | 返回上一页 | -| `goForward()` | 前进 | -| `copyUrl()` | 复制当前 URL 到剪贴板 | -| `postMessageToWeb(jsString)` | 向 H5 注入并执行 JS | -| `getTitle()` | 获取当前页面标题 | +其他消息先交给 `config.bridge`。Bridge 返回 `false` 时才继续交给 +`behavior.onMessage`。Bridge 抛错由 `behavior.onBridgeError` 接收,不产生未处理 +Promise。 ---- +## 业务协议接入 -## 三、下载处理 +业务协议必须在自己的 SDK/插件中维护唯一方法表、请求解析、响应结构、错误码和兼容 +测试,然后以 `XWebViewBridge` 适配到容器。XWebView 不提供 `getSession`、`navigate`、 +`showToast`、签名、支付或任何假定宿主业务的内置方法。 -### 3.1 自动下载拦截 - -XWebView 自动拦截以下文件扩展名的链接点击: - -``` -.pdf .zip .rar .tar .gz .apk .ipa .doc .docx .xls .xlsx .ppt .pptx .mp4 .mp3 .mov .exe .dmg -``` - -### 3.2 下载回调 - -```typescript - { - // 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. 拦截 `` 点击 -2. 通过 `FileReader` 读取 blob 数据为 base64 -3. 通过 `saveBase64File` 保存到本地 - ---- - -## 四、内置 Dialog 覆盖 - -XWebView 注入 `DIALOG_OVERRIDE_JS`,覆盖浏览器原生对话框: - -| 原生方法 | 覆盖行为 | -|---------|---------| -| `window.alert(msg)` | 发送 `{__xwv: 'alert', msg}` 消息 | -| `window.confirm(msg)` | 发送 `{__xwv: 'confirm', msg}` 消息,始终返回 `true` | -| `window.prompt(msg, def)` | 发送 `{__xwv: 'prompt', msg, def}` 消息,返回默认值 | -| `window.open(url)` | 发送日志消息,然后 `window.location.href = url` | - -RN 端收到这些消息后,可以展示原生 Alert/Confirm/Prompt 对话框。 - ---- - -## 五、标准 Bridge 接口(宿主实现) - -以下接口由宿主应用通过 `bridgeHandlers` 实现,H5 通过 `xuqm.call(method, params)` 调用: - -### 5.1 会话管理 - -| 接口 | 参数 | 返回值 | 说明 | -|------|------|--------|------| -| `getSession` | — | `{token, userId}` | 获取当前登录会话 | -| `getUserInfo` | — | `{userId, name, phone, avatar}` | 获取用户信息 | - -### 5.2 导航 - -| 接口 | 参数 | 返回值 | 说明 | -|------|------|--------|------| -| `navigate` | `{screen, params?}` | — | 跳转宿主页面 | -| `close` | — | — | 关闭当前 WebView | -| `goBack` | — | — | 返回上一页 | - -### 5.2 UI 交互 - -| 接口 | 参数 | 返回值 | 说明 | -|------|------|--------|------| -| `showToast` | `{message}` | — | 显示 Toast | -| `showAlert` | `{title?, message}` | — | 显示 Alert 对话框 | -| `showConfirm` | `{title?, message, confirmText?, cancelText?}` | `{confirmed: boolean}` | 显示确认对话框 | - -### 5.3 文件操作 - -| 接口 | 参数 | 返回值 | 说明 | -|------|------|--------|------| -| `uploadFile` | `{accept?}` | `{url, name, size}` | 选择并上传文件 | -| `openCamera` | — | `{url, base64}` | 打开相机拍照 | -| `scanQR` | — | `{result}` | 扫描二维码 | - -### 5.4 设备信息 - -| 接口 | 参数 | 返回值 | 说明 | -|------|------|--------|------| -| `getDeviceInfo` | — | `{platform, version, model}` | 获取设备信息 | -| `getLocation` | — | `{latitude, longitude}` | 获取当前位置 | - ---- - -## 六、H5 端调用示例 - -```javascript -// 获取会话 -xuqm.call('getSession').then(session => { - console.log('Token:', session.token); -}); - -// 跳转宿主页面 -xuqm.call('navigate', { screen: 'Settings' }); - -// 显示 Toast -xuqm.call('showToast', { message: '操作成功' }); - -// 上传文件 -xuqm.call('uploadFile', { accept: 'image/*' }).then(file => { - console.log('Uploaded:', file.url); -}); -``` +医网信 H5 场景使用 `@szyx/sdk-h5-app/host`;其 V5 线协议不属于 +`@xuqm/rn-xwebview` 的公开契约。 diff --git a/packages/common/src/api/errors.ts b/packages/common/src/api/errors.ts index dcf2f7e..a4dd85a 100644 --- a/packages/common/src/api/errors.ts +++ b/packages/common/src/api/errors.ts @@ -1,7 +1,8 @@ export class RequestError extends Error { constructor( message: string, - public readonly type: 'Cancel' | 'ValidationError' | 'AxiosError' | 'OtherError', + public readonly type: + 'Cancel' | 'ValidationError' | 'BusinessError' | 'AxiosError' | 'OtherError', public readonly cause?: unknown, public readonly response?: { data?: TData; status?: number }, ) { diff --git a/packages/common/src/api/useApi.ts b/packages/common/src/api/useApi.ts index 9aec1ac..4081221 100644 --- a/packages/common/src/api/useApi.ts +++ b/packages/common/src/api/useApi.ts @@ -16,7 +16,7 @@ import { import { RequestError } from './errors' import { type RequestOptions, useRequest, ValidationError } from './useRequest' -const SUCCESS_STATUS = /^0$/ +const SUCCESS_STATUS = '0' type ApiResponse = { status: string @@ -43,12 +43,14 @@ export const useApi = = z.infer requestInterceptors: AxiosInterceptorManager> responseInterceptors: AxiosInterceptorManager, unknown>> } => { + // schema 只校验响应结构,不强制 status==='0'; + // 业务状态码非 '0' 属于正常业务响应,由 transformedFetchAsync 统一处理。 const responseSchema = useMemo( () => z.object({ - status: z.string().regex(SUCCESS_STATUS), + status: z.string(), message: z.string(), - data: validationSchema ?? z.unknown(), + data: (validationSchema ?? z.unknown()).optional(), }), [validationSchema], ) @@ -107,12 +109,6 @@ export const useApi = = z.infer } const firstIssue = e.issues[0] - if (firstIssue.path.length > 0 && firstIssue.path[0] === 'status') { - return Promise.reject( - new RequestError(e.response.data?.message ?? '接口返回失败', 'ValidationError', e), - ) - } - return Promise.reject(new RequestError(firstIssue.message, 'ValidationError', e)) } @@ -143,7 +139,13 @@ export const useApi = = z.infer async (nextConfig?: AxiosRequestConfig) => { try { const requestResponse = await fetchAsync(nextConfig) - return (requestResponse as unknown as AxiosResponse>).data.data + const body = (requestResponse as unknown as AxiosResponse>).data + // 业务状态码非 '0' 时,抛出 RequestError 而非 ValidationError, + // 避免业务错误被误报为"校验失败"并影响调用方。 + if (body.status !== SUCCESS_STATUS) { + throw new RequestError(body.message || '接口返回失败', 'BusinessError', body) + } + return body.data } catch (requestError) { return Promise.reject(requestError) } diff --git a/packages/common/src/index.ts b/packages/common/src/index.ts index 38e6685..ea4b0a4 100644 --- a/packages/common/src/index.ts +++ b/packages/common/src/index.ts @@ -52,30 +52,6 @@ export { satisfiesVersion, } from './version' export { ScaledImage } from './components/ScaledImage' -export { - XWebViewControl, - getXWebViewConfig, - openXWebView, - setXWebViewController, - setXWebViewNavigationRef, - setXWebViewScanQRCodeHandler, - processJSBridgeMessage, -} from './xwebview/XWebViewBridge' -export type { XWebViewNavigationRef, ScanQRCodeHandler } from './xwebview/XWebViewBridge' -export type { - XWebViewClickMenu, - XWebViewConfig, - XWebViewControllerAPI, - XWebViewNavigationState, - XWebViewDownloadDecision, - XWebViewDownloadDestination, - XWebViewDownloadProgress, - XWebViewDownloadRequest, - XWebViewDownloadResult, - XWebViewMessageEvent, - XWebViewPermissionRequest, -} from './xwebview/types' - // api/ 子模块 export { RequestError, diff --git a/packages/common/src/xwebview/XWebViewBridge.ts b/packages/common/src/xwebview/XWebViewBridge.ts deleted file mode 100644 index 707dc7a..0000000 --- a/packages/common/src/xwebview/XWebViewBridge.ts +++ /dev/null @@ -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) => 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 - -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 { - let parsed: { - __xuqm?: string - id?: string | number - params?: Record - } - - 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 | 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: '', - }, -} diff --git a/packages/common/src/xwebview/types.ts b/packages/common/src/xwebview/types.ts deleted file mode 100644 index 0ceb64d..0000000 --- a/packages/common/src/xwebview/types.ts +++ /dev/null @@ -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 - 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 - downloadConflict?: 'rename' | 'overwrite' - /** 下载保存位置;与原生 Android SDK FileDownloadDestination 对齐。 */ - downloadDestination?: XWebViewDownloadDestination - /** 非空时显示状态栏下载进度通知。 */ - downloadNotificationTitle?: string - onClose?: () => void -} diff --git a/packages/xwebview/README.md b/packages/xwebview/README.md index 62bbdca..c0ea86d 100644 --- a/packages/xwebview/README.md +++ b/packages/xwebview/README.md @@ -1,53 +1,122 @@ # @xuqm/rn-xwebview -`XWebViewView` 是 WebView、Bridge、下载、权限和加载错误的唯一内核;`XWebViewScreen` 只组合全屏导航栏与系统返回。两者不得分别维护请求、下载、权限或错误状态机。 +通用 React Native WebView 容器。`1.0.0` 只有三项正式入口: -## 摄像头与麦克风权限 +- ``:应用根部挂载一次的全屏页面 Host。 +- `openWebView(config)`:从任意业务服务打开页面,返回该页面自己的 Handle。 +- ``:不需要全屏导航栏时使用的内嵌浏览器内核。 -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 ( + <> + + + + ) +} + +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 ``` -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. 根部挂载一次 ``。 +2. 扁平参数转换为上述分组配置。 +3. 全局 Control 改为保存本次 `openWebView` 返回的页面 Handle。 +4. 业务 Bridge 单独作为 `config.bridge` 注入,不进入 common 或 XWebView。 +5. 内嵌页面直接使用 ``,不再直接包装 `react-native-webview`。 + +## 验证 ```bash -pnpm add @xuqm/rn-common @xuqm/rn-xwebview \ - react-native-webview react-native-blob-util react-native-svg +pnpm --dir packages/xwebview typecheck +pnpm --dir packages/xwebview test ``` - -## 使用 - -```tsx -import { XWebViewView, openXWebView } from '@xuqm/rn-xwebview' - -openXWebView(() => navigation.navigate('XWebView'), { - title: '页面', - url: 'https://example.com', - onNavigationStateChange(state) { - console.log(state.canGoBack, state.canGoForward, state.url) - }, -}) - -; -``` - -全屏和内嵌容器都从同一 `XWebViewConfig.onNavigationStateChange` 返回实时导航状态。命令式场景可调用 `XWebViewControl.getNavigationState()`;业务层不得自行轮询标题或维护第二份 URL/前进后退状态。 - -下载统一使用 `handleDownloadRequest`、`startDownload` 或 `saveBase64File`。目录解析、RFC 文件名、冲突策略、MIME 推断、大文件落盘、进度、取消、Android 下载登记和打开文件均复用 common,不在 xwebview 或业务层重复实现。 - -H5 需要原生能力时,通过 `setXWebViewScanQRCodeHandler` 等全局 handler 接入。用户、token、平台地址不得作为页面 props 逐级传递;Bridge 在调用时读取 common 当前会话。 diff --git a/packages/xwebview/package.json b/packages/xwebview/package.json index 7eabbfc..fd9c25a 100644 --- a/packages/xwebview/package.json +++ b/packages/xwebview/package.json @@ -1,6 +1,6 @@ { "name": "@xuqm/rn-xwebview", - "version": "0.3.0-alpha.2", + "version": "1.0.0", "description": "XuqmGroup RN SDK — XWebView module", "license": "UNLICENSED", "main": "src/index.ts", @@ -18,17 +18,16 @@ "typecheck": "tsc --noEmit -p tsconfig.json" }, "dependencies": { - "react-native-svg": "^15.15.4", "react-native-webview": "^14.0.1" }, "peerDependencies": { + "@xuqm/rn-common": "workspace:>=0.6.0-alpha.9 <1.0.0", "react": ">=18.0.0", "react-native": ">=0.76.0", "@react-native-camera-roll/camera-roll": ">=7.0.0", "@react-native-clipboard/clipboard": ">=1.16.0", - "@react-navigation/native": ">=7.0.0", "react-native-safe-area-context": ">=4.0.0", - "@xuqm/rn-common": "workspace:>=0.6.0-alpha.4" + "react-native-svg": ">=15.0.0" }, "peerDependenciesMeta": { "@react-native-camera-roll/camera-roll": { @@ -37,9 +36,9 @@ }, "devDependencies": { "@types/react": "^19.0.0", - "@react-navigation/native": "^7.0.0", "@react-native-clipboard/clipboard": "^1.16.3", "react-native-safe-area-context": "^5.4.0", + "react-native-svg": "^15.15.4", "tsx": "^4.23.1", "typescript": "catalog:" } diff --git a/packages/xwebview/src/DownloadMetadata.ts b/packages/xwebview/src/DownloadMetadata.ts index 3ee4376..96dcf62 100644 --- a/packages/xwebview/src/DownloadMetadata.ts +++ b/packages/xwebview/src/DownloadMetadata.ts @@ -1,4 +1,4 @@ -import type { XWebViewDownloadRequest } from '@xuqm/rn-common' +import type { XWebViewDownloadRequest } from './types' import { fileNameFromUrl, parseContentDispositionFileName, @@ -14,10 +14,11 @@ function normalizedSize(value: string | null): number | undefined { export async function fetchDownloadInfo( url: string, hintFilename?: string, + headers?: Record, ): Promise { const hint = hintFilename?.trim() try { - const response = await fetch(url, { method: 'HEAD' }) + const response = await fetch(url, { headers, method: 'HEAD' }) const disposition = response.headers.get('content-disposition') ?? '' const mimeType = response.headers.get('content-type')?.split(';')[0].trim() || undefined const suggestedFilename = diff --git a/packages/xwebview/src/XWebViewDownload.ts b/packages/xwebview/src/XWebViewDownload.ts index 80d50c1..e353335 100644 --- a/packages/xwebview/src/XWebViewDownload.ts +++ b/packages/xwebview/src/XWebViewDownload.ts @@ -1,22 +1,18 @@ import { Platform } from 'react-native' import type { - XWebViewDownloadDecision, XWebViewDownloadDestination, XWebViewDownloadProgress, - XWebViewDownloadRequest, XWebViewDownloadResult, -} from '@xuqm/rn-common' +} from './types' import { downloadFileToPath, getFileDirectories, inferMimeType, - openLocalFile, registerAndroidDownloadedFile, resolveAvailableFilePath, writeBase64File, } from '@xuqm/rn-common' -import { fetchDownloadInfo } from './DownloadMetadata' export { fetchDownloadInfo } from './DownloadMetadata' @@ -50,18 +46,6 @@ async function saveToGallery(filePath: string): Promise { } } -/** - * 用系统查看器打开已下载文件(Android: actionViewIntent;iOS: openDocument)。 - * best-effort,失败不抛出。 - */ -export async function openFile(filePath: string, filenameOrMime?: string): Promise { - const mime = - filenameOrMime && filenameOrMime.includes('/') - ? filenameOrMime - : inferMimeType(filenameOrMime ?? filePath) - return openLocalFile(filePath, mime) -} - export async function saveBase64File( base64: string, filename: string, @@ -105,73 +89,7 @@ export async function saveBase64File( return filePath } -export type DownloadHandle = { cancel: () => void } - -/** - * 统一的下载请求处理函数。 - * - * 封装 fetchDownloadInfo → onDownloadDecide → startDownload 的完整流程, - * 供 XWebViewScreen / XWebViewView 及外部调用方使用。 - * - * @returns DownloadHandle(可取消),若用户拒绝或无需下载则返回 null - */ -export async function handleDownloadRequest( - url: string, - hintFilename: string | undefined, - options: { - autoDownload?: boolean - downloadConflict?: 'rename' | 'overwrite' - savePath?: string - downloadDestination?: XWebViewDownloadDestination - downloadNotificationTitle?: string - onDownloadDecide?: ( - request: XWebViewDownloadRequest, - ) => XWebViewDownloadDecision | Promise - onDownloadStart?: (request: XWebViewDownloadRequest) => void - onDownloadProgress?: (progress: XWebViewDownloadProgress) => void - onDownloadComplete?: (result: XWebViewDownloadResult) => void - onDownloadError?: (url: string, error: string) => void - } = {}, -): Promise { - const { - autoDownload = true, - downloadConflict = 'rename', - savePath, - downloadDestination = 'sandbox', - downloadNotificationTitle, - onDownloadDecide, - onDownloadStart, - onDownloadProgress, - onDownloadComplete, - onDownloadError, - } = options - - const info = await fetchDownloadInfo(url, hintFilename) - let finalFilename = info.suggestedFilename - let finalSavePath = savePath - - // 非自动下载模式:需要宿主决定是否允许 - if (!autoDownload) { - if (!onDownloadDecide) return null - const decision = await Promise.resolve(onDownloadDecide(info)) - if (!decision.allowed) return null - if (decision.filename) finalFilename = decision.filename - if (decision.savePath) finalSavePath = decision.savePath - } - - onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) - - return startDownload( - url, - finalFilename, - finalSavePath, - downloadConflict, - p => onDownloadProgress?.(p), - r => onDownloadComplete?.(r), - err => onDownloadError?.(url, err), - { destination: downloadDestination, notificationTitle: downloadNotificationTitle }, - ) -} +type DownloadHandle = { cancel: () => void } export function startDownload( url: string, @@ -183,10 +101,11 @@ export function startDownload( onError: (error: string) => void, options: { destination?: XWebViewDownloadDestination + headers?: Record notificationTitle?: string } = {}, ): DownloadHandle { - const { destination = 'sandbox', notificationTitle } = options + const { destination = 'sandbox', headers, notificationTitle } = options const dirs = getFileDirectories() // 公共 Downloads(Android):下载到沙盒(保留 JS 进度回调),完成后通过 DownloadManager @@ -202,6 +121,7 @@ export function startDownload( .then(cachePath => { if (cancelled) return const task = downloadFileToPath(url, cachePath, { + headers, onProgress: progress => { onProgress({ url, @@ -259,6 +179,7 @@ export function startDownload( if (cancelled) return const task = downloadFileToPath(url, filePath, { + headers, onProgress: progress => { onProgress({ url, diff --git a/packages/xwebview/src/XWebViewHost.tsx b/packages/xwebview/src/XWebViewHost.tsx new file mode 100644 index 0000000..f9e2cac --- /dev/null +++ b/packages/xwebview/src/XWebViewHost.tsx @@ -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 ? ( + + + + {background.gradient.map((color, index, values) => ( + + ))} + + + + + ) : null} + {children} + + ) + + if (background.image) { + return ( + + {content} + + ) + } + + return ( + + {content} + + ) +} + +function DefaultMenu({ + items, + onDismiss, + topOffset, + visible, +}: { + items: Array<{ + id: string + icon?: React.ReactNode + label: string + onPress(): void | Promise + }> + onDismiss(): void + topOffset: number + visible: boolean +}) { + return ( + + + + + {items.map(item => ( + { + onDismiss() + void item.onPress() + }} + style={styles.menuItem} + > + {item.icon} + {item.label} + + ))} + + + + ) +} + +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( + () => + 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 ( + + + + + {backButton.visible ? ( + { + if (navigationState.canGoBack) handle.goBack() + else handle.close('back') + }} + style={styles.navigationButton} + > + + + ) : null} + + + + {presentation.titleText} + + + + {(navigationBar.menu?.visible ?? true) ? ( + setMenuVisible(true)} + style={styles.navigationButton} + > + + + ) : null} + {closeButton.visible ? ( + handle.close('api')} + style={styles.navigationButton} + > + + + ) : null} + + + ({ + ...item, + onPress: async () => item.onPress(menuContext), + }))} + onDismiss={() => setMenuVisible(false)} + topOffset={topInset + NAVIGATION_HEIGHT} + visible={menuVisible} + /> + + ) +} + +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 ( + + {active ? ( + + ) +} + +/** + * 全局 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 ( + 0} + > + + + {snapshot.pages.map((page, index) => ( + + ))} + + + + ) +} + +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', + }, +}) diff --git a/packages/xwebview/src/XWebViewPermissions.ts b/packages/xwebview/src/XWebViewPermissions.ts index 8c6b573..9e34b04 100644 --- a/packages/xwebview/src/XWebViewPermissions.ts +++ b/packages/xwebview/src/XWebViewPermissions.ts @@ -1,80 +1,68 @@ import { useCallback } from 'react' import { PermissionsAndroid, Platform } from 'react-native' -import type { XWebViewPermissionRequest } from '@xuqm/rn-common' - -import { - allowedWebPermissionResources, - webPermissionKind, - type WebPermissionKind, -} from './permissionPolicy' - -type NativePermissionRequest = { - nativeEvent: { - origin: string - resources: string[] - grant: (resources: string[]) => void - deny: () => void - } -} +import type { XWebViewPermissionRequest, XWebViewPermissionsConfig } from './types' +import type { WebPermissionKind } from './permissionPolicy' const ANDROID_PERMISSIONS = { camera: PermissionsAndroid.PERMISSIONS.CAMERA, microphone: PermissionsAndroid.PERMISSIONS.RECORD_AUDIO, } as const -async function requestAndroidPermissions( - resources: string[], -): Promise> { - const requestedKinds = new Set() - for (const resource of resources) { - const kind = webPermissionKind(resource) - if (kind) requestedKinds.add(kind) +function requestFromHost( + config: XWebViewPermissionsConfig, + origin: string, + resources: WebPermissionKind[], +): Promise { + if (!config.onRequest) { + return Promise.resolve(resources.every(resource => config[resource] === true)) } - if (requestedKinds.size === 0) return requestedKinds + return new Promise(resolve => { + let settled = false + let timer: ReturnType + 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 { + const uniqueResources = [...new Set(resources)] const results = await PermissionsAndroid.requestMultiple( - [...requestedKinds].map(kind => ANDROID_PERMISSIONS[kind]), + uniqueResources.map(resource => ANDROID_PERMISSIONS[resource]), ) - return new Set( - [...requestedKinds].filter( - kind => results[ANDROID_PERMISSIONS[kind]] === PermissionsAndroid.RESULTS.GRANTED, - ), + return uniqueResources.every( + resource => results[ANDROID_PERMISSIONS[resource]] === PermissionsAndroid.RESULTS.GRANTED, ) } /** - * XWebView 唯一权限处理器:宿主提供回调时完全交由宿主决定;否则使用安全默认策略。 + * 页面只有同时通过宿主白名单与系统权限,才会继续调用 WebView 的 getUserMedia。 + * SDK 本身不向 Manifest 注入敏感权限,宿主仍需按实际启用能力声明。 */ -export function useXWebViewPermissionRequest( - onPermissionRequest?: (request: XWebViewPermissionRequest) => void, -) { +export function useXWebViewPermissionDecision(config: XWebViewPermissionsConfig = {}) { return useCallback( - (request: NativePermissionRequest) => { - const { nativeEvent } = request - if (onPermissionRequest) { - onPermissionRequest({ - deny: nativeEvent.deny, - grant: resources => nativeEvent.grant(resources ?? nativeEvent.resources), - origin: nativeEvent.origin, - resources: nativeEvent.resources, - }) - return - } - - if (Platform.OS !== 'android') { - nativeEvent.grant(nativeEvent.resources) - return - } - - void requestAndroidPermissions(nativeEvent.resources) - .then(grantedKinds => { - const allowed = allowedWebPermissionResources(nativeEvent.resources, grantedKinds) - if (allowed.length > 0) nativeEvent.grant(allowed) - else nativeEvent.deny() - }) - .catch(nativeEvent.deny) + async (origin: string, resources: WebPermissionKind[]) => { + if (resources.length === 0) return false + if (!(await requestFromHost(config, origin, resources))) return false + if (Platform.OS !== 'android') return true + return requestAndroidPermissions(resources) }, - [onPermissionRequest], + [config], ) } diff --git a/packages/xwebview/src/XWebViewScreen.tsx b/packages/xwebview/src/XWebViewScreen.tsx deleted file mode 100644 index f1259e1..0000000 --- a/packages/xwebview/src/XWebViewScreen.tsx +++ /dev/null @@ -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 ( - - {canGoBack ? ( - - - - ) : null} - - - - - ) -} - -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 ( - - {config.clickMenu.view ?? } - - ) - } - return ( - - - - ) -} - -/** - * 带统一导航栏的全屏容器。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({ - 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 ( - - - ) -} - -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 }, -}) diff --git a/packages/xwebview/src/XWebViewView.tsx b/packages/xwebview/src/XWebViewView.tsx index e77c1dc..044ec6e 100644 --- a/packages/xwebview/src/XWebViewView.tsx +++ b/packages/xwebview/src/XWebViewView.tsx @@ -1,7 +1,6 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react' -import { Alert, Platform, Pressable, StatusBar, StyleSheet, Text, View } from 'react-native' +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { Image, Linking, Pressable, StyleSheet, Text, View } from 'react-native' import Clipboard from '@react-native-clipboard/clipboard' -import { SafeAreaView } from 'react-native-safe-area-context' import WebView from 'react-native-webview' import type { ShouldStartLoadRequest, @@ -12,178 +11,221 @@ import type { WebViewProgressEvent, } from 'react-native-webview/lib/WebViewTypes' -import { getXWebViewConfig, setXWebViewController, type XWebViewConfig } from '@xuqm/rn-common' - -import XWebViewProgress from './XWebViewProgress' +import { resolveLinkPolicy } from './linkPolicy' +import { + closePageById, + isActivePage, + registerPageController, + updatePageNavigation, + updatePageNavigationState, + updatePageWindow, +} from './runtime' +import type { + XWebViewBridgePageContext, + XWebViewConfig, + XWebViewController, + XWebViewNavigationState, + XWebViewSource, +} from './types' import { fetchDownloadInfo, saveBase64File, startDownload } from './XWebViewDownload' -import { useXWebViewPermissionRequest } from './XWebViewPermissions' +import { useXWebViewPermissionDecision } from './XWebViewPermissions' +import XWebViewProgress from './XWebViewProgress' + const WebViewAny = WebView as unknown as React.ComponentType -const DOWNLOAD_EXTENSIONS = [ - '.pdf', - '.zip', - '.rar', - '.tar', - '.gz', - '.apk', - '.ipa', - '.doc', - '.docx', - '.xls', - '.xlsx', - '.ppt', - '.pptx', - '.mp4', - '.mp3', - '.mov', - '.exe', - '.dmg', -] - -function isDownloadUrl(url: string): boolean { - const lower = url.toLowerCase().split('?')[0] - return DOWNLOAD_EXTENSIONS.some(ext => lower.endsWith(ext)) -} - const DOWNLOAD_EXT_RE = /\.(exe|apk|ipa|zip|rar|tar|gz|dmg|pkg|deb|rpm|msi|pdf|doc|docx|xls|xlsx|ppt|pptx|mp4|mp3|mov|avi|mkv)(\?|#|$)/i -const DIALOG_OVERRIDE_JS = ` +const DOWNLOAD_INTERCEPT_SCRIPT = ` (function() { - function post(obj) { - window.ReactNativeWebView.postMessage(JSON.stringify(obj)); + function post(value) { + if (window.ReactNativeWebView && + typeof window.ReactNativeWebView.postMessage === 'function') { + window.ReactNativeWebView.postMessage(JSON.stringify(value)); + } } - window.alert = function(msg) { post({ __xwv: 'alert', msg: String(msg) }); }; - window.confirm = function(msg) { post({ __xwv: 'confirm', msg: String(msg) }); return true; }; - window.prompt = function(msg, def) { post({ __xwv: 'prompt', msg: String(msg), def: def || '' }); return def || ''; }; - - window.open = function(url) { - post({ __xwv: 'log', msg: '[XWV] window.open: ' + url }); - if (url) { window.location.href = url; } - return null; - }; - - URL.revokeObjectURL = function() {}; - - function readBlobAndPost(blobUrl, filename) { + function readBlob(blobUrl, filename) { fetch(blobUrl) - .then(function(r) { return r.blob(); }) + .then(function(response) { return response.blob(); }) .then(function(blob) { var reader = new FileReader(); reader.onloadend = function() { - var b64 = reader.result.split(',')[1]; - post({ __xwv: 'blobdownload', url: blobUrl, filename: filename, data: b64 }); + var value = String(reader.result || ''); + var comma = value.indexOf(','); + post({ + __xwv: 'blobdownload', + data: comma >= 0 ? value.slice(comma + 1) : value, + filename: filename || 'download', + url: blobUrl + }); }; reader.readAsDataURL(blob); }) - .catch(function(err) { - post({ __xwv: 'bloberror', msg: String(err) }); + .catch(function() { + post({ __xwv: 'bloberror', url: blobUrl }); }); } - var DL_RE = ${JSON.stringify(DOWNLOAD_EXT_RE.source)}; - var dlRe = new RegExp(DL_RE, 'i'); + var downloadPattern = new RegExp(${JSON.stringify(DOWNLOAD_EXT_RE.source)}, 'i'); - function tryInterceptAnchor(el, e) { - if (!el || el.tagName !== 'A') return false; - var href = el.href || el.getAttribute('href') || ''; - if (!href || href.indexOf('javascript') === 0) return false; - var hasDownloadAttr = el.hasAttribute('download'); - var dlName = el.getAttribute('download') || ''; - var isDL = hasDownloadAttr || dlRe.test(href); - post({ __xwv: 'log', msg: '[XWV] anchor href=' + href + ' download=' + hasDownloadAttr + ' dlName=' + dlName + ' isDL=' + isDL }); - if (isDL) { - if (e) { e.preventDefault(); e.stopPropagation(); } - if (href.startsWith('blob:')) { - readBlobAndPost(href, dlName); - } else { - post({ __xwv: 'download', url: href, filename: dlName }); - } - return true; + function intercept(anchor, event) { + if (!anchor || anchor.tagName !== 'A') return false; + var href = anchor.href || anchor.getAttribute('href') || ''; + if (!href || href.indexOf('javascript:') === 0) return false; + var filename = anchor.getAttribute('download') || ''; + var download = anchor.hasAttribute('download') || + downloadPattern.test(href); + if (!download) return false; + if (event) { + event.preventDefault(); + event.stopPropagation(); } - return false; + if (href.indexOf('blob:') === 0) { + readBlob(href, filename); + } else { + post({ __xwv: 'download', filename: filename, url: href }); + } + return true; } - document.addEventListener('click', function(e) { - var el = e.target; - while (el && el.tagName !== 'A') { el = el.parentElement; } - tryInterceptAnchor(el, e); + document.addEventListener('click', function(event) { + var element = event.target; + while (element && element.tagName !== 'A') { + element = element.parentElement; + } + intercept(element, event); }, true); - var _origClick = HTMLAnchorElement.prototype.click; + var originalClick = HTMLAnchorElement.prototype.click; HTMLAnchorElement.prototype.click = function() { - post({ __xwv: 'log', msg: '[XWV] HTMLAnchorElement.click() href=' + this.href }); - if (!tryInterceptAnchor(this, null)) { - _origClick.call(this); - } + if (!intercept(this, null)) originalClick.call(this); }; - - post({ __xwv: 'log', msg: '[XWV] injected scripts ready' }); })(); true; -` +`.trim() +const MEDIA_PERMISSION_SCRIPT = ` +(function() { + var mediaDevices = navigator.mediaDevices; + if (!mediaDevices || typeof mediaDevices.getUserMedia !== 'function' || + mediaDevices.__xwvPermissionWrapped) return; + + var originalGetUserMedia = mediaDevices.getUserMedia.bind(mediaDevices); + var pending = {}; + var sequence = 0; + + window.__XWV_PERMISSION_REPLY = function(id, granted) { + var item = pending[id]; + if (!item) return; + delete pending[id]; + if (!granted) { + item.reject(new DOMException('Permission denied by host', 'NotAllowedError')); + return; + } + originalGetUserMedia(item.constraints).then(item.resolve, item.reject); + }; + + mediaDevices.getUserMedia = function(constraints) { + var resources = []; + if (constraints && constraints.video) resources.push('camera'); + if (constraints && constraints.audio) resources.push('microphone'); + if (resources.length === 0) return originalGetUserMedia(constraints); + + sequence += 1; + var id = 'xwv-permission-' + Date.now() + '-' + sequence; + return new Promise(function(resolve, reject) { + pending[id] = { constraints: constraints, reject: reject, resolve: resolve }; + window.ReactNativeWebView.postMessage(JSON.stringify({ + __xwv: 'permission', + id: id, + origin: location.origin, + resources: resources + })); + }); + }; + mediaDevices.__xwvPermissionWrapped = true; +})(); +true; +`.trim() + +function isDownloadUrl(url: string): boolean { + return DOWNLOAD_EXT_RE.test(url) +} + +function joinScripts(...scripts: Array): string { + return scripts.filter(Boolean).join('\n') +} + +function isUriSource(source: XWebViewSource): source is Extract { + return typeof source.uri === 'string' +} + +function validateImageSource(uri: string): Promise { + return new Promise((resolve, reject) => { + Image.getSize( + uri, + () => resolve(), + () => reject(new Error('导航栏背景图片加载失败')), + ) + }) +} + +export type XWebViewViewProps = { + active?: boolean + config: XWebViewConfig + pageId?: string + statusBarHeight?: number +} + +/** + * XWebView 唯一浏览器内核。 + * + * 全屏 Host 与内嵌场景都复用这里的权限、下载、外链、Bridge、进度和错误状态, + * 页面或插件不得再次直接封装 react-native-webview。 + */ export function XWebViewView({ - config: configProp, - manageSystemBars = true, - registerController, -}: { - config?: XWebViewConfig - /** 外层 Screen 自己管理安全区和状态栏时关闭。 */ - manageSystemBars?: boolean - /** 组合式 Screen 传入 config 时仍可显式注册全局控制器。 */ - registerController?: boolean -} = {}) { + active = true, + config, + pageId, + statusBarHeight = 0, +}: XWebViewViewProps) { + const embeddedId = useRef( + `xwv-embedded-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`, + ) + const resolvedPageId = pageId ?? embeddedId.current const webViewRef = useRef(null) - - // configProp 用于内联嵌入(不依赖全局单例 config,可同屏多实例); - // 不传时回退到 openXWebView 设置的全局 config(全屏/miniapp 用法)。 - const inline = configProp != null - const shouldRegisterController = registerController ?? !inline - const config = configProp ?? getXWebViewConfig() - const { - showStatusBar = true, - title: initialTitle = '', - url, - headers, - content, - injectedJavaScript, - onMessage: userOnMessage, - onNavigationStateChange, - onPermissionRequest, - autoDownload = true, - downloadConflict = 'rename', - downloadDestination = 'sandbox', - downloadNotificationTitle, - onDownloadStart, - onDownloadProgress, - onDownloadComplete, - onDownloadError, - onDownloadDecide, - onClose, - } = config - const [progress, setProgress] = useState(0) const [showProgress, setShowProgress] = useState(false) - const [canGoBack, setCanGoBack] = useState(false) - const [canGoForward, setCanGoForward] = useState(false) - const [currentUrl, setCurrentUrl] = useState(url ?? '') - const [currentTitle, setCurrentTitle] = useState(initialTitle) const [loadError, setLoadError] = useState(null) - const handlePermissionRequest = useXWebViewPermissionRequest(onPermissionRequest) + const [navigationState, setNavigationState] = useState({ + canGoBack: false, + canGoForward: false, + title: config.navigationBar?.title?.text ?? '', + url: isUriSource(config.source) ? config.source.uri : '', + }) + + const decidePermission = useXWebViewPermissionDecision(config.permissions) + const downloads = config.downloads ?? {} + const downloadHeaders = useMemo( + () => ({ + ...(isUriSource(config.source) ? config.source.headers : {}), + ...(downloads.headers ?? {}), + }), + [config.source, downloads.headers], + ) + const reload = useCallback(() => { setLoadError(null) setShowProgress(true) webViewRef.current?.reload() }, []) - useEffect(() => { - // 内联模式不注册全局 controller,避免与全屏 XWebView 实例互相覆盖。 - if (!shouldRegisterController) return - setXWebViewController({ - refresh: reload, - close: () => onClose?.(), + const controller = useMemo( + () => ({ + copyUrl: () => Clipboard.setString(navigationState.url), + evaluateJavaScript: script => webViewRef.current?.injectJavaScript(script), + getNavigationState: () => navigationState, goBack: () => { setLoadError(null) webViewRef.current?.goBack() @@ -192,225 +234,336 @@ export function XWebViewView({ setLoadError(null) webViewRef.current?.goForward() }, - copyUrl: () => Clipboard.setString(currentUrl), - postMessageToWeb: (js: string) => webViewRef.current?.injectJavaScript(js), - getTitle: () => currentTitle, - getNavigationState: () => ({ canGoBack, canGoForward, title: currentTitle, url: currentUrl }), - }) - return () => setXWebViewController(null) - }, [canGoBack, canGoForward, currentUrl, currentTitle, onClose, reload, shouldRegisterController]) - - const handleNavigationStateChange = useCallback( - (state: WebViewNavigation) => { - const navigationState = { - canGoBack: state.canGoBack, - canGoForward: state.canGoForward, - title: state.title ?? '', - url: state.url ?? '', - } - setCanGoBack(navigationState.canGoBack) - setCanGoForward(navigationState.canGoForward) - if (navigationState.url) setCurrentUrl(navigationState.url) - if (navigationState.title) setCurrentTitle(navigationState.title) - onNavigationStateChange?.(navigationState) - }, - [onNavigationStateChange], + postMessage: message => webViewRef.current?.postMessage(message), + reload, + }), + [navigationState, reload], ) - const handleLoadProgress = useCallback((event: WebViewProgressEvent) => { - const p = event.nativeEvent.progress - setProgress(p) - if (p >= 0.96) setShowProgress(false) - }, []) + useEffect(() => { + if (pageId) registerPageController(pageId, controller) + else config.behavior?.onControllerReady?.(controller) + return () => { + if (pageId) registerPageController(pageId, null) + else config.behavior?.onControllerReady?.(null) + } + }, [config.behavior, controller, pageId]) + + const bridgeContext = useMemo( + () => ({ + close: (reason = 'h5_close', data) => { + if (pageId) { + closePageById(pageId, reason, data) + } else { + config.behavior?.onClose?.({ data, reason }) + } + }, + evaluateJavaScript: script => webViewRef.current?.injectJavaScript(script), + getStatusBarHeight: () => statusBarHeight, + isActive: () => (pageId ? isActivePage(pageId) : active), + pageId: resolvedPageId, + updateNavigation: async patch => { + if (patch.background?.image) { + await validateImageSource(patch.background.image) + } + if (pageId) updatePageNavigation(pageId, patch) + }, + updateWindow: patch => { + if (pageId) updatePageWindow(pageId, patch) + }, + }), + [active, config.behavior, pageId, resolvedPageId, statusBarHeight], + ) const processBlobDownload = useCallback( async (blobUrl: string, hintFilename: string, base64: string) => { - const info = { url: blobUrl, suggestedFilename: hintFilename || 'download' } - let finalFilename = info.suggestedFilename - let finalSavePath: string | undefined - - if (!autoDownload) { - if (!onDownloadDecide) return - const decision = await Promise.resolve(onDownloadDecide(info)) + const info = { + suggestedFilename: hintFilename || 'download', + url: blobUrl, + } + let filename = info.suggestedFilename + let savePath: string | undefined + if (downloads.auto === false) { + if (!downloads.onDecide) return + const decision = await Promise.resolve(downloads.onDecide(info)) if (!decision.allowed) return - if (decision.filename) finalFilename = decision.filename - if (decision.savePath) finalSavePath = decision.savePath + filename = decision.filename ?? filename + savePath = decision.savePath } - onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) + downloads.onStart?.({ ...info, suggestedFilename: filename }) try { const filePath = await saveBase64File( base64, - finalFilename, - finalSavePath, - downloadConflict, - downloadDestination, + filename, + savePath, + downloads.conflict ?? 'rename', + downloads.destination ?? 'sandbox', ) - onDownloadComplete?.({ url: blobUrl, filename: finalFilename, filePath, fileSize: 0 }) - } catch (err) { - onDownloadError?.(blobUrl, String(err)) + downloads.onComplete?.({ + filePath, + fileSize: 0, + filename, + url: blobUrl, + }) + } catch (error) { + downloads.onError?.(blobUrl, String(error)) } }, - [ - autoDownload, - downloadConflict, - downloadDestination, - onDownloadDecide, - onDownloadStart, - onDownloadComplete, - onDownloadError, - ], + [downloads], ) const processDownload = useCallback( async (downloadUrl: string, hintFilename?: string) => { - const info = await fetchDownloadInfo(downloadUrl, hintFilename) - let finalFilename = info.suggestedFilename - let finalSavePath: string | undefined + try { + const info = await fetchDownloadInfo(downloadUrl, hintFilename, downloadHeaders) + let filename = info.suggestedFilename + let savePath: string | undefined + if (downloads.auto === false) { + if (!downloads.onDecide) return + const decision = await Promise.resolve(downloads.onDecide(info)) + if (!decision.allowed) return + filename = decision.filename ?? filename + savePath = decision.savePath + } - if (!autoDownload) { - if (!onDownloadDecide) return - const decision = await Promise.resolve(onDownloadDecide(info)) - if (!decision.allowed) return - if (decision.filename) finalFilename = decision.filename - if (decision.savePath) finalSavePath = decision.savePath + downloads.onStart?.({ ...info, suggestedFilename: filename }) + startDownload( + downloadUrl, + filename, + savePath, + downloads.conflict ?? 'rename', + value => downloads.onProgress?.(value), + value => downloads.onComplete?.(value), + error => downloads.onError?.(downloadUrl, error), + { + destination: downloads.destination ?? 'sandbox', + headers: downloadHeaders, + notificationTitle: downloads.notificationTitle, + }, + ) + } catch (error) { + downloads.onError?.(downloadUrl, String(error)) } - - onDownloadStart?.({ ...info, suggestedFilename: finalFilename }) - startDownload( - downloadUrl, - finalFilename, - finalSavePath, - downloadConflict, - p => onDownloadProgress?.(p), - r => onDownloadComplete?.(r), - err => onDownloadError?.(downloadUrl, err), - { destination: downloadDestination, notificationTitle: downloadNotificationTitle }, - ) }, - [ - autoDownload, - downloadConflict, - downloadDestination, - downloadNotificationTitle, - onDownloadDecide, - onDownloadStart, - onDownloadProgress, - onDownloadComplete, - onDownloadError, - ], + [downloadHeaders, downloads], ) const handleMessage = useCallback( (event: WebViewMessageEvent) => { const raw = event.nativeEvent.data try { - const parsed = JSON.parse(raw) - + const parsed = JSON.parse(raw) as Record if (parsed.__xwv === 'download') { - processDownload(String(parsed.url), parsed.filename ? String(parsed.filename) : undefined) + void processDownload( + String(parsed.url ?? ''), + parsed.filename ? String(parsed.filename) : undefined, + ) return } if (parsed.__xwv === 'blobdownload') { - processBlobDownload( - String(parsed.url), + void processBlobDownload( + String(parsed.url ?? ''), String(parsed.filename ?? 'download'), String(parsed.data ?? ''), ) return } if (parsed.__xwv === 'bloberror') { - onDownloadError?.(String(parsed.url ?? ''), String(parsed.msg ?? 'blob read failed')) + downloads.onError?.(String(parsed.url ?? ''), '无法读取网页生成的文件') return } - if (parsed.__xwv === 'alert' || parsed.__xwv === 'confirm' || parsed.__xwv === 'prompt') { - Alert.alert('提示', String(parsed.msg ?? '')) + if (parsed.__xwv === 'permission') { + const id = String(parsed.id ?? '') + const origin = String(parsed.origin ?? '') + const resources = Array.isArray(parsed.resources) + ? parsed.resources.filter( + (value): value is 'camera' | 'microphone' => + value === 'camera' || value === 'microphone', + ) + : [] + void decidePermission(origin, resources) + .then(granted => { + webViewRef.current?.injectJavaScript( + `window.__XWV_PERMISSION_REPLY&&window.__XWV_PERMISSION_REPLY(${JSON.stringify(id)},${granted});true;`, + ) + }) + .catch(() => { + webViewRef.current?.injectJavaScript( + `window.__XWV_PERMISSION_REPLY&&window.__XWV_PERMISSION_REPLY(${JSON.stringify(id)},false);true;`, + ) + }) return } } catch { - // not internal + // 非 XWebView 内部消息,继续交给业务 Bridge。 } - userOnMessage?.(event) + + const bridge = config.bridge + if (!bridge) { + config.behavior?.onMessage?.(event) + return + } + void Promise.resolve(bridge.onMessage(raw, bridgeContext)) + .then(handled => { + if (!handled) config.behavior?.onMessage?.(event) + }) + .catch(error => { + config.behavior?.onBridgeError?.( + error instanceof Error ? error.message : 'Bridge 调用失败', + ) + }) }, - [processDownload, processBlobDownload, onDownloadError, userOnMessage], + [ + bridgeContext, + config.behavior, + config.bridge, + decidePermission, + downloads, + processBlobDownload, + processDownload, + ], ) + const openExternal = useCallback(async (url: string) => { + if (await Linking.canOpenURL(url)) { + await Linking.openURL(url) + } + }, []) + const handleShouldStartLoad = useCallback( (request: ShouldStartLoadRequest): boolean => { if (isDownloadUrl(request.url)) { - processDownload(request.url) + void processDownload(request.url) return false } - return true + const policy = resolveLinkPolicy(request.url) + if (policy.action === 'allow') return true + if (policy.action === 'external') { + void openExternal(request.url) + return false + } + + const decide = config.behavior?.onExternalUrl + if (decide) { + void Promise.resolve(decide(request.url)).then(decision => { + if (decision === 'external') void openExternal(request.url) + if (decision === 'allow') { + webViewRef.current?.injectJavaScript( + `window.location.href=${JSON.stringify(request.url)};true;`, + ) + } + }) + } + return false }, - [processDownload], + [config.behavior, openExternal, processDownload], ) - const handleError = useCallback((event: WebViewErrorEvent) => { - setLoadError(event.nativeEvent.description || '页面加载失败') - setShowProgress(false) - }, []) + const handleOpenWindow = useCallback( + (event: WebViewOpenWindowEvent) => { + const targetUrl = event.nativeEvent.targetUrl + if (!targetUrl) return + const policy = resolveLinkPolicy(targetUrl) + if (policy.action === 'allow') { + webViewRef.current?.injectJavaScript( + `window.location.href=${JSON.stringify(targetUrl)};true;`, + ) + } else if (policy.action === 'external') { + void openExternal(targetUrl) + } + }, + [openExternal], + ) - const handleOpenWindow = useCallback((event: WebViewOpenWindowEvent) => { - const targetUrl = event.nativeEvent.targetUrl - if (targetUrl) { - webViewRef.current?.injectJavaScript( - `window.location.href = ${JSON.stringify(targetUrl)}; true;`, - ) - } - }, []) + const handleNavigationStateChange = useCallback( + (state: WebViewNavigation) => { + const value: XWebViewNavigationState = { + canGoBack: state.canGoBack, + canGoForward: state.canGoForward, + title: state.title ?? '', + url: state.url ?? '', + } + setNavigationState(value) + if (pageId) updatePageNavigationState(pageId, value) + else config.behavior?.onNavigationStateChange?.(value) + }, + [config.behavior, pageId], + ) - const source = url - ? ({ uri: url, headers } as const) - : ({ html: content ?? '' } as const) + const source = isUriSource(config.source) + ? { headers: config.source.headers, uri: config.source.uri } + : { baseUrl: config.source.baseUrl, html: config.source.html } - const injected = DIALOG_OVERRIDE_JS + '\n' + (injectedJavaScript ?? '') - const ContentContainer = manageSystemBars && showStatusBar ? SafeAreaView : View + const injectedBefore = joinScripts( + MEDIA_PERMISSION_SCRIPT, + config.bridge?.injectedJavaScriptBeforeContentLoaded, + config.behavior?.injectedJavaScriptBeforeContentLoaded, + ) + const injected = joinScripts( + MEDIA_PERMISSION_SCRIPT, + DOWNLOAD_INTERCEPT_SCRIPT, + config.behavior?.injectedJavaScript, + ) return ( - - {manageSystemBars ? + + ) : null} + + ) +} + +function WebViewError({ message, onRetry }: { message: string; onRetry: () => void }) { + return ( + + 页面加载失败 + {message} + + 点击重试 + + ) } @@ -419,45 +572,45 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#ffffff', }, - webview: { - flex: 1, - }, - content: { - flex: 1, - }, errorContainer: { - position: 'absolute', - top: 0, - right: 0, bottom: 0, left: 0, + position: 'absolute', + right: 0, + top: 0, alignItems: 'center', backgroundColor: '#ffffff', justifyContent: 'center', paddingHorizontal: 32, + }, + errorContent: { + alignItems: 'center', gap: 12, }, - errorTitle: { - fontSize: 18, - fontWeight: '800', - color: '#b91c1c', - }, errorMessage: { - fontSize: 13, - color: '#7f1d1d', + color: '#6b7280', + fontSize: 14, + lineHeight: 21, textAlign: 'center', - lineHeight: 20, }, - retryBtn: { - marginTop: 8, - backgroundColor: '#3b82f6', + errorTitle: { + color: '#111827', + fontSize: 18, + fontWeight: '700', + }, + retryButton: { + backgroundColor: '#1677ff', borderRadius: 8, + marginTop: 8, paddingHorizontal: 24, paddingVertical: 10, }, retryText: { color: '#ffffff', - fontWeight: '700', fontSize: 15, + fontWeight: '600', + }, + webview: { + flex: 1, }, }) diff --git a/packages/xwebview/src/index.ts b/packages/xwebview/src/index.ts index 9b38370..db4f233 100644 --- a/packages/xwebview/src/index.ts +++ b/packages/xwebview/src/index.ts @@ -1,34 +1,33 @@ import '@xuqm/rn-common/internal' -export { - getXWebViewConfig, - openXWebView, - setXWebViewController, - setXWebViewScanQRCodeHandler, - XWebViewControl, -} from '@xuqm/rn-common' +export { XWebViewHost } from './XWebViewHost' +export { XWebViewView } from './XWebViewView' +export { openWebView } from './runtime' +export { resolveLinkPolicy } from './linkPolicy' export type { - ScanQRCodeHandler, - XWebViewClickMenu, + XWebViewBehaviorConfig, + XWebViewBridge, + XWebViewBridgePageContext, XWebViewConfig, - XWebViewControllerAPI, + XWebViewController, XWebViewDownloadDecision, XWebViewDownloadDestination, XWebViewDownloadProgress, XWebViewDownloadRequest, XWebViewDownloadResult, + XWebViewDownloadsConfig, + XWebViewExternalUrlDecision, + XWebViewMenuContext, + XWebViewMenuItem, XWebViewMessageEvent, + XWebViewNavigationBackground, + XWebViewNavigationBarConfig, + XWebViewNavigationRenderContext, + XWebViewNavigationState, + XWebViewPageClosedResult, + XWebViewPageHandle, XWebViewPermissionRequest, -} from '@xuqm/rn-common' - -export { default as XWebViewProgress } from './XWebViewProgress' -export { XWebViewView } from './XWebViewView' -export { XWebViewScreen } from './XWebViewScreen' -export { - handleDownloadRequest, - fetchDownloadInfo, - saveBase64File, - startDownload, - openFile, -} from './XWebViewDownload' -export type { DownloadHandle } from './XWebViewDownload' + XWebViewPermissionsConfig, + XWebViewSource, + XWebViewStatusBarConfig, +} from './types' diff --git a/packages/xwebview/src/linkPolicy.ts b/packages/xwebview/src/linkPolicy.ts new file mode 100644 index 0000000..25c484f --- /dev/null +++ b/packages/xwebview/src/linkPolicy.ts @@ -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' } +} diff --git a/packages/xwebview/src/runtime.ts b/packages/xwebview/src/runtime.ts new file mode 100644 index 0000000..ee8693e --- /dev/null +++ b/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 | 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(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() +} diff --git a/packages/xwebview/src/types.ts b/packages/xwebview/src/types.ts new file mode 100644 index 0000000..7afbbe1 --- /dev/null +++ b/packages/xwebview/src/types.ts @@ -0,0 +1,232 @@ +import type React from 'react' + +export type XWebViewSource = + | { + headers?: Record + 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 + 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 +} + +export type XWebViewNavigationRenderContext = XWebViewMenuContext & { + background: XWebViewNavigationBackground + closeButton: XWebViewNavigationButton + backButton: XWebViewNavigationButton + title: Required> & + Pick +} + +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 + notificationTitle?: string + onComplete?: (result: XWebViewDownloadResult) => void + onDecide?: ( + request: XWebViewDownloadRequest, + ) => XWebViewDownloadDecision | Promise + 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 + updateWindow(patch: XWebViewWindowPatch): void +} + +export type XWebViewBridge = { + injectedJavaScriptBeforeContentLoaded?: string + onMessage(raw: string, context: XWebViewBridgePageContext): boolean | Promise + 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 + 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 +} diff --git a/packages/xwebview/tests/linkPolicy.test.ts b/packages/xwebview/tests/linkPolicy.test.ts new file mode 100644 index 0000000..731f26d --- /dev/null +++ b/packages/xwebview/tests/linkPolicy.test.ts @@ -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', + }) +}) diff --git a/packages/xwebview/tests/runtime.test.ts b/packages/xwebview/tests/runtime.test.ts new file mode 100644 index 0000000..0d9e302 --- /dev/null +++ b/packages/xwebview/tests/runtime.test.ts @@ -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() +}) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 931550f..8441d02 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -140,9 +140,6 @@ importers: packages/xwebview: dependencies: - react-native-svg: - specifier: ^15.15.4 - version: 15.15.5(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) react-native-webview: specifier: ^14.0.1 version: 14.0.1(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) @@ -150,15 +147,15 @@ importers: '@react-native-clipboard/clipboard': specifier: ^1.16.3 version: 1.16.3(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) - '@react-navigation/native': - specifier: ^7.0.0 - version: 7.3.8(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) '@types/react': specifier: ^19.0.0 version: 19.2.17 react-native-safe-area-context: specifier: ^5.4.0 version: 5.8.0(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + react-native-svg: + specifier: ^15.15.4 + version: 15.15.5(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) tsx: specifier: ^4.23.1 version: 4.23.1 @@ -524,20 +521,6 @@ packages: '@types/react': optional: true - '@react-navigation/core@7.21.5': - resolution: {integrity: sha512-3hpV7uR41LBW+GHDoLhztZCb/i5ySRJISZ/rez4d7DCHSZo6ej4gNxYclaS6LRguoLiKG7SOCNa6O390AQklZQ==} - peerDependencies: - react: '>= 18.2.0' - - '@react-navigation/native@7.3.8': - resolution: {integrity: sha512-zHmQcxWBT8GOwsofEOmHqpdM5twkwE/esa9JFGlW4hpXeQTTe/dRcPSLjwsvePtolbDKz0YZbK+I5KrW3j63LQ==} - peerDependencies: - react: '>= 18.2.0' - react-native: '*' - - '@react-navigation/routers@7.6.0': - resolution: {integrity: sha512-lblhDXfS75jLc7G2K7BZGM+7cjqQXk13X/MA4fq/12r62zM+fBhhreLzYflSitrDDXFRJpSvJXy0ziiGU04Xow==} - '@sinclair/typebox@0.27.10': resolution: {integrity: sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA==} @@ -688,6 +671,14 @@ packages: cpu: [x64] os: [win32] + '@xuqm/rn-common@0.6.0-alpha.9': + resolution: {integrity: sha512-NvSYr92sRV8xKVCTrO7sPFXypUYfxMozvHWaNlIk+4bnvGlhWGGUvmvjm2QxF0rBzTO8b0HzhXhb9S/CIx629Q==} + peerDependencies: + '@react-native-async-storage/async-storage': '>=1.21.0' + axios: '>=1.0.0' + react: '>=18.0.0' + react-native: '>=0.76.0' + abort-controller@3.0.0: resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} engines: {node: '>=6.5'} @@ -867,10 +858,6 @@ packages: supports-color: optional: true - decode-uri-component@0.2.2: - resolution: {integrity: sha512-FqUYQ+8o158GyGTrMFJms9qh3CqTKvAqgqsTnkLI8sKu0028orqBhxNMFkFen0zGyg6epACD32pjVk58ngIErQ==} - engines: {node: '>=0.10'} - delayed-stream@1.0.0: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} @@ -967,9 +954,6 @@ packages: exponential-backoff@3.1.3: resolution: {integrity: sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==} - fast-deep-equal@3.1.3: - resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} - fb-dotslash@0.5.8: resolution: {integrity: sha512-XHYLKk9J4BupDxi9bSEhkfss0m+Vr9ChTrjhf9l2iw3jB5C7BnY4GVPoMcqbrTutsKJso6yj2nAB6BI/F2oZaA==} engines: {node: '>=20'} @@ -994,10 +978,6 @@ packages: resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} engines: {node: '>=8'} - filter-obj@1.1.0: - resolution: {integrity: sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ==} - engines: {node: '>=0.10.0'} - finalhandler@1.1.2: resolution: {integrity: sha512-aAWcW57uxVNrQZqFXjITpW3sIUQmHGG3qSb9mUah9MgMC4NeWhNOlNjXEYq3HjRAvL6arUviZGGJsBg6z0zsWA==} engines: {node: '>= 0.8'} @@ -1311,11 +1291,6 @@ packages: ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} - nanoid@3.3.16: - resolution: {integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==} - engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} - hasBin: true - negotiator@1.0.0: resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} engines: {node: '>= 0.6'} @@ -1388,10 +1363,6 @@ packages: resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==} engines: {node: '>=10'} - query-string@7.1.3: - resolution: {integrity: sha512-hh2WYhq4fi8+b+/2Kg9CEge4fDPvHS534aOOvOZeQ3+Vf2mCFsaFBYj0i+iXcAq6I9Vzp5fjMFBlONvayDC1qg==} - engines: {node: '>=6'} - queue@6.0.2: resolution: {integrity: sha512-iHZWu+q3IdFZFX36ro/lKBkSvfkztY5Y7HMiPlOUjhupPcG2JMfst2KKEpu5XndviX/3UhFbRngUPNKtgvtZiA==} @@ -1408,9 +1379,6 @@ packages: react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} - react-is@19.2.7: - resolution: {integrity: sha512-kZFnouyVv7eP/Phmrlo9FK+zcAdriZJvzxXHF1Sl1P377WSGe2G/JxVolhTrB/jeV47lKImhNUsijjHAAbcl/A==} - react-native-blob-util@0.24.10: resolution: {integrity: sha512-4yazgoCstXgt2/CRURtowGP3furAzbWdfMQ4jyUPDo5xtLx7KE2F/xMz3oU3shLwfL4id0iEKsBywoxvJSHPzQ==} peerDependencies: @@ -1520,10 +1488,6 @@ packages: resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} engines: {node: '>=0.10.0'} - split-on-first@1.1.0: - resolution: {integrity: sha512-43ZssAJaMusuKWL8sKUBQXHWOpq8d6CfN/u1p4gUzfJkM05C8rxTmYrkIPTXapZpORA6LkkzcUulJ8FqA7Uudw==} - engines: {node: '>=6'} - sql-escape-string@1.1.0: resolution: {integrity: sha512-/kqO4pLZSLfV0KsBM2xkVh2S3GbjJJone37d7gYwLyP0c+REh3vnmkhQ7VwNrX76igC0OhJWpTg0ukkdef9vvA==} @@ -1534,9 +1498,6 @@ packages: resolution: {integrity: sha512-WjlahMgHmCJpqzU8bIBy4qtsZdU9lRlcZE3Lvyej6t4tuOuv1vk57OW3MBrj6hXBFx/nNoC9MPMTcr5YA7NQbg==} engines: {node: '>=6'} - standard-navigation@0.0.7: - resolution: {integrity: sha512-NCGLCNyuXrFOkGHxdNZFnpsehGtiq1oXbPhKl7ZuxFO5J//H2evqqOchmD4YwEUJnkjO4kH9Xp4hQX6hdAYCKQ==} - statuses@1.5.0: resolution: {integrity: sha512-OpZ3zP+jT1PI7I8nemJX4AKmAX070ZkYPVWV/AaKTJl+tXCTGyVdC1a4SL8RUQYEwk/f34ZX8UTykN68FwrqAA==} engines: {node: '>= 0.6'} @@ -1545,10 +1506,6 @@ packages: resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} engines: {node: '>= 0.8'} - strict-uri-encode@2.0.0: - resolution: {integrity: sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ==} - engines: {node: '>=4'} - string-width@4.2.3: resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} engines: {node: '>=8'} @@ -1621,16 +1578,6 @@ packages: peerDependencies: browserslist: '>= 4.21.0' - use-latest-callback@0.2.6: - resolution: {integrity: sha512-FvRG9i1HSo0wagmX63Vrm8SnlUU3LMM3WyZkQ76RnslpBrX694AdG4A0zQBx2B3ZifFA0yv/BaEHGBnEax5rZg==} - peerDependencies: - react: '>=16.8' - - use-sync-external-store@1.6.0: - resolution: {integrity: sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==} - peerDependencies: - react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - utils-merge@1.0.1: resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} engines: {node: '>= 0.4.0'} @@ -2016,33 +1963,6 @@ snapshots: optionalDependencies: '@types/react': 19.2.17 - '@react-navigation/core@7.21.5(react@19.2.7)': - dependencies: - '@react-navigation/routers': 7.6.0 - escape-string-regexp: 4.0.0 - fast-deep-equal: 3.1.3 - nanoid: 3.3.16 - query-string: 7.1.3 - react: 19.2.7 - react-is: 19.2.7 - use-latest-callback: 0.2.6(react@19.2.7) - use-sync-external-store: 1.6.0(react@19.2.7) - - '@react-navigation/native@7.3.8(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)': - dependencies: - '@react-navigation/core': 7.21.5(react@19.2.7) - escape-string-regexp: 4.0.0 - fast-deep-equal: 3.1.3 - nanoid: 3.3.16 - react: 19.2.7 - react-native: 0.86.0(@types/react@19.2.17)(react@19.2.7) - standard-navigation: 0.0.7 - use-latest-callback: 0.2.6(react@19.2.7) - - '@react-navigation/routers@7.6.0': - dependencies: - nanoid: 3.3.16 - '@sinclair/typebox@0.27.10': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -2135,6 +2055,17 @@ snapshots: '@typescript/typescript-win32-x64@7.0.2': optional: true + '@xuqm/rn-common@0.6.0-alpha.9(@react-native-async-storage/async-storage@3.1.1(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7))(axios@1.18.1)(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7)': + dependencies: + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + '@react-native-async-storage/async-storage': 3.1.1(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + axios: 1.18.1 + react: 19.2.7 + react-native: 0.86.0(@types/react@19.2.17)(react@19.2.7) + react-native-blob-util: 0.24.10(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7) + zod: 4.4.3 + abort-controller@3.0.0: dependencies: event-target-shim: 5.0.1 @@ -2313,8 +2244,6 @@ snapshots: dependencies: ms: 2.1.3 - decode-uri-component@0.2.2: {} - delayed-stream@1.0.0: {} depd@2.0.0: {} @@ -2417,8 +2346,6 @@ snapshots: exponential-backoff@3.1.3: {} - fast-deep-equal@3.1.3: {} - fb-dotslash@0.5.8: {} fb-watchman@2.0.2: @@ -2435,8 +2362,6 @@ snapshots: dependencies: to-regex-range: 5.0.1 - filter-obj@1.1.0: {} - finalhandler@1.1.2: dependencies: debug: 2.6.9 @@ -2851,8 +2776,6 @@ snapshots: ms@2.1.3: {} - nanoid@3.3.16: {} - negotiator@1.0.0: {} node-int64@0.4.0: {} @@ -2911,13 +2834,6 @@ snapshots: proxy-from-env@2.1.0: {} - query-string@7.1.3: - dependencies: - decode-uri-component: 0.2.2 - filter-obj: 1.1.0 - split-on-first: 1.1.0 - strict-uri-encode: 2.0.0 - queue@6.0.2: dependencies: inherits: 2.0.4 @@ -2936,8 +2852,6 @@ snapshots: react-is@18.3.1: {} - react-is@19.2.7: {} - react-native-blob-util@0.24.10(react-native@0.86.0(@types/react@19.2.17)(react@19.2.7))(react@19.2.7): dependencies: base-64: 1.0.0 @@ -3076,8 +2990,6 @@ snapshots: source-map@0.6.1: {} - split-on-first@1.1.0: {} - sql-escape-string@1.1.0: {} stackframe@1.3.4: {} @@ -3086,14 +2998,10 @@ snapshots: dependencies: type-fest: 0.7.1 - standard-navigation@0.0.7: {} - statuses@1.5.0: {} statuses@2.0.2: {} - strict-uri-encode@2.0.0: {} - string-width@4.2.3: dependencies: emoji-regex: 8.0.0 @@ -3179,14 +3087,6 @@ snapshots: escalade: 3.2.0 picocolors: 1.1.1 - use-latest-callback@0.2.6(react@19.2.7): - dependencies: - react: 19.2.7 - - use-sync-external-store@1.6.0(react@19.2.7): - dependencies: - react: 19.2.7 - utils-merge@1.0.1: {} vlq@1.0.1: {} diff --git a/src/index.ts b/src/index.ts index 367f7c4..249b38f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -25,28 +25,33 @@ export type { } from '@xuqm/rn-update' export { BugCollect } from '@xuqm/rn-bugcollect' export { - XWebViewControl, - XWebViewScreen, + XWebViewHost, XWebViewView, - getXWebViewConfig, - openXWebView, - setXWebViewController, - setXWebViewScanQRCodeHandler, - handleDownloadRequest, - fetchDownloadInfo, - saveBase64File, - startDownload, + openWebView, } from '@xuqm/rn-xwebview' export type { - ScanQRCodeHandler, - XWebViewClickMenu, + XWebViewBridge, + XWebViewBridgePageContext, XWebViewConfig, - XWebViewControllerAPI, + XWebViewController, XWebViewDownloadDecision, + XWebViewDownloadDestination, XWebViewDownloadProgress, XWebViewDownloadRequest, XWebViewDownloadResult, + XWebViewDownloadsConfig, + XWebViewExternalUrlDecision, + XWebViewMenuContext, + XWebViewMenuItem, XWebViewMessageEvent, + XWebViewNavigationBackground, + XWebViewNavigationBarConfig, + XWebViewNavigationRenderContext, + XWebViewNavigationState, + XWebViewPageClosedResult, XWebViewPermissionRequest, - DownloadHandle, + XWebViewPageHandle, + XWebViewPermissionsConfig, + XWebViewSource, + XWebViewStatusBarConfig, } from '@xuqm/rn-xwebview'