From fc9a557978ebddec5d20461ad8a7efcfffe01188 Mon Sep 17 00:00:00 2001 From: kdletters Date: Sat, 20 Jun 2026 18:57:13 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E9=BD=90=E7=A7=BB=E5=8A=A8=E5=A3=B3?= =?UTF-8?q?=E6=B7=B1=E9=93=BE=E5=A4=B1=E8=B4=A5=E8=A7=82=E6=B5=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 移动壳 Deep Link 解析返回状态并记录拒绝路径 ShellApp 记录初始链接读取失败且保留安全入口 移动壳配置检查和文档同步深链失败边界 --- apps/mobile-shell/scripts/check-config.mjs | 14 ++++- apps/mobile-shell/src/shell/ShellApp.test.tsx | 62 ++++++++++++++++++- apps/mobile-shell/src/shell/ShellApp.tsx | 38 +++++++++--- apps/mobile-shell/src/shell/deepLink.test.ts | 30 ++++++++- apps/mobile-shell/src/shell/deepLink.ts | 30 ++++++++- .../shared-memory/decision-log.md | 7 +++ ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 +- 7 files changed, 166 insertions(+), 17 deletions(-) diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index c543fa117..18f7b21b9 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -1385,7 +1385,10 @@ if (appConfig.extra?.genarrativeHostBridgeVersion !== sharedHostBridgeVersion) { for (const snippet of [ 'Linking.getInitialURL()', "Linking.addEventListener('url'", - 'buildMobileShellUrlFromDeepLink', + 'resolveMobileShellUrlFromDeepLink', + 'logMobileShellDeepLinkFailure', + "'initial_url.read'", + "logMobileShellDeepLinkFailure(`${source}.rejected`, url)", 'configureMobileHostBridgeNavigation', 'HOST_BRIDGE_PROTOCOL', 'HOST_BRIDGE_VERSION', @@ -1805,6 +1808,11 @@ for (const snippet of [ } for (const snippet of [ + 'MobileShellDeepLinkResolution', + "status: 'default'", + "status: 'mapped'", + "status: 'rejected'", + 'resolveMobileShellUrlFromDeepLink', 'resolveMobileShellBaseWebUrl(baseWebUrl)', 'resolveTargetPath(rawUrl, webOrigin)', 'buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options)', @@ -2497,6 +2505,10 @@ for (const snippet of [ 'external WebView navigation native failures stay outside the WebView', "expect(Linking.openURL).toHaveBeenCalledWith(", "'mobile shell navigation failed for external_navigation.open'", + 'initial deep link read failures are logged without replacing the current WebView URL', + "'mobile shell deep link failed for initial_url.read'", + 'runtime deep link rejections are logged and fall back to a safe WebView URL', + "'mobile shell deep link failed for runtime_url.rejected'", 'expect.any(Error)', ]) { if (!shellAppTestSource.includes(snippet)) { diff --git a/apps/mobile-shell/src/shell/ShellApp.test.tsx b/apps/mobile-shell/src/shell/ShellApp.test.tsx index 05d2369ac..ec822bf38 100644 --- a/apps/mobile-shell/src/shell/ShellApp.test.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.test.tsx @@ -17,6 +17,7 @@ const shellHarness = vi.hoisted(() => { const cameraViewProps = { current: null as Record | null }; const injectJavaScriptError = { current: null as Error | null }; const injectedScripts = [] as string[]; + const linkingUrlListeners = [] as Array<(event: { url: string }) => void>; const networkListeners = [] as Array<(state: Record) => void>; return { @@ -31,8 +32,10 @@ const shellHarness = vi.hoisted(() => { cameraViewProps.current = null; injectJavaScriptError.current = null; injectedScripts.length = 0; + linkingUrlListeners.length = 0; networkListeners.length = 0; }, + linkingUrlListeners, webViewProps, }; }); @@ -184,7 +187,10 @@ vi.mock('react-native', () => ({ addEventListener: vi.fn(() => ({ remove: vi.fn() })), }, Linking: { - addEventListener: vi.fn(() => ({ remove: vi.fn() })), + addEventListener: vi.fn((_event, listener) => { + shellHarness.linkingUrlListeners.push(listener); + return { remove: vi.fn() }; + }), canOpenURL: vi.fn(async () => true), getInitialURL: vi.fn(async () => null), openURL: vi.fn(async () => undefined), @@ -613,4 +619,58 @@ describe('ShellApp HostBridge event injection', () => { warnSpy.mockRestore(); }); + + test('initial deep link read failures are logged without replacing the current WebView URL', async () => { + const { Linking } = await import('react-native'); + const initialUrlError = new Error('initial URL unavailable'); + vi.mocked(Linking.getInitialURL).mockRejectedValueOnce(initialUrlError); + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const ShellApp = await importShellApp(); + + render(); + + const webViewProps = shellHarness.webViewProps.current as { + source?: { uri?: string }; + }; + const initialWebUrl = webViewProps.source?.uri; + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith( + 'mobile shell deep link failed for initial_url.read', + initialUrlError, + ); + }); + expect(shellHarness.webViewProps.current?.source).toEqual({ + uri: initialWebUrl, + }); + + warnSpy.mockRestore(); + }); + + test('runtime deep link rejections are logged and fall back to a safe WebView URL', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + const ShellApp = await importShellApp(); + + render(); + const webViewProps = shellHarness.webViewProps.current as { + source?: { uri?: string }; + }; + const initialWebUrl = webViewProps.source?.uri; + + shellHarness.linkingUrlListeners[0]?.({ + url: 'https://outside.example/works/detail?work=PZ-1', + }); + + await waitFor(() => { + expect(warnSpy).toHaveBeenCalledWith( + 'mobile shell deep link failed for runtime_url.rejected', + 'https://outside.example/works/detail?work=PZ-1', + ); + }); + expect(shellHarness.webViewProps.current?.source).toEqual({ + uri: initialWebUrl, + }); + + warnSpy.mockRestore(); + }); }); diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index dcfb5813a..0ecd53663 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -25,7 +25,7 @@ import { handleMobileHostBridgeMessage, resolveMobileHostCapabilities, } from '../host-bridge/bridge'; -import { buildMobileShellUrlFromDeepLink } from './deepLink'; +import { resolveMobileShellUrlFromDeepLink } from './deepLink'; import { lifecyclePayloadFromAppState } from './lifecycle'; import { type MobileShellLoadFailure, @@ -71,6 +71,10 @@ function logMobileShellNavigationFailure(label: string, error: unknown) { console.warn(`mobile shell navigation failed for ${label}`, error); } +function logMobileShellDeepLinkFailure(label: string, error: unknown) { + console.warn(`mobile shell deep link failed for ${label}`, error); +} + type MobileWebViewLoadErrorEvent = { nativeEvent: { url: string; @@ -206,25 +210,39 @@ export default function ShellApp() { useEffect(() => { let disposed = false; - const openDeepLink = (url: string | null | undefined) => { - const nextUrl = buildMobileShellUrlFromDeepLink( + const openDeepLink = ( + url: string | null | undefined, + source: 'initial_url' | 'runtime_url', + ) => { + const resolution = resolveMobileShellUrlFromDeepLink( url, baseWebUrl, urlOptions, ); + if (resolution.status === 'rejected') { + logMobileShellDeepLinkFailure(`${source}.rejected`, url); + } resetNavigationCanGoBack(); setLoadFailure(null); - setWebUrl(nextUrl); + setWebUrl(resolution.url); }; - void Linking.getInitialURL().then((url) => { - if (!disposed) { - openDeepLink(url); - } - }); + void Linking.getInitialURL() + .then((url) => { + if (!disposed) { + openDeepLink(url, 'initial_url'); + } + }) + .catch((error: unknown) => { + logMobileShellDeepLinkFailure('initial_url.read', error); + }); const subscription = Linking.addEventListener('url', (event) => { - openDeepLink(event.url); + try { + openDeepLink(event.url, 'runtime_url'); + } catch (error) { + logMobileShellDeepLinkFailure('runtime_url.open', error); + } }); return () => { diff --git a/apps/mobile-shell/src/shell/deepLink.test.ts b/apps/mobile-shell/src/shell/deepLink.test.ts index f28db0373..a6845bbc5 100644 --- a/apps/mobile-shell/src/shell/deepLink.test.ts +++ b/apps/mobile-shell/src/shell/deepLink.test.ts @@ -1,6 +1,9 @@ import { describe, expect, test } from 'vitest'; -import { buildMobileShellUrlFromDeepLink } from './deepLink'; +import { + buildMobileShellUrlFromDeepLink, + resolveMobileShellUrlFromDeepLink, +} from './deepLink'; const options = { platform: 'ios' as const, @@ -88,4 +91,29 @@ describe('buildMobileShellUrlFromDeepLink', () => { expect(url.searchParams.get('work')).toBe('PZ-1'); expect(url.searchParams.get('clientRuntime')).toBe('native_app'); }); + + test('返回 deep link 解析状态用于壳层记录拒绝路径', () => { + const mapped = resolveMobileShellUrlFromDeepLink( + 'genarrative://open/works/detail?work=PZ-1', + 'https://app.genarrative.world/', + options, + ); + const rejected = resolveMobileShellUrlFromDeepLink( + 'https://example.com/works/detail?work=PZ-1', + 'https://app.genarrative.world/', + options, + ); + const empty = resolveMobileShellUrlFromDeepLink( + null, + 'https://app.genarrative.world/', + options, + ); + + expect(mapped.status).toBe('mapped'); + expect(new URL(mapped.url).pathname).toBe('/works/detail'); + expect(rejected.status).toBe('rejected'); + expect(new URL(rejected.url).pathname).toBe('/'); + expect(empty.status).toBe('default'); + expect(new URL(empty.url).pathname).toBe('/'); + }); }); diff --git a/apps/mobile-shell/src/shell/deepLink.ts b/apps/mobile-shell/src/shell/deepLink.ts index 599411dc9..4fed9976a 100644 --- a/apps/mobile-shell/src/shell/deepLink.ts +++ b/apps/mobile-shell/src/shell/deepLink.ts @@ -6,6 +6,13 @@ import { const supportedHosts = new Set(['open', 'app']); +type MobileShellDeepLinkResolutionStatus = 'default' | 'mapped' | 'rejected'; + +export type MobileShellDeepLinkResolution = { + status: MobileShellDeepLinkResolutionStatus; + url: string; +}; + function extractPathFromNativeUrl(url: URL) { if (supportedHosts.has(url.hostname)) { return `${url.pathname}${url.search}${url.hash}`; @@ -53,17 +60,34 @@ export function buildMobileShellUrlFromDeepLink( baseWebUrl: string, options: MobileShellUrlOptions, ) { + return resolveMobileShellUrlFromDeepLink(rawUrl, baseWebUrl, options).url; +} + +export function resolveMobileShellUrlFromDeepLink( + rawUrl: string | null | undefined, + baseWebUrl: string, + options: MobileShellUrlOptions, +): MobileShellDeepLinkResolution { const normalizedBaseWebUrl = resolveMobileShellBaseWebUrl(baseWebUrl); const defaultUrl = buildMobileShellUrl(normalizedBaseWebUrl, options); if (!rawUrl) { - return defaultUrl; + return { + status: 'default', + url: defaultUrl, + }; } const webOrigin = new URL(normalizedBaseWebUrl).origin; const targetPath = resolveTargetPath(rawUrl, webOrigin); if (!targetPath) { - return defaultUrl; + return { + status: 'rejected', + url: defaultUrl, + }; } - return buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options); + return { + status: 'mapped', + url: buildMobileShellUrl(new URL(targetPath, webOrigin).toString(), options), + }; } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index caba80ac9..138ad55ee 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -3079,6 +3079,13 @@ - 影响范围:`scripts/check-native-shells.mjs`。 - 验证方式:`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 +## 2026-06-20 移动壳 Deep Link 失败必须可观测 + +- 背景:Expo 移动壳已声明 `genarrative://` scheme、iOS associated domain 和 Android app link,冷启动 / 热启动 deep link 会决定用户是否落到作品详情、创作页或邀请码页;如果 `Linking.getInitialURL()` 读取失败或运行时 URL 被拒绝后静默回首页,真实安装包会表现为“能打开 App 但目标丢失”,排障也缺少证据。 +- 决策:移动壳 deep link 解析必须返回 `default` / `mapped` / `rejected` 状态;外域、危险协议或非法路径继续回到安全默认主站入口,但必须记录拒绝日志。`Linking.getInitialURL()` reject 必须记录 `initial_url.read` 错误且不替换当前 WebView URL;运行时 URL 被拒绝必须记录 `runtime_url.rejected`,并继续落安全默认入口。配置检查反查 ShellApp 日志路径、deep link 状态 resolver 和对应测试。 +- 影响范围:`apps/mobile-shell/src/shell/deepLink.ts`、`apps/mobile-shell/src/shell/ShellApp.tsx`、`apps/mobile-shell/scripts/check-config.mjs`、宿主壳方案文档。 +- 验证方式:`npm run mobile-shell:typecheck`、`npm run mobile-shell:test -- src/shell/deepLink.test.ts src/shell/ShellApp.test.tsx`、`npm run check:native-shells`、`npm run check:encoding`、`git diff --check`。 + ## 2026-06-20 移动壳外链失败与扫码超时边界 - 背景:Expo 移动壳外链导航会离开带 HostBridge 的主 WebView,扫码能力也会打开原生相机 overlay;如果系统外链 API 异常被 helper 吞掉,或扫码 pending 没有共享超时清理,用户会看到点击无反应或后续扫码一直提示通道占用。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index c30180159..86b470d9b 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -302,7 +302,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.importText`、`file.importDocument`、`file.exportImage`、`file.importImage`、`file.captureImage`、`scanner.scanQrCode`、`file.importAudio`、`file.exportAudio`、`haptics.impact`、`notification.showLocal` 和 Android 返回键回退;其中 `appearance.getColorScheme` 只读系统配色偏好,不强改 H5 或系统主题;`app.lifecycle` 通过 React Native `AppState` 注入 `active` / `inactive` / `background` 统一状态,供 H5 游戏循环、音频和轮询做真实暂停 / 恢复判断,H5 的 `useHostLifecycleActive()` 会把该事件归一成运行态可播放状态,WebAudio 背景音乐和拼图、抓大鹅等固定玩法 `