diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 84c159729..6ef97cacd 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -1063,3 +1063,11 @@ if (mobileCapabilitySet.has('app.setBadgeCount')) { if (!dispatchSource.includes('Appearance.getColorScheme()')) { throw new Error('mobile shell HostBridge must read the native color scheme'); } + +if (!dispatchSource.includes('Linking.canOpenURL(url)')) { + throw new Error('mobile shell HostBridge external URL flow must check Linking.canOpenURL'); +} + +if (!shellAppSource.includes('Linking.canOpenURL(externalUrl)')) { + throw new Error('mobile shell WebView external navigation must check Linking.canOpenURL'); +} diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 14d7b8ed1..a3a90269b 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -141,6 +141,7 @@ vi.mock('expo-image-picker', () => ({ })); vi.mock('expo-linking', () => ({ + canOpenURL: vi.fn(), openURL: vi.fn(), })); @@ -294,6 +295,8 @@ afterEach(() => { expires: 'never', }); vi.mocked(Linking.openURL).mockReset(); + vi.mocked(Linking.canOpenURL).mockReset(); + vi.mocked(Linking.canOpenURL).mockResolvedValue(true); vi.mocked(Network.getNetworkStateAsync).mockReset(); vi.mocked(Network.getNetworkStateAsync).mockResolvedValue({ type: Network.NetworkStateType.WIFI, @@ -528,9 +531,26 @@ describe('handleMobileHostBridgeMessage', () => { ); expectOk(response); + expect(Linking.canOpenURL).toHaveBeenCalledWith('https://example.com/path'); expect(Linking.openURL).toHaveBeenCalledWith('https://example.com/path'); }); + test('app.openExternalUrl 在系统不可打开时返回 host_error', async () => { + vi.mocked(Linking.canOpenURL).mockResolvedValue(false); + + const response = await send( + request('app.openExternalUrl', { + url: 'tel:+12345678', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('host_error'); + expect(failedResponse.error.message).toBe('external URL cannot be opened'); + expect(Linking.openURL).not.toHaveBeenCalled(); + }); + test('app.openExternalUrl 拒绝危险协议', async () => { const response = await send( request('app.openExternalUrl', { @@ -541,6 +561,7 @@ describe('handleMobileHostBridgeMessage', () => { const failedResponse = expectFailed(response); expect(failedResponse.error.code).toBe('invalid_request'); + expect(Linking.canOpenURL).not.toHaveBeenCalled(); expect(Linking.openURL).not.toHaveBeenCalled(); }); diff --git a/apps/mobile-shell/src/host-bridge/dispatch.ts b/apps/mobile-shell/src/host-bridge/dispatch.ts index 3231db564..5f954ffd9 100644 --- a/apps/mobile-shell/src/host-bridge/dispatch.ts +++ b/apps/mobile-shell/src/host-bridge/dispatch.ts @@ -76,6 +76,13 @@ async function openExternalUrl(payload: unknown) { throw invalidRequest('url must use an allowed external protocol'); } + if (!(await Linking.canOpenURL(url))) { + throw { + code: 'host_error', + message: 'external URL cannot be opened', + } satisfies HostBridgeError; + } + await Linking.openURL(url); return true; } diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index 4e0c7795d..20bbc0731 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -215,7 +215,9 @@ export default function ShellApp() { const externalUrl = resolveMobileShellExternalUrl(request.url); if (externalUrl) { - void Linking.openURL(externalUrl).catch(() => undefined); + void Linking.canOpenURL(externalUrl) + .then((canOpen) => (canOpen ? Linking.openURL(externalUrl) : undefined)) + .catch(() => undefined); } return false; }; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6ca9aa19c..58097564e 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2336,6 +2336,7 @@ - 背景:Expo 移动壳和 Tauri 桌面壳都需要一个真实的宿主级刷新入口,供 H5 在检测到资源、登录态或运行态需要重新载入时请求宿主刷新当前容器;该能力不能演变成任意 URL 导航或原生 WebView ref 透传。 - 决策:新增 HostBridge method `app.reloadWebView` 和 H5 facade `reloadHostWebView()`。移动端只调用当前 `react-native-webview` 的 `reload()`,桌面端只调用 Tauri 主 `WebviewWindow.reload()`;该 method 不接受 payload,成功只表示宿主已发起刷新,刷新后当前 H5 上下文会卸载。继续把同源跳转留给 `navigation.openNativePage`,外链离开容器留给 `app.openExternalUrl`。 +- 2026-06-18 追加:Expo 移动壳的外链离开容器路径必须在协议白名单后再调用 `Linking.canOpenURL`;WebView 外域导航只有当前设备确认可打开时才调用 `Linking.openURL`,`app.openExternalUrl` 在系统不可打开时返回 `host_error`。该收紧不新增 HostBridge method,不把危险协议、相对路径或设备不可处理的外链留在带完整 HostBridge 的 WebView 内。 - 2026-06-18 追加:`AuthGate` 登录态身份边界刷新改为优先调用 `reloadHostWebView()`,用于登录成功、退出登录或从已登录变为未登录后的主站重新初始化;宿主未声明、返回失败或不可用时再回退浏览器 `window.location.reload()`,普通 token refresh、账号资料更新、主题和音量变化仍不触发整页刷新。 - 2026-06-18 追加:Expo 移动壳的 iOS `onContentProcessDidTerminate` 和 Android `onRenderProcessGone` 也复用当前 WebView 的受控 `reload()` 路径,系统回收 WebView 内容 / 渲染进程后只恢复同一 H5 容器,不改写 URL、不注入额外脚本、不新增宿主恢复页面。 - 2026-06-18 追加:Expo 移动壳的 `onError` / `onHttpError` 只对同源 H5 主页面展示原生加载失败兜底层,用户重试时仍复用当前 WebView `reload()`;兜底不接管外域、危险协议、`about:blank` 或 favicon 失败,也不向 H5 注入错误事件。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 5dfc50684..cef98d5e2 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -275,7 +275,7 @@ GameBridge 禁止: - iOS / Android 深链打开作品详情、创作页和邀请码。 - 登录和支付先 fallback 到 H5;只把能力边界跑通。 -当前状态:已新增 `apps/mobile-shell/`,通过 Expo development build 运行,`react-native-webview` 加载 H5 URL 并附加 `native_app` 宿主 query。移动壳使用真实品牌图标资产,已接入 `genarrative://` scheme、iOS associated domain 和 Android app link filter,启动和运行时 deep link 只会映射到同源 H5 路径并继续附加 HostBridge 上下文,外域和危险协议回退到默认主站入口。首轮真实能力包括 `host.getRuntime`、`appearance.getColorScheme`、`host.events`、`app.lifecycle`、`network.status`、`network.statusChanged`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`navigation.canGoBack`、`app.reloadWebView`、`app.openExternalUrl`、`clipboard.writeText`、`clipboard.readText`、`file.exportText`、`file.exportImage`、`file.importImage`、`file.captureImage`、`file.importAudio`、`file.exportAudio`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `