diff --git a/apps/mobile-shell/scripts/check-config.mjs b/apps/mobile-shell/scripts/check-config.mjs index 35ebd81da..74db0ec00 100644 --- a/apps/mobile-shell/scripts/check-config.mjs +++ b/apps/mobile-shell/scripts/check-config.mjs @@ -1707,6 +1707,7 @@ for (const snippet of [ 'normalizeMobileShellLoadFailure', 'shouldShowLoadFailure', 'sameDocumentUrl', + 'sanitizeLoadFailureUrl', 'shouldOpenInMobileShellWebView', "input.type === 'http'", "input.type === 'process'", @@ -1720,20 +1721,35 @@ for (const snippet of [ } } +if ( + loadFailureSource.includes('new URL(input.url, allowedOrigin).toString()') || + loadFailureSource.includes('description ??') || + loadFailureSource.includes('normalizeDescription') +) { + throw new Error( + 'mobile shell load failure panel must not expose full URLs or native descriptions', + ); +} + for (const snippet of [ "describe('loadFailure'", "test('归一化同源 HTTP 加载失败'", + "test('归一化同源原生加载失败并隐藏系统错误描述'", "test('忽略外域和非页面加载失败'", "test('只展示当前主页面失败'", "test('连续 WebView 进程恢复失败时展示同源页面兜底'", "type: 'process'", "title: '页面已停止'", + "detail: '服务器暂时没有返回可用页面'", + "detail: '当前页面没有加载成功'", + "detail: '当前页面连续恢复失败'", "url: 'https://example.com/'", "url: 'about:blank'", "url: 'javascript:alert(1)'", "url: '/favicon.ico'", "url: 'https://app.genarrative.world/assets/main.js'", - "https://app.genarrative.world/creation/puzzle", + "https://app.genarrative.world/creation/puzzle?sessionId=private#recover", + "url: 'https://app.genarrative.world/works/detail'", ]) { if (!loadFailureTestSource.includes(snippet)) { throw new Error(`mobile shell load failure tests missing ${snippet}`); diff --git a/apps/mobile-shell/src/shell/loadFailure.test.ts b/apps/mobile-shell/src/shell/loadFailure.test.ts index bc89216aa..0ce69edb8 100644 --- a/apps/mobile-shell/src/shell/loadFailure.test.ts +++ b/apps/mobile-shell/src/shell/loadFailure.test.ts @@ -10,7 +10,7 @@ describe('loadFailure', () => { normalizeMobileShellLoadFailure( { type: 'http', - url: 'https://app.genarrative.world/creation/puzzle', + url: 'https://app.genarrative.world/creation/puzzle?sessionId=private#recover', statusCode: 503, description: 'Service Unavailable', }, @@ -20,17 +20,17 @@ describe('loadFailure', () => { type: 'http', url: 'https://app.genarrative.world/creation/puzzle', title: '加载失败 503', - detail: 'Service Unavailable', + detail: '服务器暂时没有返回可用页面', retryLabel: '重试', }); }); - test('归一化同源原生加载失败并折叠错误描述', () => { + test('归一化同源原生加载失败并隐藏系统错误描述', () => { expect( normalizeMobileShellLoadFailure( { type: 'native', - url: '/works/detail?work=WF-1', + url: '/works/detail?work=WF-1&token=private#runtime', code: -1009, description: ' The Internet connection appears to be offline. ', }, @@ -38,9 +38,9 @@ describe('loadFailure', () => { ), ).toEqual({ type: 'native', - url: 'https://app.genarrative.world/works/detail?work=WF-1', + url: 'https://app.genarrative.world/works/detail', title: '网络不可用', - detail: 'The Internet connection appears to be offline.', + detail: '当前页面没有加载成功', retryLabel: '重试', }); }); @@ -118,17 +118,17 @@ describe('loadFailure', () => { normalizeMobileShellLoadFailure( { type: 'process', - url: 'https://app.genarrative.world/works/detail?work=WF-1', + url: 'https://app.genarrative.world/works/detail?work=WF-1&token=private#runtime', description: 'WebView renderer terminated repeatedly', }, allowedOrigin, - 'https://app.genarrative.world/works/detail?work=WF-1', + 'https://app.genarrative.world/works/detail?work=WF-1&token=private#runtime', ), ).toEqual({ type: 'process', - url: 'https://app.genarrative.world/works/detail?work=WF-1', + url: 'https://app.genarrative.world/works/detail', title: '页面已停止', - detail: 'WebView renderer terminated repeatedly', + detail: '当前页面连续恢复失败', retryLabel: '重试', }); }); diff --git a/apps/mobile-shell/src/shell/loadFailure.ts b/apps/mobile-shell/src/shell/loadFailure.ts index 35861de3b..b04971195 100644 --- a/apps/mobile-shell/src/shell/loadFailure.ts +++ b/apps/mobile-shell/src/shell/loadFailure.ts @@ -27,17 +27,6 @@ export type MobileShellLoadFailure = { 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, @@ -80,6 +69,11 @@ function shouldShowLoadFailure( } } +function sanitizeLoadFailureUrl(rawUrl: string, allowedOrigin: string) { + const url = new URL(rawUrl, allowedOrigin); + return `${url.origin}${url.pathname}`; +} + export function normalizeMobileShellLoadFailure( input: MobileShellLoadFailureInput, allowedOrigin: string, @@ -89,8 +83,7 @@ export function normalizeMobileShellLoadFailure( return null; } - const url = new URL(input.url, allowedOrigin).toString(); - const description = normalizeDescription(input.description); + const url = sanitizeLoadFailureUrl(input.url, allowedOrigin); if (input.type === 'http') { const statusCode = Number.isInteger(input.statusCode) && input.statusCode @@ -100,7 +93,7 @@ export function normalizeMobileShellLoadFailure( type: 'http', url, title: statusCode ? `加载失败 ${statusCode}` : '加载失败', - detail: description ?? '服务器暂时没有返回可用页面', + detail: '服务器暂时没有返回可用页面', retryLabel: '重试', }; } @@ -110,7 +103,7 @@ export function normalizeMobileShellLoadFailure( type: 'process', url, title: '页面已停止', - detail: description ?? '当前页面连续恢复失败', + detail: '当前页面连续恢复失败', retryLabel: '重试', }; } @@ -119,7 +112,7 @@ export function normalizeMobileShellLoadFailure( type: 'native', url, title: '网络不可用', - detail: description ?? '当前页面没有加载成功', + detail: '当前页面没有加载成功', retryLabel: '重试', }; } diff --git a/docs/project-memory/shared-memory/decision-log.md b/docs/project-memory/shared-memory/decision-log.md index e407f1042..f805f01cb 100644 --- a/docs/project-memory/shared-memory/decision-log.md +++ b/docs/project-memory/shared-memory/decision-log.md @@ -118,7 +118,7 @@ - 2026-06-18 原生壳网络状态:新增 `network.status` 与 `network.statusChanged` HostBridge capability,Expo 壳通过 `expo-network` 查询和订阅真实系统网络状态;Tauri 壳只声明 `network.status`,从 `WEB_APP_ORIGIN` 解析主站 host / port 后做短超时 TCP 可达性查询,暂不声明 `network.statusChanged`,避免把 WebView `online` / `offline` 当作桌面 Rust 网络事实。H5 统一使用 `getHostNetworkStatus()` / `subscribeHostNetworkStatusChange()`,不得直接读取 Expo / Tauri 私有网络 API。 - 2026-06-19 移动壳本地通知边界:Expo `notification.showLocal` 的 payload 归一、权限确认、iOS 仅 alert 且不请求 badge/sound、Android 固定 channel、即时调度、通知 handler 和成功响应包装统一收口在 `apps/mobile-shell/src/host-bridge/notifications.ts`;`dispatch.ts` 只负责把 HostBridge request 委托给 `showMobileHostBridgeLocalNotification(...)`,不得直接调用 `expo-notifications` 调度或权限 API,不得重新做通知 payload 归一,也不得包装本地通知成功响应。移动壳配置检查会覆盖该模块结构、固定 channel、即时调度形态和 dispatch 委托关系,避免后续混入远程推送、后台推送或散落的本地通知实现。 - 2026-06-18 移动壳 WebView 状态重放:Expo WebView 每次同源主站页面成功加载后都会补发当前 `app.lifecycle` 与 `network.statusChanged` 状态,覆盖首载、受控刷新、H5 刷新和系统回收 WebView 进程后的新 JS 上下文;补发不新增 HostBridge capability,也不向 `about:blank`、外域或错误页注入宿主状态。 -- 2026-06-20 移动壳加载失败边界:Expo 壳 `onError` / `onHttpError` 只对同源 H5 主页面展示原生失败兜底层,外域、`about:blank`、危险协议、favicon 和非当前主页面资源失败不得触发兜底。移动壳配置检查会反查 `loadFailure.test.ts` 的同源、favicon 和当前主页面测试,避免错误页策略漂移。 +- 2026-06-20 移动壳加载失败边界:Expo 壳 `onError` / `onHttpError` 只对同源 H5 主页面展示原生失败兜底层,外域、`about:blank`、危险协议、favicon 和非当前主页面资源失败不得触发兜底。兜底层可以用完整 URL 判断是否属于当前主文档,但返回给 UI 的 `url` 只保留 `origin + pathname`,`detail` 只使用稳定文案,不展示 query、hash 或系统原生 description。移动壳配置检查会反查 `loadFailure.test.ts` 的同源、favicon、当前主页面、URL 脱敏和稳定文案测试,避免错误页策略漂移或泄露 H5 运行态上下文。 - 2026-06-18 桌面壳 WebView 状态重放:Tauri 主 WebView 每次页面加载完成后都会回放当前 `app.lifecycle`,覆盖托盘刷新、`app.reloadWebView` 和 H5 自刷新后的新 JS 上下文;桌面 runtime 同步声明 `host.events` 表示生命周期、返回栈和拖拽图片事件通道可用,但桌面暂不声明 `network.statusChanged`,仍不开放 Tauri event 插件或额外 command。 - 2026-06-18 桌面壳 H5 返回栈事件:Tauri 壳开始声明 `navigation.canGoBack`,但只通过固定注入脚本追踪当前 H5 文档内的 `pushState` / `replaceState` / `popstate` 路由栈并派发 HostBridge event;不把该能力实现为 request method,不开放 H5 到 Tauri 的 event 写入通道,也不声明跨文档 native back-forward list 真相。 - 2026-06-18 移动壳 H5 返回栈事件:Expo 壳开始用固定 WebView 注入脚本追踪当前 H5 文档内的 `pushState` / `replaceState` / `popstate` 路由栈,并通过内部 `genarrative.mobile.historyState` 消息回传给壳层;壳层把该状态与 `react-native-webview` 原生 `canGoBack` 合成为 HostBridge `navigation.canGoBack` 事件。Android 返回键优先回退 H5 当前文档路由栈,H5 不可回退时才走 WebView 原生 `goBack()`;该内部消息不是 HostBridge request method,不开放通用 H5 -> 原生事件通道,外域 / 危险页面消息仍在进入 HostBridge 前丢弃。 diff --git a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md index 76f77b9d2..cd54688c3 100644 --- a/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md +++ b/docs/【前端架构】ExpoReactNative与Tauri宿主壳方案-2026-06-17.md @@ -368,6 +368,8 @@ GameBridge 禁止: 2026-06-18 追加:移动壳 WebView 首载或同源主文档 HTTP 加载失败时,由 Expo 壳层展示原生失败兜底层和重试动作;重试只调用当前 WebView 的 `reload()`,不会改写 URL、绕过同源校验或向 H5 注入错误事件。失败归一逻辑只接受同源 H5 页面,`about:blank`、外域、危险协议、favicon 等非主页面失败不进入兜底,避免把导航拦截策略误展示为页面加载失败。 +2026-06-21 追加:移动壳加载失败兜底层不得展示或持有完整 H5 运行态 URL、query、hash 或系统原生错误描述。`normalizeMobileShellLoadFailure(...)` 可以使用完整 URL 判断是否为当前同源主文档,但返回给 UI 的 `url` 只保留 `origin + pathname`,`detail` 只使用稳定文案,例如“服务器暂时没有返回可用页面”“当前页面没有加载成功”或“当前页面连续恢复失败”。移动壳配置检查反查 query / hash 脱敏、系统 description 不直出和稳定文案测试,避免加载失败面板泄露 `sessionId`、`draftId`、`workId`、登录恢复上下文或 WebView 原生错误明细。 + 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。移动壳 `safeArea.test.ts` 必须证明 `MOBILE_SHELL_SAFE_AREA_EDGES` 固定覆盖 top / right / bottom / left 四边,配置检查会反查该测试,避免后续只保护竖屏常见边缘。