From b96be76a341ecdeebd9ee6cc19433ee43ba792b8 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 19:12:54 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=9D=E6=8C=81=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E4=B8=BB=E5=8A=A8=E5=AF=BC=E8=88=AA=E5=AE=BF=E4=B8=BB=E4=B8=8A?= =?UTF-8?q?=E4=B8=8B=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HostBridge 主动导航同源页面时重新附加 native_app 宿主参数 补充移动壳导航测试覆盖平台版本和能力清单 同步 Expo/Tauri 宿主壳方案和团队决策记录 --- .../src/host-bridge/bridge.test.ts | 23 +++++++++++++++++-- apps/mobile-shell/src/host-bridge/dispatch.ts | 5 +++- apps/mobile-shell/src/host-bridge/protocol.ts | 2 ++ apps/mobile-shell/src/shell/ShellApp.tsx | 3 ++- .../shared-memory/decision-log.md | 7 ++++++ ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 +- 6 files changed, 37 insertions(+), 5 deletions(-) diff --git a/apps/mobile-shell/src/host-bridge/bridge.test.ts b/apps/mobile-shell/src/host-bridge/bridge.test.ts index 1829b858f..1b03ffdde 100644 --- a/apps/mobile-shell/src/host-bridge/bridge.test.ts +++ b/apps/mobile-shell/src/host-bridge/bridge.test.ts @@ -21,6 +21,7 @@ import { type HostBridgeRequest, type HostBridgeResponse, } from '../../../../packages/shared/src/contracts/hostBridge'; +import type { MobileShellUrlOptions } from '../shell/url'; import { configureMobileHostBridgeNavigation, handleMobileHostBridgeMessage, @@ -46,6 +47,12 @@ const DENIED_NOTIFICATION_PERMISSION = { let requestSequence = 0; +const TEST_MOBILE_URL_OPTIONS: MobileShellUrlOptions = { + platform: 'ios', + hostVersion: '0.1.0', + capabilities: ['host.getRuntime', 'share.open'], +}; + vi.mock('expo-clipboard', () => ({ getStringAsync: vi.fn(), setStringAsync: vi.fn(), @@ -388,6 +395,7 @@ describe('handleMobileHostBridgeMessage', () => { const openWebViewUrl = vi.fn(); configureMobileHostBridgeNavigation({ allowedOrigin: 'https://app.genarrative.world', + urlOptions: TEST_MOBILE_URL_OPTIONS, openWebViewUrl, reloadWebView: vi.fn(), }); @@ -399,14 +407,24 @@ describe('handleMobileHostBridgeMessage', () => { ); expectOk(response); - expect(openWebViewUrl).toHaveBeenCalledWith( - 'https://app.genarrative.world/works/detail?work=PZ-1', + const openedUrl = new URL(openWebViewUrl.mock.calls[0]?.[0] as string); + expect(openedUrl.origin).toBe('https://app.genarrative.world'); + expect(openedUrl.pathname).toBe('/works/detail'); + expect(openedUrl.searchParams.get('work')).toBe('PZ-1'); + expect(openedUrl.searchParams.get('clientRuntime')).toBe('native_app'); + expect(openedUrl.searchParams.get('hostShell')).toBe('expo_mobile'); + expect(openedUrl.searchParams.get('hostPlatform')).toBe('ios'); + expect(openedUrl.searchParams.get('hostVersion')).toBe('0.1.0'); + expect(openedUrl.searchParams.get('bridgeVersion')).toBe('1'); + expect(openedUrl.searchParams.get('hostCapabilities')).toBe( + 'host.getRuntime,share.open', ); }); test('navigation.openNativePage 拒绝外域目标', async () => { configureMobileHostBridgeNavigation({ allowedOrigin: 'https://app.genarrative.world', + urlOptions: TEST_MOBILE_URL_OPTIONS, openWebViewUrl: vi.fn(), reloadWebView: vi.fn(), }); @@ -475,6 +493,7 @@ describe('handleMobileHostBridgeMessage', () => { const reloadWebView = vi.fn(); configureMobileHostBridgeNavigation({ allowedOrigin: 'https://app.genarrative.world', + urlOptions: TEST_MOBILE_URL_OPTIONS, openWebViewUrl: vi.fn(), reloadWebView, }); diff --git a/apps/mobile-shell/src/host-bridge/dispatch.ts b/apps/mobile-shell/src/host-bridge/dispatch.ts index a73fc9235..3231db564 100644 --- a/apps/mobile-shell/src/host-bridge/dispatch.ts +++ b/apps/mobile-shell/src/host-bridge/dispatch.ts @@ -46,6 +46,7 @@ import { unsupported, } from './protocol'; import { openShare } from './share'; +import { buildMobileShellUrl } from '../shell/url'; const LOCAL_NOTIFICATION_CHANNEL_ID = 'genarrative-local'; @@ -222,7 +223,9 @@ function openNativePage(payload: unknown) { throw invalidRequest('url must be an allowed same-origin web path'); } - navigation.openWebViewUrl(webViewUrl); + navigation.openWebViewUrl( + buildMobileShellUrl(webViewUrl, navigation.urlOptions), + ); return true; } diff --git a/apps/mobile-shell/src/host-bridge/protocol.ts b/apps/mobile-shell/src/host-bridge/protocol.ts index 3b352cee6..1c6cc6c82 100644 --- a/apps/mobile-shell/src/host-bridge/protocol.ts +++ b/apps/mobile-shell/src/host-bridge/protocol.ts @@ -8,11 +8,13 @@ import { isHostBridgeMethod, normalizeHostBridgeRequestId, } from '../../../../packages/shared/src/contracts/hostBridge'; +import type { MobileShellUrlOptions } from '../shell/url'; export const HOST_BRIDGE_RESPONSE_CACHE_MAX = 128; export type MobileHostBridgeNavigation = { allowedOrigin: string; + urlOptions: MobileShellUrlOptions; openWebViewUrl: (url: string) => void; reloadWebView: () => void; }; diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index 34369ccce..e857a929a 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -89,6 +89,7 @@ export default function ShellApp() { useEffect(() => { configureMobileHostBridgeNavigation({ allowedOrigin: allowedWebOrigin, + urlOptions, openWebViewUrl(url) { setWebUrl(url); }, @@ -96,7 +97,7 @@ export default function ShellApp() { }); return () => configureMobileHostBridgeNavigation(null); - }, [allowedWebOrigin, reloadCurrentWebView]); + }, [allowedWebOrigin, reloadCurrentWebView, urlOptions]); useEffect(() => { let disposed = false; diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 6928e266f..e9db5f161 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2485,3 +2485,10 @@ - 决策:`SquareHoleResultView` 图片槽位弹窗在 `native_app` 且宿主声明 `file.importImage` 时优先调用 `importHostImageFile()`,把宿主返回的图片内容副本转换为 `data:;base64,` 并写回当前槽位 `imageSrc`。该动作继续走现有 result edit state、自动保存、试玩和发布链路,不新增后端上传路径,不暴露设备 URI、本机绝对路径或通用文件系统;普通浏览器、小程序和未声明能力的裁剪壳继续使用原浏览器文件输入。 - 影响范围:`src/components/square-hole-result/SquareHoleResultView.tsx`、`src/services/host-bridge/hostBridge.ts`、方洞玩法链路文档与 Expo / Tauri HostBridge 方案文档。 - 验证方式:`npm run test -- src/components/square-hole-result/SquareHoleResultView.test.tsx`、`npm run typecheck -- --pretty false`、`npm run check:encoding`、`npm run check:native-shells`、`git diff --check`。 + +## 2026-06-18 移动壳主动导航保留宿主上下文 + +- 背景:Expo 移动壳启动 URL 和 deep link 已经会给 H5 追加 `native_app`、`expo_mobile`、平台、版本和 capability query;但 H5 通过 HostBridge 调用 `navigation.openNativePage` 主动跳转同源 route 时,壳层只把裸同源 URL 交给 WebView,目标页首屏可能短暂或持续按普通浏览器运行态识别。 +- 决策:`navigation.openNativePage` 仍只接受同源 H5 route,不新增真实原生页面、不放宽外域导航;通过校验后的目标 URL 在进入 WebView 前统一调用 `buildMobileShellUrl(...)` 重新附加当前 `MobileShellUrlOptions`,确保主动导航后的页面继续带 `clientRuntime=native_app`、`hostShell=expo_mobile`、真实平台、宿主版本和当前 capability 清单。 +- 影响范围:`apps/mobile-shell/src/host-bridge/`、`apps/mobile-shell/src/shell/ShellApp.tsx`、Expo / Tauri HostBridge 方案文档。 +- 验证方式:`npm run mobile-shell:test`、`npm run mobile-shell:typecheck`、`npm run check:native-shells`、`npm run typecheck -- --pretty false`、`npm run check:encoding`、`git diff --check`。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 58232f0b7..32342f963 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -274,7 +274,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 背景音乐和拼图、抓大鹅等固定玩法 `