Files
geo/apps/desktop-client/src/main/external-window.ts
T

666 lines
17 KiB
TypeScript
Raw Normal View History

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('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.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">
<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,
})
}