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,
+ })
}