refactor(desktop-client): harden external-window load flow with loading page and timeout watchdog

- Introduce `navigateRemoteURL` / `showLocalPage` / `showErrorPage` helpers
  that centralize remote navigation, watchdog wiring, and error fallback,
  replacing the ad-hoc logic inside `loadWindowURLSafely`.
- Show a localized loading page (`loadingPageDataURL`) while a remote URL
  is fetching, so the window no longer sits on blank white during slow
  navigations.
- Add a 25s remote-load timeout watchdog that switches the window to the
  error page when the target never finishes loading, plus an active load
  token so stale watchdogs from earlier navigations cannot fire on the
  current attempt.
- Track `localPageKind` and `activeTargetURL` per window so blank-page
  retries, did-fail-load handlers, and ready-for-detection checks behave
  correctly when the window is currently showing a local loading/error
  page.
- Escalate the blank-page watchdog: after the retry attempt, if the page
  is still blank, surface the error page instead of looping silent
  reloads.
- Restyle the error page (drop gradients, use a neutral light/dark token
  palette, single primary retry button) and add a dedicated loading page
  template with the same look.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 01:31:50 +08:00
parent b6cc12cefe
commit 793c310560
+407 -66
View File
@@ -5,6 +5,7 @@ import { STANDARD_USER_AGENT } from './user-agent'
const ignoredNavigationFailurePattern = /ERR_ABORTED|ERR_BLOCKED_BY_CLIENT|\((-3|-20)\) loading/i
const errorPagePrefix = 'data:text/html'
const REMOTE_PAGE_LOAD_TIMEOUT_MS = 25_000
const BLANK_PAGE_PROBE_DELAY_MS = 7_000
const BLANK_PAGE_NODE_THRESHOLD = 3
const BLANK_PAGE_TEXT_THRESHOLD = 5
@@ -12,8 +13,12 @@ const BLANK_PAGE_TEXT_THRESHOLD = 5
interface WindowNavigationState {
readyForDetection: boolean
lastMainFrameError: string | null
loadTimeoutWatchdog: NodeJS.Timeout | null
blankPageWatchdog: NodeJS.Timeout | null
blankPageRetried: boolean
localPageKind: 'loading' | 'error' | null
activeTargetURL: string | null
activeLoadToken: number
context: string
}
@@ -26,6 +31,30 @@ function clearBlankPageWatchdog(state: WindowNavigationState): void {
}
}
function clearLoadTimeoutWatchdog(state: WindowNavigationState): void {
if (state.loadTimeoutWatchdog) {
clearTimeout(state.loadTimeoutWatchdog)
state.loadTimeoutWatchdog = null
}
}
function clearNavigationWatchdogs(state: WindowNavigationState): void {
clearLoadTimeoutWatchdog(state)
clearBlankPageWatchdog(state)
}
function isLocalPageURL(url: string): boolean {
return url.startsWith(errorPagePrefix)
}
function isIgnoredNavigationFailure(errorCode: number, errorDescription = ''): boolean {
return (
errorCode === -3 ||
errorCode === -20 ||
ignoredNavigationFailurePattern.test(`${errorCode}:${errorDescription}`)
)
}
async function probePageBodyShape(
window: BrowserWindow,
): Promise<{ nodeCount: number; textLength: number; readyState: string } | null> {
@@ -78,6 +107,12 @@ function scheduleBlankPageWatchdog(window: BrowserWindow, state: WindowNavigatio
url: currentURL,
...shape,
})
void showErrorPage(
window,
state.context,
currentURL,
'页面加载完成后仍然是空白状态,可能是网络、平台风控、兼容性或远程脚本加载失败导致。',
)
return
}
@@ -86,7 +121,10 @@ function scheduleBlankPageWatchdog(window: BrowserWindow, state: WindowNavigatio
url: currentURL,
...shape,
})
window.webContents.reloadIgnoringCache()
void navigateRemoteURL(window, currentURL, state.context, {
resetBlankRetry: false,
showLoadingPage: true,
})
})
}, BLANK_PAGE_PROBE_DELAY_MS)
}
@@ -100,6 +138,126 @@ function escapeHtml(value: string): string {
.replaceAll("'", '&#39;')
}
function targetHostLabel(targetURL: string): string {
try {
return new URL(targetURL).host
} catch {
return targetURL
}
}
function loadingPageDataURL(context: string, targetURL: string): string {
const html = `<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>${escapeHtml(context)} - 正在打开</title>
<style>
:root {
color-scheme: light dark;
font-family: "IBM Plex Sans", "Segoe UI Variable", "Segoe UI", sans-serif;
background: #f6f7f9;
color: #111827;
}
* {
box-sizing: border-box;
}
html {
width: 100%;
min-height: 100%;
}
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
padding: 24px;
background: #f6f7f9;
overflow: hidden;
}
main {
width: min(520px, 100%);
padding: 28px;
border: 1px solid #d9dee7;
border-radius: 8px;
background: #ffffff;
text-align: center;
}
.spinner {
width: 32px;
height: 32px;
margin: 0 auto;
border-radius: 50%;
border: 3px solid #d9dee7;
border-top-color: #2563eb;
animation: spin 0.9s linear infinite;
}
h1 {
margin: 18px 0 0;
font-size: 22px;
line-height: 1.35;
letter-spacing: 0;
}
p {
margin: 10px 0 0;
color: #5b6573;
font-size: 14px;
line-height: 1.7;
}
code {
display: block;
margin-top: 16px;
padding: 10px 12px;
border-radius: 6px;
background: #f1f4f8;
color: #374151;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 13px;
word-break: break-all;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@media (prefers-color-scheme: dark) {
:root,
body {
background: #111827;
color: #f9fafb;
}
main {
border-color: #2f3948;
background: #182130;
}
p {
color: #b6bfcb;
}
code {
background: #111827;
color: #d1d5db;
}
.spinner {
border-color: #344154;
border-top-color: #60a5fa;
}
}
</style>
</head>
<body>
<main>
<div class="spinner" aria-hidden="true"></div>
<h1>正在打开${escapeHtml(context)}</h1>
<p>如果平台网络较慢,窗口会停留在这里;加载失败时会给出重试入口。</p>
<code>${escapeHtml(targetHostLabel(targetURL))}</code>
</main>
</body>
</html>`
return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}`
}
function errorPageDataURL(context: string, targetURL: string, message: string): string {
const html = `<!doctype html>
<html lang="zh-CN">
@@ -111,6 +269,15 @@ function errorPageDataURL(context: string, targetURL: string, message: string):
:root {
color-scheme: light dark;
font-family: "IBM Plex Sans", "Segoe UI Variable", "Segoe UI", sans-serif;
background: #f6f7f9;
color: #111827;
}
* {
box-sizing: border-box;
}
html {
width: 100%;
min-height: 100%;
}
body {
margin: 0;
@@ -118,26 +285,21 @@ function errorPageDataURL(context: string, targetURL: string, message: string):
display: grid;
place-items: center;
padding: 24px;
background:
radial-gradient(circle at top left, rgba(15, 118, 110, 0.12), transparent 28%),
radial-gradient(circle at top right, rgba(37, 99, 235, 0.1), transparent 24%),
linear-gradient(180deg, #eef3f8, #e4ebf3);
color: #0f172a;
background: #f6f7f9;
}
.card {
width: min(680px, 100%);
padding: 24px;
border-radius: 24px;
border: 1px solid rgba(15, 23, 42, 0.08);
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 18px 42px rgba(15, 23, 42, 0.12);
border-radius: 8px;
border: 1px solid #d9dee7;
background: #ffffff;
}
h1, p, pre {
margin: 0;
}
h1 {
font-size: 28px;
letter-spacing: -0.04em;
font-size: 24px;
letter-spacing: 0;
}
p {
margin-top: 12px;
@@ -147,8 +309,8 @@ function errorPageDataURL(context: string, targetURL: string, message: string):
pre {
margin-top: 16px;
padding: 14px 16px;
border-radius: 16px;
background: rgba(15, 23, 42, 0.05);
border-radius: 6px;
background: #f1f4f8;
overflow: auto;
white-space: pre-wrap;
word-break: break-word;
@@ -157,42 +319,70 @@ function errorPageDataURL(context: string, targetURL: string, message: string):
display: flex;
gap: 12px;
flex-wrap: wrap;
justify-content: center;
margin-top: 18px;
}
button,
a {
button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 44px;
padding: 0 16px;
border-radius: 12px;
border: 1px solid rgba(15, 23, 42, 0.08);
border-radius: 8px;
border: 1px solid #d9dee7;
background: white;
color: #0f172a;
text-decoration: none;
font: inherit;
font-weight: 700;
cursor: pointer;
}
button:disabled {
cursor: default;
opacity: 0.72;
}
.primary {
background: linear-gradient(180deg, #44a7ff, #1b6cff);
border-color: rgba(27, 108, 255, 0.18);
background: #2563eb;
border-color: #2563eb;
color: white;
}
@media (prefers-color-scheme: dark) {
:root,
body {
background: #111827;
color: #f9fafb;
}
.card {
border-color: #2f3948;
background: #182130;
}
p {
color: #b6bfcb;
}
pre {
background: #111827;
}
button {
border-color: #344154;
background: #111827;
color: #f9fafb;
}
.primary {
border-color: #3b82f6;
background: #3b82f6;
}
}
</style>
</head>
<body>
<main class="card">
<h1>授权页加载失败</h1>
<p>${escapeHtml(context)} 在打开远程页面时出现网络或 TLS 握手异常。窗口已保留,不再直接闪退。</p>
<p>如果这是偶发网络抖动,点“重新加载”即可;如果持续失败,再看控制台里打印的 URL 和错误码定位。</p>
<p>${escapeHtml(context)} 在打开远程页面时遇到网络、TLS、平台脚本或兼容性异常。窗口已保留,可以直接重试。</p>
<p>如果持续失败,请检查代理、DNS、系统时间和平台是否触发风控。</p>
<pre>${escapeHtml(message)}
${escapeHtml(targetURL)}</pre>
<div class="actions">
<button class="primary" onclick="location.href = ${JSON.stringify(targetURL)}">重新加载</button>
<a href=${JSON.stringify(targetURL)} target="_self">打开目标地址</a>
<button class="primary" onclick="this.disabled = true; this.textContent = '正在重试...'; location.replace(${JSON.stringify(targetURL)})">重新加载</button>
</div>
</main>
</body>
@@ -201,12 +391,160 @@ ${escapeHtml(targetURL)}</pre>
return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}`
}
async function showLocalPage(
window: BrowserWindow,
pageURL: string,
kind: WindowNavigationState['localPageKind'],
): Promise<void> {
if (window.isDestroyed()) {
return
}
const state = navigationStateRegistry.get(window)
if (state) {
state.localPageKind = kind
}
try {
await window.loadURL(pageURL)
} catch (error) {
if (!window.isDestroyed()) {
console.warn('[desktop-window] local page load failed', {
message: error instanceof Error ? error.message : String(error),
})
}
}
}
async function showErrorPage(
window: BrowserWindow,
context: string,
targetURL: string,
message: string,
): Promise<void> {
if (window.isDestroyed()) {
return
}
const state = navigationStateRegistry.get(window)
if (state) {
state.readyForDetection = false
state.lastMainFrameError = message
clearNavigationWatchdogs(state)
}
await showLocalPage(window, errorPageDataURL(context, targetURL, message), 'error')
}
function scheduleRemoteLoadTimeout(
window: BrowserWindow,
state: WindowNavigationState,
targetURL: string,
context: string,
loadToken: number,
): void {
clearLoadTimeoutWatchdog(state)
state.loadTimeoutWatchdog = setTimeout(() => {
if (window.isDestroyed()) {
return
}
const latestState = navigationStateRegistry.get(window)
if (!latestState || latestState.activeLoadToken !== loadToken) {
return
}
const currentURL = window.webContents.getURL()
if (isLocalPageURL(currentURL) && latestState.localPageKind === 'error') {
return
}
console.warn(`[desktop-window] ${context} remote page load timed out`, {
targetURL,
currentURL,
timeoutMs: REMOTE_PAGE_LOAD_TIMEOUT_MS,
})
void showErrorPage(
window,
context,
targetURL,
`页面加载超过 ${Math.round(REMOTE_PAGE_LOAD_TIMEOUT_MS / 1000)} 秒,可能是网络连接或平台服务暂时不可用。`,
)
}, REMOTE_PAGE_LOAD_TIMEOUT_MS)
}
async function navigateRemoteURL(
window: BrowserWindow,
targetURL: string,
context: string,
options: { resetBlankRetry: boolean; showLoadingPage: boolean },
): Promise<void> {
const state = navigationStateRegistry.get(window)
const loadToken = (state?.activeLoadToken ?? 0) + 1
if (state) {
state.activeLoadToken = loadToken
state.activeTargetURL = targetURL
state.readyForDetection = false
if (options.resetBlankRetry) {
state.blankPageRetried = false
}
clearNavigationWatchdogs(state)
}
if (options.showLoadingPage) {
await showLocalPage(window, loadingPageDataURL(context, targetURL), 'loading')
}
if (window.isDestroyed()) {
return
}
if (state && state.activeLoadToken === loadToken) {
scheduleRemoteLoadTimeout(window, state, targetURL, context, loadToken)
}
try {
await window.loadURL(targetURL, { userAgent: STANDARD_USER_AGENT })
const latestState = navigationStateRegistry.get(window)
if (latestState?.activeLoadToken === loadToken) {
clearLoadTimeoutWatchdog(latestState)
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
const latestState = navigationStateRegistry.get(window)
if (latestState?.activeLoadToken === loadToken) {
clearLoadTimeoutWatchdog(latestState)
}
if (ignoredNavigationFailurePattern.test(message) || window.isDestroyed()) {
return
}
console.error(`[desktop-window] ${context} loadURL failed`, {
targetURL,
message,
})
const currentURL = window.webContents.getURL()
if (isLocalPageURL(currentURL) && latestState?.localPageKind === 'error') {
return
}
await showErrorPage(window, context, targetURL, message)
}
}
export function attachWindowDiagnostics(window: BrowserWindow, context: string): void {
navigationStateRegistry.set(window, {
readyForDetection: false,
lastMainFrameError: null,
loadTimeoutWatchdog: null,
blankPageWatchdog: null,
blankPageRetried: false,
localPageKind: null,
activeTargetURL: null,
activeLoadToken: 0,
context,
})
@@ -219,10 +557,36 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
clearBlankPageWatchdog(state)
})
window.webContents.on(
'did-start-navigation',
(_event, url, isInPlace, isMainFrame) => {
if (!isMainFrame || isInPlace || !/^https?:\/\//i.test(url)) {
return
}
const state = navigationStateRegistry.get(window)
if (!state) {
return
}
const fromLocalPage = isLocalPageURL(window.webContents.getURL())
state.readyForDetection = false
state.activeTargetURL = url
if (fromLocalPage) {
state.localPageKind = 'loading'
}
if (fromLocalPage || !state.loadTimeoutWatchdog) {
state.activeLoadToken += 1
scheduleRemoteLoadTimeout(window, state, url, context, state.activeLoadToken)
}
},
)
window.webContents.on(
'did-fail-load',
(_event, errorCode, errorDescription, validatedURL, isMainFrame) => {
if (!isMainFrame || errorCode === -3) {
if (!isMainFrame || isIgnoredNavigationFailure(errorCode, errorDescription)) {
return
}
@@ -230,7 +594,7 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
if (state) {
state.readyForDetection = false
state.lastMainFrameError = `${errorCode}:${errorDescription}`
clearBlankPageWatchdog(state)
clearNavigationWatchdogs(state)
}
console.warn(`[desktop-window] ${context} main-frame load failed`, {
@@ -238,6 +602,9 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
errorDescription,
validatedURL,
})
const targetURL = validatedURL || state?.activeTargetURL || window.webContents.getURL()
void showErrorPage(window, context, targetURL, `${errorCode}: ${errorDescription}`)
},
)
@@ -248,7 +615,12 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
}
const currentURL = window.webContents.getURL()
state.readyForDetection = !currentURL.startsWith(errorPagePrefix)
clearLoadTimeoutWatchdog(state)
const isLocalPage = isLocalPageURL(currentURL)
state.readyForDetection = !isLocalPage
if (!isLocalPage) {
state.localPageKind = null
}
if (state.readyForDetection) {
state.lastMainFrameError = null
scheduleBlankPageWatchdog(window, state)
@@ -259,7 +631,7 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
const state = navigationStateRegistry.get(window)
if (state) {
state.readyForDetection = false
clearBlankPageWatchdog(state)
clearNavigationWatchdogs(state)
}
console.error(`[desktop-window] ${context} renderer gone`, details)
@@ -268,7 +640,7 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
window.once('closed', () => {
const state = navigationStateRegistry.get(window)
if (state) {
clearBlankPageWatchdog(state)
clearNavigationWatchdogs(state)
}
})
}
@@ -276,7 +648,7 @@ export function attachWindowDiagnostics(window: BrowserWindow, context: string):
export function isWindowReadyForDetection(window: BrowserWindow): boolean {
const state = navigationStateRegistry.get(window)
if (!state) {
return !window.webContents.getURL().startsWith(errorPagePrefix)
return !isLocalPageURL(window.webContents.getURL())
}
return state.readyForDetection
}
@@ -286,39 +658,8 @@ export async function loadWindowURLSafely(
targetURL: string,
context: string,
): Promise<void> {
const state = navigationStateRegistry.get(window)
if (state) {
state.blankPageRetried = false
clearBlankPageWatchdog(state)
}
try {
await window.loadURL(targetURL, { userAgent: STANDARD_USER_AGENT })
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
if (ignoredNavigationFailurePattern.test(message) || window.isDestroyed()) {
return
}
console.error(`[desktop-window] ${context} loadURL failed`, {
targetURL,
message,
})
if (window.webContents.getURL().startsWith(errorPagePrefix)) {
return
}
try {
await window.loadURL(errorPageDataURL(context, targetURL, message))
} catch (fallbackError) {
const fallbackMessage =
fallbackError instanceof Error ? fallbackError.message : String(fallbackError)
console.error(`[desktop-window] ${context} error-page loadURL failed`, {
targetURL,
message: fallbackMessage,
})
}
}
await navigateRemoteURL(window, targetURL, context, {
resetBlankRetry: true,
showLoadingPage: true,
})
}