From 080ebaedfd5d385d4f53e0ccf13d2356add416be Mon Sep 17 00:00:00 2001 From: kdletters Date: Wed, 17 Jun 2026 22:15:31 +0800 Subject: [PATCH] =?UTF-8?q?=E6=8E=A5=E5=85=A5=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E5=8F=97=E6=8E=A7=E8=B7=AF=E7=94=B1=E5=AF=BC=E8=88=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动 HostBridge 声明 navigation.openNativePage 并限制为同源 H5 route Expo WebView 将受控导航请求切换为新的 WebView URL 补充移动壳 HostBridge 与导航解析测试和配置守卫 更新宿主壳方案、统一协议和团队共享决策记录 --- apps/mobile-shell/App.tsx | 12 ++ apps/mobile-shell/scripts/check-config.mjs | 1 + .../mobile-shell/src/mobileHostBridge.test.ts | 151 ++++++++++++++++++ apps/mobile-shell/src/mobileHostBridge.ts | 40 ++++- .../src/mobileShellNavigation.test.ts | 25 ++- .../mobile-shell/src/mobileShellNavigation.ts | 18 +++ .../shared-memory/decision-log.md | 2 +- ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 4 +- ...前端架构】宿主壳能力统一协议-2026-06-17.md | 2 +- 9 files changed, 249 insertions(+), 6 deletions(-) create mode 100644 apps/mobile-shell/src/mobileHostBridge.test.ts diff --git a/apps/mobile-shell/App.tsx b/apps/mobile-shell/App.tsx index b7e68219..aa33c28e 100644 --- a/apps/mobile-shell/App.tsx +++ b/apps/mobile-shell/App.tsx @@ -5,6 +5,7 @@ import type { WebViewMessageEvent } from 'react-native-webview'; import { WebView } from 'react-native-webview'; import { + configureMobileHostBridgeNavigation, handleMobileHostBridgeMessage, MOBILE_HOST_CAPABILITIES, } from './src/mobileHostBridge'; @@ -32,6 +33,17 @@ export default function App() { ); const allowedWebOrigin = useMemo(() => new URL(webUrl).origin, [webUrl]); + useEffect(() => { + configureMobileHostBridgeNavigation({ + allowedOrigin: allowedWebOrigin, + openWebViewUrl(url) { + setWebUrl(url); + }, + }); + + return () => configureMobileHostBridgeNavigation(null); + }, [allowedWebOrigin]); + useEffect(() => { let disposed = false; diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 155b151b..90c1b72c 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -40,6 +40,7 @@ for (const snippet of [ 'Linking.getInitialURL()', "Linking.addEventListener('url'", 'buildMobileShellUrlFromDeepLink', + 'configureMobileHostBridgeNavigation', ]) { if (!appSource.includes(snippet)) { throw new Error(`mobile shell App missing ${snippet}`); diff --git a/apps/mobile-shell/src/mobileHostBridge.test.ts b/apps/mobile-shell/src/mobileHostBridge.test.ts new file mode 100644 index 00000000..34888ea7 --- /dev/null +++ b/apps/mobile-shell/src/mobileHostBridge.test.ts @@ -0,0 +1,151 @@ +import { afterEach, describe, expect, test, vi } from 'vitest'; + +import { + HOST_BRIDGE_PROTOCOL, + HOST_BRIDGE_VERSION, + type HostBridgeMethod, + type HostBridgeRequest, + type HostBridgeResponse, +} from '../../../packages/shared/src/contracts/hostBridge'; +import { + configureMobileHostBridgeNavigation, + handleMobileHostBridgeMessage, +} from './mobileHostBridge'; + +vi.mock('expo-clipboard', () => ({ + setStringAsync: vi.fn(), +})); + +vi.mock('expo-haptics', () => ({ + ImpactFeedbackStyle: { + Heavy: 'heavy', + Light: 'light', + Medium: 'medium', + }, + impactAsync: vi.fn(), +})); + +vi.mock('expo-linking', () => ({ + openURL: vi.fn(), +})); + +vi.mock('react-native', () => ({ + Platform: { + OS: 'ios', + }, + Share: { + share: vi.fn(), + }, +})); + +function request( + method: HostBridgeMethod, + payload?: unknown, +): HostBridgeRequest { + return { + bridge: HOST_BRIDGE_PROTOCOL, + version: HOST_BRIDGE_VERSION, + id: 'request-1', + method, + payload, + }; +} + +async function send(requestValue: HostBridgeRequest) { + const responses: HostBridgeResponse[] = []; + + await handleMobileHostBridgeMessage(JSON.stringify(requestValue), (response) => + responses.push(response), + ); + + const response = responses[0]; + if (!response) { + throw new Error('host bridge response missing'); + } + + return response; +} + +function expectOk(response: HostBridgeResponse) { + if (!response.ok) { + throw new Error('expected ok host bridge response'); + } + + return response; +} + +function expectFailed(response: HostBridgeResponse) { + if (response.ok) { + throw new Error('expected failed host bridge response'); + } + + return response; +} + +afterEach(() => { + configureMobileHostBridgeNavigation(null); +}); + +describe('handleMobileHostBridgeMessage', () => { + test('runtime 能力清单声明移动壳支持受控 WebView 导航', async () => { + const response = await send(request('host.getRuntime')); + + const okResponse = expectOk(response); + + expect(okResponse.result).toMatchObject({ + shell: 'expo_mobile', + platform: 'ios', + }); + expect( + (okResponse.result as { capabilities: string[] }).capabilities, + ).toContain('navigation.openNativePage'); + }); + + test('navigation.openNativePage 把同源路径切到移动壳 WebView', async () => { + const openWebViewUrl = vi.fn(); + configureMobileHostBridgeNavigation({ + allowedOrigin: 'https://app.genarrative.world', + openWebViewUrl, + }); + + const response = await send( + request('navigation.openNativePage', { + url: '/works/detail?work=PZ-1', + }), + ); + + expectOk(response); + expect(openWebViewUrl).toHaveBeenCalledWith( + 'https://app.genarrative.world/works/detail?work=PZ-1', + ); + }); + + test('navigation.openNativePage 拒绝外域目标', async () => { + configureMobileHostBridgeNavigation({ + allowedOrigin: 'https://app.genarrative.world', + openWebViewUrl: vi.fn(), + }); + + const response = await send( + request('navigation.openNativePage', { + url: 'https://example.com/works/detail?work=PZ-1', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('invalid_request'); + }); + + test('未配置 WebView 导航器时明确返回 unsupported', async () => { + const response = await send( + request('navigation.openNativePage', { + url: '/works/detail?work=PZ-1', + }), + ); + + const failedResponse = expectFailed(response); + + expect(failedResponse.error.code).toBe('unsupported_method'); + }); +}); diff --git a/apps/mobile-shell/src/mobileHostBridge.ts b/apps/mobile-shell/src/mobileHostBridge.ts index cc4e1b10..57f296b4 100644 --- a/apps/mobile-shell/src/mobileHostBridge.ts +++ b/apps/mobile-shell/src/mobileHostBridge.ts @@ -13,20 +13,35 @@ import { type HostBridgeMethod, type HostBridgeRequest, type HostBridgeResponse, + type NavigateNativePagePayload, type OpenExternalUrlPayload, type ShareOpenPayload, } from '../../../packages/shared/src/contracts/hostBridge'; +import { resolveMobileShellWebViewUrl } from './mobileShellNavigation'; export const MOBILE_HOST_CAPABILITIES: HostBridgeCapability[] = [ 'host.getRuntime', 'share.open', 'share.setTarget', + 'navigation.openNativePage', 'app.openExternalUrl', 'clipboard.writeText', 'haptics.impact', ]; +export type MobileHostBridgeNavigation = { + allowedOrigin: string; + openWebViewUrl: (url: string) => void; +}; + let currentShareTarget: unknown = null; +let navigation: MobileHostBridgeNavigation | null = null; + +export function configureMobileHostBridgeNavigation( + nextNavigation: MobileHostBridgeNavigation | null, +) { + navigation = nextNavigation; +} function unsupported(method: HostBridgeMethod): HostBridgeError { return { @@ -141,6 +156,28 @@ async function openShare(payload: unknown) { return true; } +function openNativePage(payload: unknown) { + if (!navigation) { + throw unsupported('navigation.openNativePage'); + } + + const url = (payload as NavigateNativePagePayload | undefined)?.url; + if (typeof url !== 'string') { + throw invalidRequest('url is required'); + } + + const webViewUrl = resolveMobileShellWebViewUrl( + url, + navigation.allowedOrigin, + ); + if (!webViewUrl) { + throw invalidRequest('url must be an allowed same-origin web path'); + } + + navigation.openWebViewUrl(webViewUrl); + return true; +} + async function handleRequest(request: HostBridgeRequest) { switch (request.method) { case 'host.getRuntime': @@ -165,9 +202,10 @@ async function handleRequest(request: HostBridgeRequest) { ? (request.payload as { target?: unknown }).target : null; return ok(request, true); + case 'navigation.openNativePage': + return ok(request, openNativePage(request.payload)); case 'auth.requestLogin': case 'payment.request': - case 'navigation.openNativePage': return failure(request, unsupported(request.method)); default: return failure(request, unsupported(request.method)); diff --git a/apps/mobile-shell/src/mobileShellNavigation.test.ts b/apps/mobile-shell/src/mobileShellNavigation.test.ts index ebf9a222..46fffcf2 100644 --- a/apps/mobile-shell/src/mobileShellNavigation.test.ts +++ b/apps/mobile-shell/src/mobileShellNavigation.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'vitest'; -import { shouldOpenInMobileShellWebView } from './mobileShellNavigation'; +import { + resolveMobileShellWebViewUrl, + shouldOpenInMobileShellWebView, +} from './mobileShellNavigation'; describe('shouldOpenInMobileShellWebView', () => { test('只允许主站同源页面留在移动壳 WebView 内', () => { @@ -33,4 +36,24 @@ describe('shouldOpenInMobileShellWebView', () => { false, ); }); + + test('HostBridge 主动导航只解析同源网页目标', () => { + const allowedOrigin = 'https://app.genarrative.world'; + + expect( + resolveMobileShellWebViewUrl('/works/detail?work=PZ-1', allowedOrigin), + ).toBe('https://app.genarrative.world/works/detail?work=PZ-1'); + expect( + resolveMobileShellWebViewUrl( + 'https://app.genarrative.world/creation/puzzle#draft', + allowedOrigin, + ), + ).toBe('https://app.genarrative.world/creation/puzzle#draft'); + expect( + resolveMobileShellWebViewUrl('https://example.com/', allowedOrigin), + ).toBeNull(); + expect( + resolveMobileShellWebViewUrl('about:blank', allowedOrigin), + ).toBeNull(); + }); }); diff --git a/apps/mobile-shell/src/mobileShellNavigation.ts b/apps/mobile-shell/src/mobileShellNavigation.ts index 57d3f619..e266d90c 100644 --- a/apps/mobile-shell/src/mobileShellNavigation.ts +++ b/apps/mobile-shell/src/mobileShellNavigation.ts @@ -25,3 +25,21 @@ export function shouldOpenInMobileShellWebView( return false; } } + +export function resolveMobileShellWebViewUrl( + rawUrl: string, + allowedOrigin: string, +) { + if ( + rawUrl === 'about:blank' || + !shouldOpenInMobileShellWebView(rawUrl, allowedOrigin) + ) { + return null; + } + + try { + return new URL(rawUrl, allowedOrigin).toString(); + } catch { + return null; + } +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index b7e4e87e..7b8eeb04 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -20,7 +20,7 @@ - 背景:后续需要移动端 App 和桌面端 App,但现有主站、固定玩法 runtime、小程序壳和未来 AI H5 sandbox 已经以 H5 为主线;如果移动端重写 React Native UI、桌面端重写 Rust/Tauri UI,会形成玩法、登录、支付、分享和运行态的多套实现。 - 决策:移动端原生壳采用 `Expo + React Native`,桌面端壳采用 `Tauri`。两者都只作为 `native_app` 宿主壳和 HostBridge adapter,不重写现有 React H5 主站,不把固定内置玩法迁到 React Native / Rust UI,也不让 AI 生成 H5 游戏直接访问完整 HostBridge。Expo 壳通过 `react-native-webview` 承接 H5 与 native 通信,Tauri 壳通过受控 command 和 capabilities 承接桌面能力;新增能力必须先进入 HostBridge 契约和测试。 -- 2026-06-17 首轮落地:新增 `packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/` 和 `apps/desktop-shell/`。壳只声明并实现真实可用能力;移动壳使用真实品牌图标资产并支持 `genarrative://`、iOS associated domain、Android app link 到同源 H5 路径,桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,登录、支付、桌面分享等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。 +- 2026-06-17 首轮落地:新增 `packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/nativeAppHostBridge.ts`、`apps/mobile-shell/` 和 `apps/desktop-shell/`。壳只声明并实现真实可用能力;移动壳使用真实品牌图标资产并支持 `genarrative://`、iOS associated domain、Android app link 到同源 H5 路径,`navigation.openNativePage` 只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的原生页面;桌面壳已通过 Tauri clipboard-manager 接入 `clipboard.writeText`,登录、支付、桌面分享等未接入真实 SDK / 插件前必须返回 unsupported 并让 H5 fallback,生产代码禁止 mock 成功。 - 影响范围:`src/services/host-bridge/`、未来 `apps/mobile-shell/`、未来 `apps/desktop-shell/`、移动端支付 / 分享 / 深链 / 推送、桌面端系统能力、AI H5 sandbox 的 GameBridge 边界。 - 验证方式:普通浏览器、小程序、Expo 壳、Tauri 壳都能返回正确 `getHostRuntime()`;未支持能力能回退 H5;固定玩法在各宿主中读取同一作品数据和运行态 snapshot;AI sandbox 无法直接调用 HostBridge;Tauri release 不允许任意远端页面调用桌面命令。 - 关联文档:`docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md`、`docs/【前端架构】宿主壳能力统一协议-2026-06-17.md`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index c1aee770..abd9b137 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -115,7 +115,7 @@ type HostBridgeEvent = { | `payment.request` | 发起宿主支付 | 依平台策略接入 | 桌面二维码 / 外部浏览器 | | `share.setTarget` | 同步当前作品分享目标 | 支持 | 未接入前不声明 | | `share.open` | 打开系统分享面板 | 支持 | 可先复制链接 / 打开系统分享 | -| `navigation.openNativePage` | 打开受控原生页面 | 支持 | 支持设置 / 关于等 | +| `navigation.openNativePage` | 打开受控宿主页 | 支持同源 H5 route | 支持设置 / 关于等 | | `app.openExternalUrl` | 用系统浏览器打开外链 | 支持 | 支持 | | `clipboard.writeText` | 写剪贴板 | 可选 | 可选 | | `haptics.impact` | 轻量触感反馈 | 可选 | 不支持 | @@ -238,7 +238,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`、`share.open`、`share.setTarget`、`app.openExternalUrl`、`clipboard.writeText`、`haptics.impact` 和 Android 返回键回退;登录、支付和原生页跳转尚未接入渠道 SDK 时明确返回 unsupported,让 H5 fallback 承接。 +当前状态:已新增 `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`、`share.open`、`share.setTarget`、`navigation.openNativePage`、`app.openExternalUrl`、`clipboard.writeText`、`haptics.impact` 和 Android 返回键回退;其中 `navigation.openNativePage` 在 Expo 壳内只接受同源 H5 route 并切换 WebView URL,不伪造尚未存在的登录、支付或其它原生页面。登录和支付尚未接入渠道 SDK 时明确返回 unsupported,让 H5 fallback 承接。 ### Phase 3:Tauri 桌面壳 MVP diff --git a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md index 29adf4b9..8d06472c 100644 --- a/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md +++ b/docs/【前端架构】宿主壳能力统一协议-2026-06-17.md @@ -44,7 +44,7 @@ AI H5 sandbox - `requestHostPayment()`:微信小程序支付跳转原生支付页;其它渠道返回 `false`,继续走 H5 / Native 二维码。 - `setHostShareTarget()`:把当前公开作品分享目标同步给宿主。 - `openHostShareGrid()`:微信小程序九宫格切图页。 -- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录等 adapter 复用。 +- `navigateHostNativePage()`:受控跳转宿主页,供订阅授权、支付、登录等 adapter 复用。Expo 移动壳首版只接受同源 H5 route 并切换 WebView URL;真正原生页面、登录和支付能力必须等对应 SDK / 页面接入后再声明支持。 ## 迁移顺序