Files
goodbuddy/src/main/window.ts
T
mesalogo b755951ecd fix: offer recovery for blank desktop windows
React render failures now show a localized reload page, and renderer crashes offer native reload confirmation. Record renderer and load failures in existing diagnostics without automatic retry loops. Reload may lose unsaved input; the intermittent failure cause remains unconfirmed.

Release note: Recover from desktop rendering failures using a visible Reload action instead of being left without a recovery entry point.
2026-09-15 08:07:08 +08:00

192 lines
5.7 KiB
TypeScript

import { app, BrowserWindow, dialog, nativeImage, shell } from 'electron'
import { dirname, join, posix, win32 } from 'node:path'
import { fileURLToPath } from 'node:url'
import type { DesktopDiagnosticFailureObserver } from './desktop-diagnostics'
const currentDirectory = dirname(fileURLToPath(import.meta.url))
type WindowIconEnvironment = {
platform: NodeJS.Platform
isPackaged: boolean
appPath: string
resourcesPath: string
}
export function resolveWindowIcon(
environment: WindowIconEnvironment = {
platform: process.platform,
isPackaged: app.isPackaged,
appPath: app.getAppPath(),
resourcesPath: process.resourcesPath
}
): string | undefined {
if (environment.platform === 'darwin') {
return undefined
}
const fileName =
environment.platform === 'win32'
? environment.isPackaged
? 'icon.ico'
: 'icon-taskbar.ico'
: 'icon.png'
const joinPath =
environment.platform === 'win32' ? win32.join : posix.join
return environment.isPackaged
? joinPath(environment.resourcesPath, fileName)
: joinPath(environment.appPath, 'build', fileName)
}
function isAllowedExternalUrl(url: string): boolean {
try {
return ['http:', 'https:'].includes(new URL(url).protocol)
} catch {
return false
}
}
function hasSameOrigin(url: string, allowedUrl: string): boolean {
try {
return new URL(url).origin === new URL(allowedUrl).origin
} catch {
return false
}
}
export function createMainWindow(
shouldQuit: () => boolean,
observeFailure?: DesktopDiagnosticFailureObserver
): BrowserWindow {
const iconPath = resolveWindowIcon()
const icon = iconPath
? nativeImage.createFromPath(iconPath)
: undefined
const usableIcon = icon && !icon.isEmpty() ? icon : undefined
const window = new BrowserWindow({
width: 1180,
height: 760,
minWidth: 680,
minHeight: 560,
show: false,
frame: false,
...(usableIcon ? { icon: usableIcon } : {}),
backgroundColor: '#f4f1ea',
webPreferences: {
preload: join(currentDirectory, '../preload/index.cjs'),
contextIsolation: true,
nodeIntegration: false,
sandbox: true
}
})
if (usableIcon) {
window.setIcon(usableIcon)
}
window.once('ready-to-show', () => {
window.show()
})
window.on('close', (event) => {
if (!shouldQuit()) {
event.preventDefault()
window.hide()
}
})
window.webContents.setWindowOpenHandler(({ url }) => {
if (isAllowedExternalUrl(url)) {
void shell.openExternal(url)
}
return { action: 'deny' }
})
window.webContents.on('will-navigate', (event, url) => {
const developmentUrl = process.env.ELECTRON_RENDERER_URL
if (!developmentUrl || !hasSameOrigin(url, developmentUrl)) {
event.preventDefault()
}
})
let recoveryPending = false
window.webContents.on('render-process-gone', (_event, details) => {
if (details.reason === 'clean-exit' || shouldQuit() || window.isDestroyed()) return
console.error('GoodBuddy renderer exited', details.reason, details.exitCode)
observeFailure?.({
component: 'desktop',
stage: 'renderer',
code: 'desktop.renderer.gone',
error: new Error(`Renderer exited: ${details.reason} (${details.exitCode})`)
})
if (recoveryPending) return
recoveryPending = true
// No automatic retries: a persistent crash requires a fresh user action.
const en = !app.getLocale().toLowerCase().startsWith('zh')
void dialog.showMessageBox(window, {
type: 'error',
title: 'GoodBuddy',
message: en ? 'The GoodBuddy window stopped working' : 'GoodBuddy 窗口已停止工作',
detail: en
? 'Reload to try again. Unsaved input may be lost. If this happens again, quit and reopen GoodBuddy.'
: '请重新加载后重试。尚未保存的输入可能丢失。如果再次出现,请退出并重新打开 GoodBuddy。',
buttons: en ? ['Reload', 'Cancel'] : ['重新加载', '取消'],
defaultId: 0,
cancelId: 1,
noLink: true
}).then(({ response }) => {
if (response === 0 && !shouldQuit() && !window.isDestroyed() && !window.webContents.isDestroyed()) {
window.webContents.reload()
}
}).catch((error: unknown) => {
console.error('GoodBuddy renderer recovery failed', error)
}).finally(() => {
recoveryPending = false
})
})
window.webContents.on('did-fail-load', (_event, code, description, _url, isMainFrame) => {
if (!isMainFrame || code === -3 || shouldQuit() || window.isDestroyed()) return
console.error('GoodBuddy window load failed', code, description)
observeFailure?.({
component: 'desktop', stage: 'renderer', code: 'desktop.renderer.load-failed',
error: new Error(`Window load failed: ${code}`)
})
})
return window
}
export function loadMainWindow(
window: BrowserWindow,
storageUpgrade = false
): void {
if (process.env.ELECTRON_RENDERER_URL) {
const url = new URL(process.env.ELECTRON_RENDERER_URL)
if (storageUpgrade) url.searchParams.set('storageUpgrade', '1')
void window.loadURL(url.href).catch((error: unknown) => {
console.error('GoodBuddy window load rejected', error)
})
} else {
void window.loadFile(
join(currentDirectory, '../renderer/index.html'),
storageUpgrade ? { query: { storageUpgrade: '1' } } : undefined
).catch((error: unknown) => {
console.error('GoodBuddy window load rejected', error)
})
}
}
export function showWindow(window: BrowserWindow): void {
if (window.isMinimized()) {
window.restore()
}
window.show()
window.focus()
}
export function toggleWindow(window: BrowserWindow): void {
if (window.isVisible() && window.isFocused()) {
window.hide()
return
}
showWindow(window)
}