From 81c353e5865bb4cff1578f7f0517fbe15a0a2591 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 21 Jun 2026 19:19:27 +0800 Subject: [PATCH] =?UTF-8?q?=E6=94=B6=E7=B4=A7=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E6=9C=AC=E6=9C=BA=E5=85=A5=E5=8F=A3=E4=BC=A0=E9=80=92=E8=BE=B9?= =?UTF-8?q?=E7=95=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动壳 URL 构建默认拒绝本机 H5 入口 Deep Link 与原生页导航显式传递开发态入口选项 补充移动壳 URL 与 Deep Link 边界测试 同步原生壳门禁和项目决策日志 --- apps/mobile-shell/scripts/check-config.mjs | 18 +++++++--- .../src/host-bridge/navigation.ts | 6 +++- apps/mobile-shell/src/host-bridge/protocol.ts | 6 +++- apps/mobile-shell/src/shell/ShellApp.tsx | 14 +++++--- apps/mobile-shell/src/shell/deepLink.test.ts | 27 ++++++++++++++ apps/mobile-shell/src/shell/deepLink.ts | 12 +++++-- apps/mobile-shell/src/shell/url.test.ts | 35 +++++++++++++++++++ apps/mobile-shell/src/shell/url.ts | 7 ++-- .../shared-memory/decision-log.md | 2 +- scripts/check-native-shells.mjs | 3 +- 10 files changed, 111 insertions(+), 19 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index b91792217..d3cdc01a4 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -199,6 +199,8 @@ const urlTestPath = new URL('../src/shell/url.test.ts', import.meta.url); const urlTestSource = fs.readFileSync(urlTestPath, 'utf8'); const deepLinkPath = new URL('../src/shell/deepLink.ts', import.meta.url); const deepLinkSource = fs.readFileSync(deepLinkPath, 'utf8'); +const deepLinkTestPath = new URL('../src/shell/deepLink.test.ts', import.meta.url); +const deepLinkTestSource = fs.readFileSync(deepLinkTestPath, 'utf8'); const navigationPath = new URL('../src/shell/navigation.ts', import.meta.url); const navigationSource = fs.readFileSync(navigationPath, 'utf8'); const navigationTestPath = new URL('../src/shell/navigation.test.ts', import.meta.url); @@ -1985,6 +1987,7 @@ for (const snippet of [ if ( !shellAppSource.includes('allowLocalDevelopment: __DEV__') || + !shellAppSource.includes('baseWebUrlOptions') || !deepLinkSource.includes('baseWebUrlOptions: MobileShellBaseWebUrlOptions = {}') || !deepLinkSource.includes('resolveMobileShellBaseWebUrl(\n baseWebUrl,\n baseWebUrlOptions,') ) { @@ -1995,11 +1998,18 @@ for (const snippet of [ '移动壳基准 URL 默认只接受生产主站', "expect(resolveMobileShellBaseWebUrl('http://127.0.0.1:3000/')).toBe(", '移动壳基准 URL 只在开发模式接受本机入口', + '移动壳 URL 构建默认不会接受本机入口', + '移动壳 URL 构建只在显式开发模式接受本机入口', + '基准 H5 URL 只在显式开发模式保留本机 deep link 入口', 'allowLocalDevelopment: true', 'production shell ignores local H5 URL env before opening WebView', 'development shell allows explicit local H5 URL env', ]) { - if (!urlTestSource.includes(snippet) && !shellAppTestSource.includes(snippet)) { + if ( + !urlTestSource.includes(snippet) && + !deepLinkTestSource.includes(snippet) && + !shellAppTestSource.includes(snippet) + ) { throw new Error(`mobile shell local H5 URL tests must include ${snippet}`); } } @@ -2012,7 +2022,7 @@ for (const snippet of [ 'resolveMobileShellUrlFromDeepLink', 'resolveMobileShellBaseWebUrl(\n baseWebUrl,\n baseWebUrlOptions,', 'resolveTargetPath(rawUrl, webOrigin)', - 'buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options)', + 'buildMobileShellUrl(\n new URL(targetPath, webOrigin).toString(),\n options,\n baseWebUrlOptions,', ]) { if (!deepLinkSource.includes(snippet)) { throw new Error(`mobile shell deep link host-context flow missing ${snippet}`); @@ -2329,7 +2339,7 @@ for (const snippet of [ 'inFlightHostBridgeResponses', 'resolveMobileHostBridgeResponse', 'rememberHostBridgeResponse', - 'buildMobileShellUrl(webViewUrl, navigation.urlOptions)', + 'buildMobileShellUrl(\n webViewUrl,\n navigation.urlOptions,\n navigation.baseWebUrlOptions,', ]) { if (!hostBridgeSource.includes(snippet)) { throw new Error(`mobile shell HostBridge missing ${snippet}`); @@ -3124,7 +3134,7 @@ for (const snippet of [ 'openMobileHostBridgeNativePage', '(request.payload as NavigateNativePagePayload | undefined)?.url', 'resolveMobileShellWebViewUrl', - 'buildMobileShellUrl(webViewUrl, navigation.urlOptions)', + 'buildMobileShellUrl(\n webViewUrl,\n navigation.urlOptions,\n navigation.baseWebUrlOptions,', 'reloadMobileHostBridgeWebView', 'ok(request, true)', ]) { diff --git a/apps/mobile-shell/src/host-bridge/navigation.ts b/apps/mobile-shell/src/host-bridge/navigation.ts index 482abb55f..12ece029c 100644 --- a/apps/mobile-shell/src/host-bridge/navigation.ts +++ b/apps/mobile-shell/src/host-bridge/navigation.ts @@ -76,7 +76,11 @@ export function openMobileHostBridgeNativePage( } navigation.openWebViewUrl( - buildMobileShellUrl(webViewUrl, navigation.urlOptions), + buildMobileShellUrl( + webViewUrl, + navigation.urlOptions, + navigation.baseWebUrlOptions, + ), ); return ok(request, true); } diff --git a/apps/mobile-shell/src/host-bridge/protocol.ts b/apps/mobile-shell/src/host-bridge/protocol.ts index d5b8b2a58..73443bc48 100644 --- a/apps/mobile-shell/src/host-bridge/protocol.ts +++ b/apps/mobile-shell/src/host-bridge/protocol.ts @@ -8,7 +8,10 @@ import { isHostBridgeMethod, normalizeHostBridgeRequestId, } from '../../../../packages/shared/src/contracts/hostBridge'; -import type { MobileShellUrlOptions } from '../shell/url'; +import type { + MobileShellBaseWebUrlOptions, + MobileShellUrlOptions, +} from '../shell/url'; const HOST_BRIDGE_ERROR_CODES = new Set([ 'invalid_request', @@ -22,6 +25,7 @@ const HOST_BRIDGE_ERROR_CODES = new Set([ export type MobileHostBridgeNavigation = { allowedOrigin: string; urlOptions: MobileShellUrlOptions; + baseWebUrlOptions?: MobileShellBaseWebUrlOptions; 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 b5e924e04..41b720140 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -114,6 +114,12 @@ export default function ShellApp() { allowLocalDevelopment: __DEV__, }, ); + const baseWebUrlOptions = useMemo( + () => ({ + allowLocalDevelopment: __DEV__, + }), + [], + ); const urlOptions = useMemo( () => ({ platform: Platform.OS === 'ios' ? 'ios' as const : 'android' as const, @@ -123,7 +129,7 @@ export default function ShellApp() { [], ); const [webUrl, setWebUrl] = useState(() => - buildMobileShellUrl(baseWebUrl, urlOptions), + buildMobileShellUrl(baseWebUrl, urlOptions, baseWebUrlOptions), ); const [loadFailure, setLoadFailure] = useState(null); @@ -216,6 +222,7 @@ export default function ShellApp() { configureMobileHostBridgeNavigation({ allowedOrigin: allowedWebOrigin, urlOptions, + baseWebUrlOptions, openWebViewUrl(url) { resetWebViewProcessFailureWindow(); resetNavigationCanGoBack(); @@ -228,6 +235,7 @@ export default function ShellApp() { return () => configureMobileHostBridgeNavigation(null); }, [ allowedWebOrigin, + baseWebUrlOptions, reloadCurrentWebView, resetNavigationCanGoBack, resetWebViewProcessFailureWindow, @@ -245,9 +253,7 @@ export default function ShellApp() { url, baseWebUrl, urlOptions, - { - allowLocalDevelopment: __DEV__, - }, + baseWebUrlOptions, ); if (resolution.status === 'rejected') { logMobileShellDeepLinkFailure(`${source}.rejected`, url); diff --git a/apps/mobile-shell/src/shell/deepLink.test.ts b/apps/mobile-shell/src/shell/deepLink.test.ts index a6845bbc5..aa4a70634 100644 --- a/apps/mobile-shell/src/shell/deepLink.test.ts +++ b/apps/mobile-shell/src/shell/deepLink.test.ts @@ -92,6 +92,33 @@ describe('buildMobileShellUrlFromDeepLink', () => { expect(url.searchParams.get('clientRuntime')).toBe('native_app'); }); + test('基准 H5 URL 只在显式开发模式保留本机 deep link 入口', () => { + const productionUrl = new URL( + buildMobileShellUrlFromDeepLink( + 'http://127.0.0.1:3000/works/detail?work=PZ-1', + 'http://127.0.0.1:3000/', + options, + ), + ); + const developmentUrl = new URL( + buildMobileShellUrlFromDeepLink( + 'http://127.0.0.1:3000/works/detail?work=PZ-1', + 'http://127.0.0.1:3000/', + options, + { + allowLocalDevelopment: true, + }, + ), + ); + + expect(productionUrl.origin).toBe('https://app.genarrative.world'); + expect(productionUrl.pathname).toBe('/'); + expect(developmentUrl.origin).toBe('http://127.0.0.1:3000'); + expect(developmentUrl.pathname).toBe('/works/detail'); + expect(developmentUrl.searchParams.get('work')).toBe('PZ-1'); + expect(developmentUrl.searchParams.get('clientRuntime')).toBe('native_app'); + }); + test('返回 deep link 解析状态用于壳层记录拒绝路径', () => { const mapped = resolveMobileShellUrlFromDeepLink( 'genarrative://open/works/detail?work=PZ-1', diff --git a/apps/mobile-shell/src/shell/deepLink.ts b/apps/mobile-shell/src/shell/deepLink.ts index 5683ede2a..a0e64b91d 100644 --- a/apps/mobile-shell/src/shell/deepLink.ts +++ b/apps/mobile-shell/src/shell/deepLink.ts @@ -80,7 +80,11 @@ export function resolveMobileShellUrlFromDeepLink( baseWebUrl, baseWebUrlOptions, ); - const defaultUrl = buildMobileShellUrl(normalizedBaseWebUrl, options); + const defaultUrl = buildMobileShellUrl( + normalizedBaseWebUrl, + options, + baseWebUrlOptions, + ); if (!rawUrl) { return { status: 'default', @@ -99,6 +103,10 @@ export function resolveMobileShellUrlFromDeepLink( return { status: 'mapped', - url: buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options), + url: buildMobileShellUrl( + new URL(targetPath, webOrigin).toString(), + options, + baseWebUrlOptions, + ), }; } diff --git a/apps/mobile-shell/src/shell/url.test.ts b/apps/mobile-shell/src/shell/url.test.ts index 1fb175a50..abbeef3bd 100644 --- a/apps/mobile-shell/src/shell/url.test.ts +++ b/apps/mobile-shell/src/shell/url.test.ts @@ -135,6 +135,41 @@ describe('buildMobileShellUrl', () => { ); }); + test('移动壳 URL 构建默认不会接受本机入口', () => { + const url = new URL( + buildMobileShellUrl('http://127.0.0.1:3000/works/detail?work=PZ-1', { + platform: 'android', + hostVersion: '0.1.0', + capabilities: ['host.getRuntime'], + }), + ); + + expect(url.origin).toBe(HOST_BRIDGE_PUBLIC_WEB_ORIGIN); + expect(url.pathname).toBe('/'); + expect(url.searchParams.get('clientRuntime')).toBe('native_app'); + }); + + test('移动壳 URL 构建只在显式开发模式接受本机入口', () => { + const url = new URL( + buildMobileShellUrl( + 'http://127.0.0.1:3000/works/detail?work=PZ-1', + { + platform: 'android', + hostVersion: '0.1.0', + capabilities: ['host.getRuntime'], + }, + { + allowLocalDevelopment: true, + }, + ), + ); + + expect(url.origin).toBe('http://127.0.0.1:3000'); + expect(url.pathname).toBe('/works/detail'); + expect(url.searchParams.get('work')).toBe('PZ-1'); + expect(url.searchParams.get('clientRuntime')).toBe('native_app'); + }); + test('非法启动 URL 回退到默认 H5 地址并继续附加宿主上下文', () => { const url = new URL( buildMobileShellUrl('javascript:alert(1)', { diff --git a/apps/mobile-shell/src/shell/url.ts b/apps/mobile-shell/src/shell/url.ts index 9b9e5c9a7..f13d977fd 100644 --- a/apps/mobile-shell/src/shell/url.ts +++ b/apps/mobile-shell/src/shell/url.ts @@ -67,12 +67,9 @@ export function resolveMobileShellBaseWebUrl( export function buildMobileShellUrl( rawUrl: string, options: MobileShellUrlOptions, + baseWebUrlOptions: MobileShellBaseWebUrlOptions = {}, ) { - const url = new URL( - resolveMobileShellBaseWebUrl(rawUrl, { - allowLocalDevelopment: true, - }), - ); + const url = new URL(resolveMobileShellBaseWebUrl(rawUrl, baseWebUrlOptions)); url.searchParams.set( HOST_BRIDGE_NATIVE_APP_QUERY_KEY.clientRuntime, HOST_BRIDGE_NATIVE_APP_QUERY.clientRuntime, diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index 1ab683a87..dc59f6eb4 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -189,7 +189,7 @@ - 2026-06-20 桌面 HostBridge replay 内部失败边界:Tauri `HostBridgeReplayState` 的 cache lock、slot lock 和 condvar wait 异常不得 panic,也不得把 Rust 内部错误细节回传给 H5;桌面壳只写 `desktop host bridge replay failed for ...` 固定阶段标签,不把 mutex / condvar 错误文本写入 stderr,并统一返回 `host_error: desktop host bridge request failed`。桌面配置检查反查 `reserve(...)` 的 `Result` 出口、稳定错误响应、label-only 诊断和 poison lock 单测。 - 2026-06-20 移动 HostBridge runtime 能力回包边界:Expo `host.getRuntime` 回包里的 `capabilities` 必须直接等于共享契约 `HOST_BRIDGE_EXPO_MOBILE_BASE_CAPABILITIES` 或 `HOST_BRIDGE_EXPO_MOBILE_IOS_CAPABILITIES`,并使用与 `platform` 字段一致的归一平台值选择 profile;移动壳 runtime 单测和配置检查反查精确 profile 断言,避免 H5 实际消费的能力回包与入口 URL 能力 query 或共享 profile 分叉。 - 2026-06-20 移动 production bundle 宿主上下文边界:`apps/mobile-shell/scripts/check-expo-export.mjs` 必须读取 iOS / Android Metro export bundle,确认可分发 bundle metadata 是 Metro version 0、只包含当前平台 `fileMetadata`、指向 Hermes `AppEntry-*.hbc`,且 bundle 包含共享生产 H5 URL、`native_app`、`expo_mobile`、`hostCapabilities`、`hostVersion` 和 `bridgeVersion`,并不包含本机开发 H5 URL;移动壳配置检查反查 export smoke 的这些 token 和 metadata 结构门禁,避免 production bundle 丢失宿主上下文、混入本机入口或导出形态漂移。 -- 2026-06-21 移动壳本机 H5 入口边界:`EXPO_PUBLIC_GENARRATIVE_WEB_URL` 只允许生产主站或开发态显式本机 H5 联调地址;`ShellApp` 必须用 `__DEV__` 控制 `allowLocalDevelopment`,production runtime 遇到 `127.0.0.1`、`localhost` 或 `::1` 时回退到共享生产主站。Deep Link 入口复用同一基准 URL 归一选项,避免可分发移动壳被环境变量带到本机调试页面。 +- 2026-06-21 移动壳本机 H5 入口边界:`EXPO_PUBLIC_GENARRATIVE_WEB_URL` 只允许生产主站或开发态显式本机 H5 联调地址;`ShellApp` 必须用 `__DEV__` 控制 `allowLocalDevelopment`,production runtime 遇到 `127.0.0.1`、`localhost` 或 `::1` 时回退到共享生产主站。`buildMobileShellUrl(...)` 默认不得隐式放行本机入口,Deep Link 和 `navigation.openNativePage` 必须显式传递同一基准 URL 归一选项,避免可分发移动壳被环境变量、deep link 或 HostBridge 导航带到本机调试页面。 - 2026-06-20 桌面 release 主窗口宿主上下文边界:Tauri release 配置只保留基础 `index.html`,Rust app 装配层必须在主窗口启动配置单测里断言补齐 `clientRuntime`、`clientType`、`hostShell`、`hostPlatform`、`hostVersion`、`bridgeVersion` 和 `hostCapabilities`;桌面单端配置检查反查这些断言存在,避免首屏 H5 丢失桌面壳运行态 query 后只靠 runtime 回读补救。 - 2026-06-20 移动壳协议 helper 单测边界:`apps/mobile-shell/src/host-bridge/protocol.test.ts` 直接覆盖 Expo 移动壳 HostBridge JSON 解析、envelope 和 request id 校验、未知 method 拒绝、ok / failure 响应包装、unsupported / invalid_request 错误构造,以及 native helper 错误归一时只透传共享错误码与字符串 message,不泄露非法错误码、nativeStack 或其它私有字段;根级 `npm run check:native-shells` 会把该测试文件列入移动桥接层结构清单,避免协议边界只靠完整 bridge 流程间接覆盖。 - 2026-06-20 移动扫码 overlay 单测边界:`apps/mobile-shell/src/shell/QrScannerOverlay.test.tsx` 直接覆盖移动扫码 overlay 的相机权限请求、二维码扫码成功、权限拒绝失败和关闭取消;单端配置检查会反查该组件测试存在,根级 `npm run check:native-shells` 会把该测试文件列入移动 shell 层结构清单,避免扫码 UI 容器只靠 `ShellApp.test.tsx` 的完整 HostBridge 流程间接覆盖。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index e6ce37ab3..75d09b560 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -341,7 +341,8 @@ const mobileCapabilityFlowContracts = [ ], snippets: [ ['apps/mobile-shell/src/host-bridge/dispatch.ts', 'openMobileHostBridgeNativePage(request, navigation)'], - ['apps/mobile-shell/src/host-bridge/navigation.ts', 'buildMobileShellUrl(webViewUrl, navigation.urlOptions)'], + ['apps/mobile-shell/src/host-bridge/navigation.ts', 'navigation.baseWebUrlOptions'], + ['apps/mobile-shell/src/host-bridge/navigation.ts', 'buildMobileShellUrl(\n webViewUrl,\n navigation.urlOptions,\n navigation.baseWebUrlOptions,'], ['apps/mobile-shell/src/shell/ShellApp.tsx', 'openWebViewUrl(url)'], ['apps/mobile-shell/src/shell/ShellApp.tsx', 'setWebUrl(url)'], ['apps/mobile-shell/scripts/check-config.mjs', 'navigation.openNativePage'],