收口移动壳加载失败兜底信息

移动壳加载失败兜底层只保留脱敏页面路径

移动壳加载失败详情改为稳定文案不直出系统描述

扩展移动壳配置门禁锁定加载失败脱敏边界

补充宿主壳方案和共享决策中的加载失败边界
This commit is contained in:
2026-06-21 00:26:31 +08:00
parent a5e9e87107
commit 6ba22b214c
5 changed files with 39 additions and 28 deletions
+17 -1
View File
@@ -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}`);
+10 -10
View File
@@ -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: '重试',
});
});
+9 -16
View File
@@ -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: '重试',
};
}
@@ -118,7 +118,7 @@
- 2026-06-18 原生壳网络状态:新增 `network.status``network.statusChanged` HostBridge capabilityExpo 壳通过 `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 前丢弃。
@@ -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 四边,配置检查会反查该测试,避免后续只保护竖屏常见边缘。