793c310560
- 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>
666 lines
17 KiB
TypeScript
666 lines
17 KiB
TypeScript
import type { BrowserWindow } from 'electron/main'
|
|
|
|
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
|
|
|
|
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
|
|
}
|
|
|
|
const navigationStateRegistry = new WeakMap<BrowserWindow, WindowNavigationState>()
|
|
|
|
function clearBlankPageWatchdog(state: WindowNavigationState): void {
|
|
if (state.blankPageWatchdog) {
|
|
clearTimeout(state.blankPageWatchdog)
|
|
state.blankPageWatchdog = null
|
|
}
|
|
}
|
|
|
|
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> {
|
|
if (window.isDestroyed()) {
|
|
return null
|
|
}
|
|
try {
|
|
return (await window.webContents.executeJavaScript(
|
|
`(() => ({
|
|
nodeCount: document.body ? document.body.querySelectorAll('*').length : 0,
|
|
textLength: document.body ? (document.body.innerText || '').trim().length : 0,
|
|
readyState: document.readyState,
|
|
}))()`,
|
|
true,
|
|
)) as { nodeCount: number; textLength: number; readyState: string }
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function scheduleBlankPageWatchdog(window: BrowserWindow, state: WindowNavigationState): void {
|
|
clearBlankPageWatchdog(state)
|
|
|
|
state.blankPageWatchdog = setTimeout(() => {
|
|
state.blankPageWatchdog = null
|
|
if (window.isDestroyed()) {
|
|
return
|
|
}
|
|
|
|
const currentURL = window.webContents.getURL()
|
|
if (currentURL.startsWith(errorPagePrefix) || currentURL === 'about:blank') {
|
|
return
|
|
}
|
|
|
|
void probePageBodyShape(window).then((shape) => {
|
|
if (!shape || window.isDestroyed()) {
|
|
return
|
|
}
|
|
|
|
const looksBlank =
|
|
shape.nodeCount < BLANK_PAGE_NODE_THRESHOLD &&
|
|
shape.textLength < BLANK_PAGE_TEXT_THRESHOLD
|
|
|
|
if (!looksBlank) {
|
|
return
|
|
}
|
|
|
|
if (state.blankPageRetried) {
|
|
console.warn(`[desktop-window] ${state.context} still blank after auto-reload`, {
|
|
url: currentURL,
|
|
...shape,
|
|
})
|
|
void showErrorPage(
|
|
window,
|
|
state.context,
|
|
currentURL,
|
|
'页面加载完成后仍然是空白状态,可能是网络、平台风控、兼容性或远程脚本加载失败导致。',
|
|
)
|
|
return
|
|
}
|
|
|
|
state.blankPageRetried = true
|
|
console.warn(`[desktop-window] ${state.context} blank page detected, reloading once`, {
|
|
url: currentURL,
|
|
...shape,
|
|
})
|
|
void navigateRemoteURL(window, currentURL, state.context, {
|
|
resetBlankRetry: false,
|
|
showLoadingPage: true,
|
|
})
|
|
})
|
|
}, BLANK_PAGE_PROBE_DELAY_MS)
|
|
}
|
|
|
|
function escapeHtml(value: string): string {
|
|
return value
|
|
.replaceAll('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''')
|
|
}
|
|
|
|
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">
|
|
<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;
|
|
}
|
|
.card {
|
|
width: min(680px, 100%);
|
|
padding: 24px;
|
|
border-radius: 8px;
|
|
border: 1px solid #d9dee7;
|
|
background: #ffffff;
|
|
}
|
|
h1, p, pre {
|
|
margin: 0;
|
|
}
|
|
h1 {
|
|
font-size: 24px;
|
|
letter-spacing: 0;
|
|
}
|
|
p {
|
|
margin-top: 12px;
|
|
line-height: 1.7;
|
|
color: #475569;
|
|
}
|
|
pre {
|
|
margin-top: 16px;
|
|
padding: 14px 16px;
|
|
border-radius: 6px;
|
|
background: #f1f4f8;
|
|
overflow: auto;
|
|
white-space: pre-wrap;
|
|
word-break: break-word;
|
|
}
|
|
.actions {
|
|
display: flex;
|
|
gap: 12px;
|
|
flex-wrap: wrap;
|
|
justify-content: center;
|
|
margin-top: 18px;
|
|
}
|
|
button {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
min-height: 44px;
|
|
padding: 0 16px;
|
|
border-radius: 8px;
|
|
border: 1px solid #d9dee7;
|
|
background: white;
|
|
color: #0f172a;
|
|
font: inherit;
|
|
font-weight: 700;
|
|
cursor: pointer;
|
|
}
|
|
button:disabled {
|
|
cursor: default;
|
|
opacity: 0.72;
|
|
}
|
|
.primary {
|
|
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>如果持续失败,请检查代理、DNS、系统时间和平台是否触发风控。</p>
|
|
<pre>${escapeHtml(message)}
|
|
|
|
${escapeHtml(targetURL)}</pre>
|
|
<div class="actions">
|
|
<button class="primary" onclick="this.disabled = true; this.textContent = '正在重试...'; location.replace(${JSON.stringify(targetURL)})">重新加载</button>
|
|
</div>
|
|
</main>
|
|
</body>
|
|
</html>`
|
|
|
|
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,
|
|
})
|
|
|
|
window.webContents.on('did-start-loading', () => {
|
|
const state = navigationStateRegistry.get(window)
|
|
if (!state) {
|
|
return
|
|
}
|
|
state.readyForDetection = false
|
|
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 || isIgnoredNavigationFailure(errorCode, errorDescription)) {
|
|
return
|
|
}
|
|
|
|
const state = navigationStateRegistry.get(window)
|
|
if (state) {
|
|
state.readyForDetection = false
|
|
state.lastMainFrameError = `${errorCode}:${errorDescription}`
|
|
clearNavigationWatchdogs(state)
|
|
}
|
|
|
|
console.warn(`[desktop-window] ${context} main-frame load failed`, {
|
|
errorCode,
|
|
errorDescription,
|
|
validatedURL,
|
|
})
|
|
|
|
const targetURL = validatedURL || state?.activeTargetURL || window.webContents.getURL()
|
|
void showErrorPage(window, context, targetURL, `${errorCode}: ${errorDescription}`)
|
|
},
|
|
)
|
|
|
|
window.webContents.on('did-finish-load', () => {
|
|
const state = navigationStateRegistry.get(window)
|
|
if (!state) {
|
|
return
|
|
}
|
|
|
|
const currentURL = window.webContents.getURL()
|
|
clearLoadTimeoutWatchdog(state)
|
|
const isLocalPage = isLocalPageURL(currentURL)
|
|
state.readyForDetection = !isLocalPage
|
|
if (!isLocalPage) {
|
|
state.localPageKind = null
|
|
}
|
|
if (state.readyForDetection) {
|
|
state.lastMainFrameError = null
|
|
scheduleBlankPageWatchdog(window, state)
|
|
}
|
|
})
|
|
|
|
window.webContents.on('render-process-gone', (_event, details) => {
|
|
const state = navigationStateRegistry.get(window)
|
|
if (state) {
|
|
state.readyForDetection = false
|
|
clearNavigationWatchdogs(state)
|
|
}
|
|
|
|
console.error(`[desktop-window] ${context} renderer gone`, details)
|
|
})
|
|
|
|
window.once('closed', () => {
|
|
const state = navigationStateRegistry.get(window)
|
|
if (state) {
|
|
clearNavigationWatchdogs(state)
|
|
}
|
|
})
|
|
}
|
|
|
|
export function isWindowReadyForDetection(window: BrowserWindow): boolean {
|
|
const state = navigationStateRegistry.get(window)
|
|
if (!state) {
|
|
return !isLocalPageURL(window.webContents.getURL())
|
|
}
|
|
return state.readyForDetection
|
|
}
|
|
|
|
export async function loadWindowURLSafely(
|
|
window: BrowserWindow,
|
|
targetURL: string,
|
|
context: string,
|
|
): Promise<void> {
|
|
await navigateRemoteURL(window, targetURL, context, {
|
|
resetBlankRetry: true,
|
|
showLoadingPage: true,
|
|
})
|
|
}
|