From 8bdfd1c6292e27243844f19b96b78f5fa65df1a4 Mon Sep 17 00:00:00 2001 From: kdletters Date: Thu, 18 Jun 2026 20:26:20 +0800 Subject: [PATCH] =?UTF-8?q?=E7=A7=BB=E5=8A=A8=E5=A3=B3=E8=A1=A5=E9=BD=90?= =?UTF-8?q?=E5=8A=A0=E8=BD=BD=E5=A4=B1=E8=B4=A5=E5=85=9C=E5=BA=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增同源主页面加载失败归一模型与测试 Expo WebView 接入 onError/onHttpError 原生重试层 更新原生壳门禁与宿主壳方案文档 --- apps/mobile-shell/scripts/check-config.mjs | 26 ++++ apps/mobile-shell/src/shell/ShellApp.tsx | 120 ++++++++++++++++++ .../src/shell/loadFailure.test.ts | 115 +++++++++++++++++ apps/mobile-shell/src/shell/loadFailure.ts | 110 ++++++++++++++++ .../shared-memory/decision-log.md | 1 + ...ExpoReactNative与Tauri宿主壳方案-2026-06-17.md | 2 + scripts/check-native-shells.mjs | 2 + 7 files changed, 376 insertions(+) create mode 100644 apps/mobile-shell/src/shell/loadFailure.test.ts create mode 100644 apps/mobile-shell/src/shell/loadFailure.ts diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index abc1c0c53..c5032600a 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -30,6 +30,8 @@ const urlPath = new URL('../src/shell/url.ts', import.meta.url); const urlSource = fs.readFileSync(urlPath, 'utf8'); const webViewPolicyPath = new URL('../src/shell/webViewPolicy.ts', import.meta.url); const webViewPolicySource = fs.readFileSync(webViewPolicyPath, 'utf8'); +const loadFailurePath = new URL('../src/shell/loadFailure.ts', import.meta.url); +const loadFailureSource = fs.readFileSync(loadFailurePath, 'utf8'); const runtimePath = new URL('../src/shell/runtime.ts', import.meta.url); const runtimeSource = fs.readFileSync(runtimePath, 'utf8'); const sharedContractPath = new URL( @@ -720,6 +722,14 @@ for (const snippet of [ 'reloadWebView: reloadCurrentWebView', 'onContentProcessDidTerminate={reloadCurrentWebView}', 'onRenderProcessGone={reloadCurrentWebView}', + 'normalizeMobileShellLoadFailure', + 'handleWebViewLoadError', + 'handleWebViewHttpError', + 'handleRetryLoadFailure', + 'onError={handleWebViewLoadError}', + 'onHttpError={handleWebViewHttpError}', + 'loadFailurePanel', + 'loadFailureButton', 'AppState.addEventListener', 'app.lifecycle', 'network.statusChanged', @@ -761,6 +771,22 @@ for (const snippet of [ } } +for (const snippet of [ + 'MobileShellLoadFailureInput', + 'normalizeMobileShellLoadFailure', + 'shouldShowLoadFailure', + 'sameDocumentUrl', + 'shouldOpenInMobileShellWebView', + "input.type === 'http'", + "title: '网络不可用'", + "retryLabel: '重试'", + "url.pathname !== '/favicon.ico'", +]) { + if (!loadFailureSource.includes(snippet)) { + throw new Error(`mobile shell load failure policy missing ${snippet}`); + } +} + for (const snippet of [ 'MOBILE_WEBVIEW_BLOCKED_DOWNLOAD_PROTOCOLS', "'blob:'", diff --git a/apps/mobile-shell/src/shell/ShellApp.tsx b/apps/mobile-shell/src/shell/ShellApp.tsx index 67c5aa1fc..4e0c7795d 100644 --- a/apps/mobile-shell/src/shell/ShellApp.tsx +++ b/apps/mobile-shell/src/shell/ShellApp.tsx @@ -6,7 +6,10 @@ import { BackHandler, Linking, Platform, + Pressable, StyleSheet, + Text, + View, } from 'react-native'; import { SafeAreaProvider, SafeAreaView } from 'react-native-safe-area-context'; import type { WebViewMessageEvent } from 'react-native-webview'; @@ -19,6 +22,10 @@ import { } from '../host-bridge/bridge'; import { buildMobileShellUrlFromDeepLink } from './deepLink'; import { lifecyclePayloadFromAppState } from './lifecycle'; +import { + type MobileShellLoadFailure, + normalizeMobileShellLoadFailure, +} from './loadFailure'; import { resolveMobileShellExternalUrl, shouldAcceptMobileShellHostBridgeMessage, @@ -45,6 +52,22 @@ function buildHostBridgeMessageScript(message: unknown) { )}, origin: window.location.origin, source: window })); true;`; } +type MobileWebViewLoadErrorEvent = { + nativeEvent: { + url: string; + code?: number; + description?: string; + }; +}; + +type MobileWebViewHttpErrorEvent = { + nativeEvent: { + url: string; + statusCode?: number; + description?: string; + }; +}; + export default function ShellApp() { const webViewRef = useRef(null); const [canGoBack, setCanGoBack] = useState(false); @@ -62,6 +85,8 @@ export default function ShellApp() { const [webUrl, setWebUrl] = useState(() => buildMobileShellUrl(baseWebUrl, urlOptions), ); + const [loadFailure, setLoadFailure] = + useState(null); const allowedWebOrigin = useMemo(() => new URL(webUrl).origin, [webUrl]); const reloadCurrentWebView = useCallback(() => { webViewRef.current?.reload(); @@ -94,6 +119,7 @@ export default function ShellApp() { allowedOrigin: allowedWebOrigin, urlOptions, openWebViewUrl(url) { + setLoadFailure(null); setWebUrl(url); }, reloadWebView: reloadCurrentWebView, @@ -111,6 +137,7 @@ export default function ShellApp() { baseWebUrl, urlOptions, ); + setLoadFailure(null); setWebUrl(nextUrl); }; @@ -203,11 +230,44 @@ export default function ShellApp() { return; } + setLoadFailure(null); injectLifecycleEvent(AppState.currentState); void getMobileNetworkStatus() .then(injectNetworkStatusEvent) .catch(() => undefined); }; + const handleWebViewLoadError = (event: MobileWebViewLoadErrorEvent) => { + setLoadFailure( + normalizeMobileShellLoadFailure( + { + type: 'native', + url: event.nativeEvent.url, + code: event.nativeEvent.code, + description: event.nativeEvent.description, + }, + allowedWebOrigin, + webUrl, + ), + ); + }; + const handleWebViewHttpError = (event: MobileWebViewHttpErrorEvent) => { + setLoadFailure( + normalizeMobileShellLoadFailure( + { + type: 'http', + url: event.nativeEvent.url, + statusCode: event.nativeEvent.statusCode, + description: event.nativeEvent.description, + }, + allowedWebOrigin, + webUrl, + ), + ); + }; + const handleRetryLoadFailure = () => { + setLoadFailure(null); + reloadCurrentWebView(); + }; const handleBlockedFileDownload = () => undefined; return ( @@ -237,6 +297,8 @@ export default function ShellApp() { onContentProcessDidTerminate={reloadCurrentWebView} onRenderProcessGone={reloadCurrentWebView} onLoad={handleWebViewLoad} + onError={handleWebViewLoadError} + onHttpError={handleWebViewHttpError} onShouldStartLoadWithRequest={handleShouldStartLoad} onNavigationStateChange={(event) => { setCanGoBack(event.canGoBack); @@ -253,6 +315,21 @@ export default function ShellApp() { }} setSupportMultipleWindows={false} /> + {loadFailure ? ( + + {loadFailure.title} + {loadFailure.detail} + + + {loadFailure.retryLabel} + + + + ) : null} ); @@ -263,4 +340,47 @@ const styles = StyleSheet.create({ flex: 1, backgroundColor: '#fffdf9', }, + loadFailurePanel: { + position: 'absolute', + top: 0, + right: 0, + bottom: 0, + left: 0, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 28, + backgroundColor: '#fffdf9', + }, + loadFailureTitle: { + color: '#211a16', + fontSize: 18, + fontWeight: '700', + lineHeight: 24, + textAlign: 'center', + }, + loadFailureDetail: { + maxWidth: 320, + marginTop: 10, + color: '#6a5c52', + fontSize: 14, + lineHeight: 20, + textAlign: 'center', + }, + loadFailureButton: { + minWidth: 112, + minHeight: 42, + alignItems: 'center', + justifyContent: 'center', + marginTop: 20, + borderRadius: 8, + backgroundColor: '#211a16', + paddingHorizontal: 22, + }, + loadFailureButtonText: { + color: '#fffdf9', + fontSize: 15, + fontWeight: '700', + lineHeight: 20, + textAlign: 'center', + }, }); diff --git a/apps/mobile-shell/src/shell/loadFailure.test.ts b/apps/mobile-shell/src/shell/loadFailure.test.ts new file mode 100644 index 000000000..d4f417243 --- /dev/null +++ b/apps/mobile-shell/src/shell/loadFailure.test.ts @@ -0,0 +1,115 @@ +import { describe, expect, test } from 'vitest'; + +import { normalizeMobileShellLoadFailure } from './loadFailure'; + +const allowedOrigin = 'https://app.genarrative.world'; + +describe('loadFailure', () => { + test('归一化同源 HTTP 加载失败', () => { + expect( + normalizeMobileShellLoadFailure( + { + type: 'http', + url: 'https://app.genarrative.world/creation/puzzle', + statusCode: 503, + description: 'Service Unavailable', + }, + allowedOrigin, + ), + ).toEqual({ + type: 'http', + url: 'https://app.genarrative.world/creation/puzzle', + title: '加载失败 503', + detail: 'Service Unavailable', + retryLabel: '重试', + }); + }); + + test('归一化同源原生加载失败并折叠错误描述', () => { + expect( + normalizeMobileShellLoadFailure( + { + type: 'native', + url: '/works/detail?work=WF-1', + code: -1009, + description: ' The Internet connection appears to be offline. ', + }, + allowedOrigin, + ), + ).toEqual({ + type: 'native', + url: 'https://app.genarrative.world/works/detail?work=WF-1', + title: '网络不可用', + detail: 'The Internet connection appears to be offline.', + retryLabel: '重试', + }); + }); + + test('忽略外域和非页面加载失败', () => { + expect( + normalizeMobileShellLoadFailure( + { + type: 'http', + url: 'https://example.com/', + statusCode: 500, + }, + allowedOrigin, + ), + ).toBeNull(); + expect( + normalizeMobileShellLoadFailure( + { + type: 'native', + url: 'about:blank', + code: -1, + }, + allowedOrigin, + ), + ).toBeNull(); + expect( + normalizeMobileShellLoadFailure( + { + type: 'native', + url: 'javascript:alert(1)', + code: -1, + }, + allowedOrigin, + ), + ).toBeNull(); + expect( + normalizeMobileShellLoadFailure( + { + type: 'http', + url: '/favicon.ico', + statusCode: 404, + }, + allowedOrigin, + ), + ).toBeNull(); + }); + + test('只展示当前主页面失败', () => { + expect( + normalizeMobileShellLoadFailure( + { + type: 'http', + url: 'https://app.genarrative.world/assets/main.js', + statusCode: 404, + }, + allowedOrigin, + 'https://app.genarrative.world/creation/puzzle', + ), + ).toBeNull(); + expect( + normalizeMobileShellLoadFailure( + { + type: 'http', + url: 'https://app.genarrative.world/creation/puzzle', + statusCode: 503, + }, + allowedOrigin, + 'https://app.genarrative.world/creation/puzzle', + )?.title, + ).toBe('加载失败 503'); + }); +}); diff --git a/apps/mobile-shell/src/shell/loadFailure.ts b/apps/mobile-shell/src/shell/loadFailure.ts new file mode 100644 index 000000000..1a319e742 --- /dev/null +++ b/apps/mobile-shell/src/shell/loadFailure.ts @@ -0,0 +1,110 @@ +import { shouldOpenInMobileShellWebView } from './navigation'; + +export type MobileShellLoadFailureInput = + | { + type: 'native'; + url: string; + code?: number | null; + description?: string | null; + } + | { + type: 'http'; + url: string; + statusCode?: number | null; + description?: string | null; + }; + +export type MobileShellLoadFailure = { + type: 'native' | 'http'; + url: string; + title: string; + detail: string; + retryLabel: string; +}; + +function normalizeDescription(value: string | null | undefined) { + const normalized = value?.replace(/\s+/g, ' ').trim(); + if (!normalized) { + return null; + } + + return normalized.length > 96 + ? `${normalized.slice(0, 96).trim()}...` + : normalized; +} + +function sameDocumentUrl( + left: URL, + right: string | null | undefined, + allowedOrigin: string, +) { + if (!right) { + return true; + } + + try { + const current = new URL(right, allowedOrigin); + return ( + left.origin === current.origin && + left.pathname === current.pathname && + left.search === current.search + ); + } catch { + return false; + } +} + +function shouldShowLoadFailure( + rawUrl: string, + allowedOrigin: string, + currentPageUrl?: string | null, +) { + if (!shouldOpenInMobileShellWebView(rawUrl, allowedOrigin)) { + return false; + } + + try { + const url = new URL(rawUrl, allowedOrigin); + return ( + url.origin === allowedOrigin && + url.pathname !== '/favicon.ico' && + sameDocumentUrl(url, currentPageUrl, allowedOrigin) + ); + } catch { + return false; + } +} + +export function normalizeMobileShellLoadFailure( + input: MobileShellLoadFailureInput, + allowedOrigin: string, + currentPageUrl?: string | null, +): MobileShellLoadFailure | null { + if (!shouldShowLoadFailure(input.url, allowedOrigin, currentPageUrl)) { + return null; + } + + const url = new URL(input.url, allowedOrigin).toString(); + const description = normalizeDescription(input.description); + if (input.type === 'http') { + const statusCode = + Number.isInteger(input.statusCode) && input.statusCode + ? input.statusCode + : null; + return { + type: 'http', + url, + title: statusCode ? `加载失败 ${statusCode}` : '加载失败', + detail: description ?? '服务器暂时没有返回可用页面', + retryLabel: '重试', + }; + } + + return { + type: 'native', + url, + title: '网络不可用', + detail: description ?? '当前页面没有加载成功', + retryLabel: '重试', + }; +} diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e2f95014f..4c4339fe8 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -2336,6 +2336,7 @@ - 决策:新增 HostBridge method `app.reloadWebView` 和 H5 facade `reloadHostWebView()`。移动端只调用当前 `react-native-webview` 的 `reload()`,桌面端只调用 Tauri 主 `WebviewWindow.reload()`;该 method 不接受 payload,成功只表示宿主已发起刷新,刷新后当前 H5 上下文会卸载。继续把同源跳转留给 `navigation.openNativePage`,外链离开容器留给 `app.openExternalUrl`。 - 2026-06-18 追加:`AuthGate` 登录态身份边界刷新改为优先调用 `reloadHostWebView()`,用于登录成功、退出登录或从已登录变为未登录后的主站重新初始化;宿主未声明、返回失败或不可用时再回退浏览器 `window.location.reload()`,普通 token refresh、账号资料更新、主题和音量变化仍不触发整页刷新。 - 2026-06-18 追加:Expo 移动壳的 iOS `onContentProcessDidTerminate` 和 Android `onRenderProcessGone` 也复用当前 WebView 的受控 `reload()` 路径,系统回收 WebView 内容 / 渲染进程后只恢复同一 H5 容器,不改写 URL、不注入额外脚本、不新增宿主恢复页面。 +- 2026-06-18 追加:Expo 移动壳的 `onError` / `onHttpError` 只对同源 H5 主页面展示原生加载失败兜底层,用户重试时仍复用当前 WebView `reload()`;兜底不接管外域、危险协议、`about:blank` 或 favicon 失败,也不向 H5 注入错误事件。 - 影响范围:`packages/shared/src/contracts/hostBridge.ts`、`src/services/host-bridge/hostBridge.ts`、`apps/mobile-shell/`、`apps/desktop-shell/`、原生壳能力检查脚本和 HostBridge 架构文档。 - 验证方式:`npm run check:native-shells`、`npm run typecheck`、`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 dc3d890dd..72f24f6b8 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -295,6 +295,8 @@ GameBridge 禁止: 2026-06-18 追加:移动壳 WebView 内容 / 渲染进程终止时复用同一受控刷新路径。iOS `onContentProcessDidTerminate` 和 Android `onRenderProcessGone` 只调用当前 `react-native-webview` 的 `reload()`,不改写 H5 URL、不注入额外脚本、不新增宿主恢复页面,避免系统回收 WebView 进程后留下空白容器。 +2026-06-18 追加:移动壳 WebView 首载或同源主文档 HTTP 加载失败时,由 Expo 壳层展示原生失败兜底层和重试动作;重试只调用当前 WebView 的 `reload()`,不会改写 URL、绕过同源校验或向 H5 注入错误事件。失败归一逻辑只接受同源 H5 页面,`about:blank`、外域、危险协议、favicon 等非主页面失败不进入兜底,避免把导航拦截策略误展示为页面加载失败。 + 2026-06-18 追加:移动壳 WebView 每次同源主站页面成功加载后都会补发当前 `app.lifecycle` 与 `network.statusChanged` 状态,覆盖首载、`app.reloadWebView`、进程恢复 reload 和 H5 自身刷新后的新 JS 上下文。补发仍只使用 HostBridge event,不新增 capability,不向 `about:blank`、外域或错误页注入宿主状态。 2026-06-18 追加:移动壳根布局接入 `react-native-safe-area-context`,用 `SafeAreaProvider` 和四边 `SafeAreaView` 承接 iOS 刘海、底部 Home Indicator、Android 状态栏和横屏边缘安全区;WebView 仍加载同一 H5 主站,不改变 H5 路由、玩法 runtime 或 HostBridge capability。该能力属于宿主壳布局保护,不在 H5 内补额外占位 UI。 diff --git a/scripts/check-native-shells.mjs b/scripts/check-native-shells.mjs index e14b894fd..b8f09d061 100644 --- a/scripts/check-native-shells.mjs +++ b/scripts/check-native-shells.mjs @@ -49,6 +49,8 @@ const expectedMobileShellFiles = [ 'deepLink.ts', 'lifecycle.test.ts', 'lifecycle.ts', + 'loadFailure.test.ts', + 'loadFailure.ts', 'navigation.test.ts', 'navigation.ts', 'network.test.ts',