From e5f1c415f8ee0f6df9adb62faa2ef580bfa7f999 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Thu, 9 Jul 2026 20:16:12 +0800 Subject: [PATCH] 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. --- desktop/electron/main.ts | 16 +++---- desktop/electron/services/updater.test.ts | 18 ++++++- desktop/electron/services/updater.ts | 20 ++++++++ desktop/electron/services/windows.test.ts | 57 +++++++++++++++++++++++ desktop/electron/services/windows.ts | 3 ++ desktop/src/pages/Settings.tsx | 2 +- 6 files changed, 104 insertions(+), 12 deletions(-) diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index a76b374c..362b98e9 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -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: '' } - : {} - 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') diff --git a/desktop/electron/services/updater.test.ts b/desktop/electron/services/updater.test.ts index 9208c501..a9eb986f 100644 --- a/desktop/electron/services/updater.test.ts +++ b/desktop/electron/services/updater.test.ts @@ -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: '', + }) + }) + it('does not hide non-metadata updater failures', async () => { const service = new ElectronUpdaterService(updater) updater.checkForUpdates.mockRejectedValueOnce(new Error('feed unavailable')) diff --git a/desktop/electron/services/updater.ts b/desktop/electron/services/updater.ts index 1a1ec48e..24294565 100644 --- a/desktop/electron/services/updater.ts +++ b/desktop/electron/services/updater.ts @@ -17,6 +17,7 @@ export type ElectronUpdateCheckOptions = { export type ElectronUpdaterLike = { autoDownload: boolean + disableDifferentialDownload?: boolean logger?: unknown checkForUpdates(): Promise downloadUpdate(): Promise @@ -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: '' } + : { 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 } diff --git a/desktop/electron/services/windows.test.ts b/desktop/electron/services/windows.test.ts index daa43eef..09a9ae5b 100644 --- a/desktop/electron/services/windows.test.ts +++ b/desktop/electron/services/windows.test.ts @@ -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 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 }) + } + }) }) diff --git a/desktop/electron/services/windows.ts b/desktop/electron/services/windows.ts index 431873ec..aec23c46 100644 --- a/desktop/electron/services/windows.ts +++ b/desktop/electron/services/windows.ts @@ -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 = { diff --git a/desktop/src/pages/Settings.tsx b/desktop/src/pages/Settings.tsx index 3ba2b790..09a5b34d 100644 --- a/desktop/src/pages/Settings.tsx +++ b/desktop/src/pages/Settings.tsx @@ -4512,7 +4512,7 @@ function AboutSettings() { })() return ( -
+
{/* Logo + App Name + Version */} Claude Code Haha

Claude Code Haha