From 0393e3fef3390eb2652053e147c9a65a7b195e0e Mon Sep 17 00:00:00 2001 From: kdletters Date: Sun, 21 Jun 2026 19:09:11 +0800 Subject: [PATCH] =?UTF-8?q?=E9=99=90=E5=88=B6=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E7=94=9F=E4=BA=A7=E6=9C=AC=E6=9C=BAH5=E5=85=A5=E5=8F=A3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 生产移动壳只允许公共 H5 主站入口 开发模式继续允许显式本机 H5 联调地址 补充移动壳 URL 与 ShellApp 测试和门禁 同步项目决策日志 --- apps/mobile-shell/scripts/check-config.mjs | 26 +++++++++++- apps/mobile-shell/src/shell/ShellApp.test.tsx | 42 ++++++++++++++++++- apps/mobile-shell/src/shell/ShellApp.tsx | 6 +++ apps/mobile-shell/src/shell/deepLink.ts | 15 ++++++- apps/mobile-shell/src/shell/url.test.ts | 24 +++++++++-- apps/mobile-shell/src/shell/url.ts | 25 ++++++++--- .../shared-memory/decision-log.md | 1 + 7 files changed, 126 insertions(+), 13 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 734dede5b..b91792217 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -1920,6 +1920,9 @@ if (shareSource.includes("const WEB_APP_ORIGIN = 'https://app.genarrative.world' for (const snippet of [ 'buildMobileShellUrl(', + 'MobileShellBaseWebUrlOptions', + 'allowLocalDevelopment?: boolean', + 'Boolean(options.allowLocalDevelopment)', 'HOST_BRIDGE_NATIVE_APP_QUERY', 'HOST_BRIDGE_NATIVE_APP_QUERY_KEY', 'HOST_BRIDGE_VERSION.toString()', @@ -1980,13 +1983,34 @@ for (const snippet of [ } } +if ( + !shellAppSource.includes('allowLocalDevelopment: __DEV__') || + !deepLinkSource.includes('baseWebUrlOptions: MobileShellBaseWebUrlOptions = {}') || + !deepLinkSource.includes('resolveMobileShellBaseWebUrl(\n baseWebUrl,\n baseWebUrlOptions,') +) { + throw new Error('mobile shell must only allow local H5 URLs through explicit development mode'); +} + +for (const snippet of [ + '移动壳基准 URL 默认只接受生产主站', + "expect(resolveMobileShellBaseWebUrl('http://127.0.0.1:3000/')).toBe(", + '移动壳基准 URL 只在开发模式接受本机入口', + '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)) { + throw new Error(`mobile shell local H5 URL tests must include ${snippet}`); + } +} + for (const snippet of [ 'MobileShellDeepLinkResolution', "status: 'default'", "status: 'mapped'", "status: 'rejected'", 'resolveMobileShellUrlFromDeepLink', - 'resolveMobileShellBaseWebUrl(baseWebUrl)', + 'resolveMobileShellBaseWebUrl(\n baseWebUrl,\n baseWebUrlOptions,', 'resolveTargetPath(rawUrl, webOrigin)', 'buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options)', ]) { diff --git a/apps/mobile-shell/src/shell/ShellApp.test.tsx b/apps/mobile-shell/src/shell/ShellApp.test.tsx index 3331a71ca..0c2a993e3 100644 --- a/apps/mobile-shell/src/shell/ShellApp.test.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.test.tsx @@ -222,10 +222,16 @@ vi.mock('react-native', () => ({ React.createElement('div', props, children), })); -async function importShellApp() { +async function importShellApp(isDev = true) { + vi.stubGlobal('__DEV__', isDev); + vi.resetModules(); return (await import('./ShellApp')).default; } +async function importShellAppWithDevFlag(isDev: boolean) { + return await importShellApp(isDev); +} + function buildRequest(): HostBridgeRequest { return { bridge: HOST_BRIDGE_PROTOCOL, @@ -276,11 +282,45 @@ function lastHostBridgeEvent(eventName: string) { afterEach(() => { shellHarness.reset(); + vi.unstubAllEnvs(); + vi.unstubAllGlobals(); vi.resetModules(); vi.clearAllMocks(); }); describe('ShellApp QR scanner HostBridge flow', () => { + test('production shell ignores local H5 URL env before opening WebView', async () => { + vi.stubEnv('EXPO_PUBLIC_GENARRATIVE_WEB_URL', 'http://127.0.0.1:3000/'); + const ShellApp = await importShellAppWithDevFlag(false); + + render(); + + const webViewProps = shellHarness.webViewProps.current as { + source?: { uri?: string }; + }; + const sourceUrl = new URL(webViewProps.source?.uri ?? ''); + + expect(sourceUrl.origin).toBe('https://app.genarrative.world'); + expect(sourceUrl.searchParams.get('clientRuntime')).toBe('native_app'); + expect(sourceUrl.searchParams.get('hostShell')).toBe('expo_mobile'); + }); + + test('development shell allows explicit local H5 URL env', async () => { + vi.stubEnv('EXPO_PUBLIC_GENARRATIVE_WEB_URL', 'http://127.0.0.1:3000/'); + const ShellApp = await importShellAppWithDevFlag(true); + + render(); + + const webViewProps = shellHarness.webViewProps.current as { + source?: { uri?: string }; + }; + const sourceUrl = new URL(webViewProps.source?.uri ?? ''); + + expect(sourceUrl.origin).toBe('http://127.0.0.1:3000'); + expect(sourceUrl.searchParams.get('clientRuntime')).toBe('native_app'); + expect(sourceUrl.searchParams.get('hostShell')).toBe('expo_mobile'); + }); + test('scanner.scanQrCode drives overlay camera scan and injects HostBridge response into WebView', async () => { const ShellApp = await importShellApp(); render(); diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index 28a580f5e..b5e924e04 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -110,6 +110,9 @@ export default function ShellApp() { const [canGoBack, setCanGoBack] = useState(false); const baseWebUrl = resolveMobileShellBaseWebUrl( process.env.EXPO_PUBLIC_GENARRATIVE_WEB_URL, + { + allowLocalDevelopment: __DEV__, + }, ); const urlOptions = useMemo( () => ({ @@ -242,6 +245,9 @@ export default function ShellApp() { url, baseWebUrl, urlOptions, + { + allowLocalDevelopment: __DEV__, + }, ); if (resolution.status === 'rejected') { logMobileShellDeepLinkFailure(`${source}.rejected`, url); diff --git a/apps/mobile-shell/src/shell/deepLink.ts b/apps/mobile-shell/src/shell/deepLink.ts index 4fed9976a..5683ede2a 100644 --- a/apps/mobile-shell/src/shell/deepLink.ts +++ b/apps/mobile-shell/src/shell/deepLink.ts @@ -1,5 +1,6 @@ import { buildMobileShellUrl, + type MobileShellBaseWebUrlOptions, resolveMobileShellBaseWebUrl, type MobileShellUrlOptions, } from './url'; @@ -59,16 +60,26 @@ export function buildMobileShellUrlFromDeepLink( rawUrl: string | null | undefined, baseWebUrl: string, options: MobileShellUrlOptions, + baseWebUrlOptions: MobileShellBaseWebUrlOptions = {}, ) { - return resolveMobileShellUrlFromDeepLink(rawUrl, baseWebUrl, options).url; + return resolveMobileShellUrlFromDeepLink( + rawUrl, + baseWebUrl, + options, + baseWebUrlOptions, + ).url; } export function resolveMobileShellUrlFromDeepLink( rawUrl: string | null | undefined, baseWebUrl: string, options: MobileShellUrlOptions, + baseWebUrlOptions: MobileShellBaseWebUrlOptions = {}, ): MobileShellDeepLinkResolution { - const normalizedBaseWebUrl = resolveMobileShellBaseWebUrl(baseWebUrl); + const normalizedBaseWebUrl = resolveMobileShellBaseWebUrl( + baseWebUrl, + baseWebUrlOptions, + ); const defaultUrl = buildMobileShellUrl(normalizedBaseWebUrl, options); if (!rawUrl) { return { diff --git a/apps/mobile-shell/src/shell/url.test.ts b/apps/mobile-shell/src/shell/url.test.ts index be8ca2e72..1fb175a50 100644 --- a/apps/mobile-shell/src/shell/url.test.ts +++ b/apps/mobile-shell/src/shell/url.test.ts @@ -89,17 +89,17 @@ describe('buildMobileShellUrl', () => { ); }); - test('移动壳基准 URL 只接受生产主站和本机开发入口', () => { + test('移动壳基准 URL 默认只接受生产主站', () => { expect( resolveMobileShellBaseWebUrl('https://app.genarrative.world/path'), ).toBe( 'https://app.genarrative.world/path', ); - expect(resolveMobileShellBaseWebUrl(' http://127.0.0.1:3000/ ')).toBe( - 'http://127.0.0.1:3000/', + expect(resolveMobileShellBaseWebUrl('http://127.0.0.1:3000/')).toBe( + DEFAULT_MOBILE_SHELL_WEB_URL, ); expect(resolveMobileShellBaseWebUrl('http://localhost:3000/')).toBe( - 'http://localhost:3000/', + DEFAULT_MOBILE_SHELL_WEB_URL, ); expect(resolveMobileShellBaseWebUrl('https://example.com/path')).toBe( DEFAULT_MOBILE_SHELL_WEB_URL, @@ -119,6 +119,22 @@ describe('buildMobileShellUrl', () => { ); }); + test('移动壳基准 URL 只在开发模式接受本机入口', () => { + const options = { + allowLocalDevelopment: true, + }; + + expect( + resolveMobileShellBaseWebUrl(' http://127.0.0.1:3000/ ', options), + ).toBe('http://127.0.0.1:3000/'); + expect(resolveMobileShellBaseWebUrl('http://localhost:3000/', options)).toBe( + 'http://localhost:3000/', + ); + expect(resolveMobileShellBaseWebUrl('http://[::1]:3000/', options)).toBe( + 'http://[::1]:3000/', + ); + }); + 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 c47d5a071..9b9e5c9a7 100644 --- a/apps/mobile-shell/src/shell/url.ts +++ b/apps/mobile-shell/src/shell/url.ts @@ -14,6 +14,10 @@ export type MobileShellUrlOptions = { capabilities: readonly HostBridgeCapability[]; }; +export type MobileShellBaseWebUrlOptions = { + allowLocalDevelopment?: boolean; +}; + export const DEFAULT_MOBILE_SHELL_WEB_URL = HOST_BRIDGE_PUBLIC_WEB_URL; export const ALLOWED_PRODUCTION_WEB_ORIGIN = HOST_BRIDGE_PUBLIC_WEB_ORIGIN; const LOCAL_DEVELOPMENT_WEB_HOSTS = new Set([ @@ -22,16 +26,23 @@ const LOCAL_DEVELOPMENT_WEB_HOSTS = new Set([ '[::1]', ]); -function isAllowedMobileShellBaseUrl(url: URL) { +function isAllowedMobileShellBaseUrl( + url: URL, + options: MobileShellBaseWebUrlOptions = {}, +) { if (url.origin === ALLOWED_PRODUCTION_WEB_ORIGIN) { return true; } - return url.protocol === 'http:' && + return Boolean(options.allowLocalDevelopment) && + url.protocol === 'http:' && LOCAL_DEVELOPMENT_WEB_HOSTS.has(url.hostname); } -export function resolveMobileShellBaseWebUrl(rawUrl: unknown) { +export function resolveMobileShellBaseWebUrl( + rawUrl: unknown, + options: MobileShellBaseWebUrlOptions = {}, +) { if (typeof rawUrl !== 'string') { return DEFAULT_MOBILE_SHELL_WEB_URL; } @@ -43,7 +54,7 @@ export function resolveMobileShellBaseWebUrl(rawUrl: unknown) { try { const url = new URL(value); - if (!isAllowedMobileShellBaseUrl(url)) { + if (!isAllowedMobileShellBaseUrl(url, options)) { return DEFAULT_MOBILE_SHELL_WEB_URL; } @@ -57,7 +68,11 @@ export function buildMobileShellUrl( rawUrl: string, options: MobileShellUrlOptions, ) { - const url = new URL(resolveMobileShellBaseWebUrl(rawUrl)); + const url = new URL( + resolveMobileShellBaseWebUrl(rawUrl, { + allowLocalDevelopment: true, + }), + ); 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 b6e9fb583..1ab683a87 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -189,6 +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-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 流程间接覆盖。