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() 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 = ` ${escapeHtml(context)} - 正在打开

正在打开${escapeHtml(context)}

如果平台网络较慢,窗口会停留在这里;加载失败时会给出重试入口。

${escapeHtml(targetHostLabel(targetURL))}
` return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}` } function errorPageDataURL(context: string, targetURL: string, message: string): string { const html = ` ${escapeHtml(context)}

授权页加载失败

${escapeHtml(context)} 在打开远程页面时遇到网络、TLS、平台脚本或兼容性异常。窗口已保留,可以直接重试。

如果持续失败,请检查代理、DNS、系统时间和平台是否触发风控。

${escapeHtml(message)}

${escapeHtml(targetURL)}
` return `data:text/html;charset=UTF-8,${encodeURIComponent(html)}` } async function showLocalPage( window: BrowserWindow, pageURL: string, kind: WindowNavigationState['localPageKind'], ): Promise { 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 { 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 { 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 { await navigateRemoteURL(window, targetURL, context, { resetBlankRetry: true, showLoadingPage: true, }) }