diff --git a/apps/desktop-client/src/main/external-window.ts b/apps/desktop-client/src/main/external-window.ts index 85ba539..2cf601b 100644 --- a/apps/desktop-client/src/main/external-window.ts +++ b/apps/desktop-client/src/main/external-window.ts @@ -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("'", ''') } +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 = ` @@ -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; + } + }

授权页加载失败

-

${escapeHtml(context)} 在打开远程页面时出现网络或 TLS 握手异常。窗口已保留,不再直接闪退。

-

如果这是偶发网络抖动,点“重新加载”即可;如果持续失败,再看控制台里打印的 URL 和错误码定位。

+

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

+

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

${escapeHtml(message)}
 
 ${escapeHtml(targetURL)}
- - 打开目标地址 +
@@ -201,12 +391,160 @@ ${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, }) @@ -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 { - 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, + }) }