收口移动壳加载失败兜底信息
移动壳加载失败兜底层只保留脱敏页面路径 移动壳加载失败详情改为稳定文案不直出系统描述 扩展移动壳配置门禁锁定加载失败脱敏边界 补充宿主壳方案和共享决策中的加载失败边界
This commit is contained in:
@@ -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,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: '重试',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 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 前丢弃。
|
||||
|
||||
@@ -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 四边,配置检查会反查该测试,避免后续只保护竖屏常见边缘。
|
||||
|
||||
Reference in New Issue
Block a user