fix(desktop): repair update proxy/session targeting, quit-and-install crash, and about layout

Update downloads never used the configured proxy because electron-updater
runs all traffic on its own session partition, while the proxy was applied
to app/defaultSession only. Point proxy config at autoUpdater.netSession,
and make the system fallback an explicit `mode: 'system'` since an empty
setProxy config means fixed_servers with no rules (= direct), which also
silently downgraded the default session away from the OS proxy.

Disable differential download: blockmap-driven ranged requests against the
GitHub CDN are RTT-bound and run far below line speed on both macOS and
Windows; full downloads restore expected bandwidth.

Guard captureWindowState and the resize forwarder against destroyed
windows: quitAndInstall tears the window down while late move/resize/close
events still fire, crashing the main process with "Object has been
destroyed".

Widen the About page container from max-w-lg to max-w-2xl so release notes
markdown is no longer cramped.
This commit is contained in:
程序员阿江(Relakkes) 2026-07-09 20:16:12 +08:00
parent b28867d551
commit e5f1c415f8
6 changed files with 104 additions and 12 deletions

View File

@ -14,7 +14,7 @@ import {
import { installApplicationMenu } from './services/menu'
import { acquireSingleInstanceLock } from './services/singleInstance'
import { installTray, shouldInstallTray, type TrayController } from './services/tray'
import { ElectronUpdaterService } from './services/updater'
import { ElectronUpdaterService, updaterSessionProxyConfig } from './services/updater'
import { createUpdateSmokeUpdaterFromEnv } from './services/updateSmoke'
import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal'
import { ElectronPreviewService, type PreviewBounds } from './services/preview'
@ -150,14 +150,9 @@ function getUpdaterService() {
const smokeUpdater = createUpdateSmokeUpdaterFromEnv(process.env)
updaterService ??= new ElectronUpdaterService(smokeUpdater ?? autoUpdater, {
async apply(proxy) {
const config = proxy
? { proxyRules: proxy, proxyBypassRules: '<local>' }
: {}
await Promise.all([
app.setProxy(config),
session.defaultSession.setProxy(config),
])
await session.defaultSession.forceReloadProxyConfig()
// Update traffic runs on electron-updater's own session partition;
// configuring app/defaultSession proxies never reaches it.
await autoUpdater.netSession.setProxy(updaterSessionProxyConfig(proxy))
},
}, {
updateConfigPath: !smokeUpdater && app.isPackaged ? path.join(process.resourcesPath, 'app-update.yml') : undefined,
@ -376,7 +371,8 @@ async function createMainWindow() {
})
mainWindow.on('resize', () => {
mainWindow?.webContents.send(ELECTRON_EVENT_CHANNELS.windowResized)
if (!mainWindow || mainWindow.isDestroyed()) return
mainWindow.webContents.send(ELECTRON_EVENT_CHANNELS.windowResized)
})
mainWindow.webContents.on('did-finish-load', () => {
writeWindowSmokeSnapshot(mainWindow, 'did-finish-load')

View File

@ -1,5 +1,5 @@
import { describe, expect, it, vi } from 'vitest'
import { ElectronUpdaterService, normalizeUpdateInfo, type ElectronUpdaterLike } from './updater'
import { ElectronUpdaterService, normalizeUpdateInfo, updaterSessionProxyConfig, type ElectronUpdaterLike } from './updater'
import { mkdtempSync, rmSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@ -145,6 +145,22 @@ describe('Electron updater service', () => {
expect(localUpdater.checkForUpdates).toHaveBeenCalledTimes(3)
})
it('disables differential download so update downloads run at full bandwidth', () => {
const localUpdater = fakeUpdater()
void new ElectronUpdaterService(localUpdater)
expect(localUpdater.disableDifferentialDownload).toBe(true)
})
it('maps proxy settings to the updater net session proxy config', () => {
expect(updaterSessionProxyConfig(null)).toEqual({ mode: 'system' })
expect(updaterSessionProxyConfig('http://127.0.0.1:7890')).toEqual({
proxyRules: 'http://127.0.0.1:7890',
proxyBypassRules: '<local>',
})
})
it('does not hide non-metadata updater failures', async () => {
const service = new ElectronUpdaterService(updater)
updater.checkForUpdates.mockRejectedValueOnce(new Error('feed unavailable'))

View File

@ -17,6 +17,7 @@ export type ElectronUpdateCheckOptions = {
export type ElectronUpdaterLike = {
autoDownload: boolean
disableDifferentialDownload?: boolean
logger?: unknown
checkForUpdates(): Promise<ElectronUpdateCheckResult>
downloadUpdate(): Promise<unknown>
@ -38,6 +39,22 @@ export type ElectronUpdaterRuntimeOptions = {
updateConfigPath?: string
}
export type UpdaterSessionProxyConfig = {
mode?: 'system'
proxyRules?: string
proxyBypassRules?: string
}
// electron-updater performs all update traffic (metadata + downloads) on its
// own session partition, so proxy settings must target that session. Passing
// an empty config would mean fixed_servers with no rules (= direct), so the
// system fallback has to be an explicit `mode: 'system'`.
export function updaterSessionProxyConfig(proxy: string | null): UpdaterSessionProxyConfig {
return proxy
? { proxyRules: proxy, proxyBypassRules: '<local>' }
: { mode: 'system' }
}
export function normalizeUpdateInfo(info: ElectronUpdateInfo | undefined): ElectronUpdateMetadata | null {
if (!info?.version) return null
const releaseNotes = Array.isArray(info.releaseNotes)
@ -86,6 +103,9 @@ export class ElectronUpdaterService {
this.proxyController = proxyController
this.updateConfigPath = runtimeOptions.updateConfigPath
this.updater.autoDownload = false
// Differential download issues many small sequential range requests and is
// RTT-bound against the GitHub CDN, so it downloads far below line speed.
this.updater.disableDifferentialDownload = true
this.updater.logger = null
}

View File

@ -124,6 +124,7 @@ describe('Electron window service', () => {
it('does not capture minimized windows', () => {
const window = {
isDestroyed: () => false,
isMinimized: () => true,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
@ -132,6 +133,20 @@ describe('Electron window service', () => {
expect(captureWindowState(window as never)).toBeNull()
})
it('does not capture destroyed windows', () => {
const destroyedAccess = () => {
throw new TypeError('Object has been destroyed')
}
const window = {
isDestroyed: () => true,
isMinimized: destroyedAccess,
isMaximized: destroyedAccess,
getBounds: destroyedAccess,
}
expect(captureWindowState(window as never)).toBeNull()
})
it('restores persisted bounds and maximized state when reopening the window', () => {
const state = { x: 12, y: 34, width: 1400, height: 900, maximized: true }
const maximize = vi.fn()
@ -260,6 +275,7 @@ describe('Electron window service', () => {
hide: vi.fn(),
isSimpleFullScreen: () => false,
isFullScreen: () => false,
isDestroyed: () => false,
isMinimized: () => false,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
@ -398,6 +414,7 @@ describe('Electron window service', () => {
handlers.set(event, handler)
}),
hide: vi.fn(),
isDestroyed: () => false,
isMinimized: () => false,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
@ -416,4 +433,44 @@ describe('Electron window service', () => {
rmSync(tmp, { recursive: true, force: true })
}
})
it('ignores late move and resize events after the window is destroyed during quit-and-install', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-destroyed-events-'))
try {
const handlers = new Map<string, (...args: never[]) => void>()
let destroyed = false
const destroyedAccess = () => {
if (destroyed) throw new TypeError('Object has been destroyed')
return false
}
const app = fakeApp(tmp)
const window = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers.set(event, handler)
}),
hide: vi.fn(),
isDestroyed: () => destroyed,
isMinimized: destroyedAccess,
isMaximized: destroyedAccess,
getBounds: () => {
if (destroyed) throw new TypeError('Object has been destroyed')
return { x: 0, y: 0, width: 1280, height: 820 }
},
}
installWindowLifecycle({
app: app as never,
window: window as never,
shouldQuit: () => true,
})
destroyed = true
expect(() => handlers.get('move')?.()).not.toThrow()
expect(() => handlers.get('resize')?.()).not.toThrow()
expect(() => handlers.get('close')?.({ preventDefault: vi.fn() } as never)).not.toThrow()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
})

View File

@ -129,6 +129,9 @@ export function writeWindowState(
}
export function captureWindowState(window: BrowserWindow): StoredWindowState | null {
// quitAndInstall/quit can emit late move/resize/close events on an already
// torn-down native window; touching it then throws "Object has been destroyed".
if (window.isDestroyed()) return null
if (window.isMinimized()) return null
const bounds = window.getBounds()
const state = {

View File

@ -4512,7 +4512,7 @@ function AboutSettings() {
})()
return (
<div className="w-full min-w-0 max-w-lg mx-auto flex flex-col items-center py-6">
<div className="w-full min-w-0 max-w-2xl mx-auto flex flex-col items-center py-6">
{/* Logo + App Name + Version */}
<img src={publicAssetPath('app-icon.png')} alt="Claude Code Haha" className="w-20 h-20 mb-4" />
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Claude Code Haha</h1>