mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-20 13:53:32 +08:00
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:
parent
b28867d551
commit
e5f1c415f8
@ -14,7 +14,7 @@ import {
|
|||||||
import { installApplicationMenu } from './services/menu'
|
import { installApplicationMenu } from './services/menu'
|
||||||
import { acquireSingleInstanceLock } from './services/singleInstance'
|
import { acquireSingleInstanceLock } from './services/singleInstance'
|
||||||
import { installTray, shouldInstallTray, type TrayController } from './services/tray'
|
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 { createUpdateSmokeUpdaterFromEnv } from './services/updateSmoke'
|
||||||
import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal'
|
import { ElectronTerminalService, type TerminalSpawnInput } from './services/terminal'
|
||||||
import { ElectronPreviewService, type PreviewBounds } from './services/preview'
|
import { ElectronPreviewService, type PreviewBounds } from './services/preview'
|
||||||
@ -150,14 +150,9 @@ function getUpdaterService() {
|
|||||||
const smokeUpdater = createUpdateSmokeUpdaterFromEnv(process.env)
|
const smokeUpdater = createUpdateSmokeUpdaterFromEnv(process.env)
|
||||||
updaterService ??= new ElectronUpdaterService(smokeUpdater ?? autoUpdater, {
|
updaterService ??= new ElectronUpdaterService(smokeUpdater ?? autoUpdater, {
|
||||||
async apply(proxy) {
|
async apply(proxy) {
|
||||||
const config = proxy
|
// Update traffic runs on electron-updater's own session partition;
|
||||||
? { proxyRules: proxy, proxyBypassRules: '<local>' }
|
// configuring app/defaultSession proxies never reaches it.
|
||||||
: {}
|
await autoUpdater.netSession.setProxy(updaterSessionProxyConfig(proxy))
|
||||||
await Promise.all([
|
|
||||||
app.setProxy(config),
|
|
||||||
session.defaultSession.setProxy(config),
|
|
||||||
])
|
|
||||||
await session.defaultSession.forceReloadProxyConfig()
|
|
||||||
},
|
},
|
||||||
}, {
|
}, {
|
||||||
updateConfigPath: !smokeUpdater && app.isPackaged ? path.join(process.resourcesPath, 'app-update.yml') : undefined,
|
updateConfigPath: !smokeUpdater && app.isPackaged ? path.join(process.resourcesPath, 'app-update.yml') : undefined,
|
||||||
@ -376,7 +371,8 @@ async function createMainWindow() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
mainWindow.on('resize', () => {
|
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', () => {
|
mainWindow.webContents.on('did-finish-load', () => {
|
||||||
writeWindowSmokeSnapshot(mainWindow, 'did-finish-load')
|
writeWindowSmokeSnapshot(mainWindow, 'did-finish-load')
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
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 { mkdtempSync, rmSync } from 'node:fs'
|
||||||
import { tmpdir } from 'node:os'
|
import { tmpdir } from 'node:os'
|
||||||
import { join } from 'node:path'
|
import { join } from 'node:path'
|
||||||
@ -145,6 +145,22 @@ describe('Electron updater service', () => {
|
|||||||
expect(localUpdater.checkForUpdates).toHaveBeenCalledTimes(3)
|
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 () => {
|
it('does not hide non-metadata updater failures', async () => {
|
||||||
const service = new ElectronUpdaterService(updater)
|
const service = new ElectronUpdaterService(updater)
|
||||||
updater.checkForUpdates.mockRejectedValueOnce(new Error('feed unavailable'))
|
updater.checkForUpdates.mockRejectedValueOnce(new Error('feed unavailable'))
|
||||||
|
|||||||
@ -17,6 +17,7 @@ export type ElectronUpdateCheckOptions = {
|
|||||||
|
|
||||||
export type ElectronUpdaterLike = {
|
export type ElectronUpdaterLike = {
|
||||||
autoDownload: boolean
|
autoDownload: boolean
|
||||||
|
disableDifferentialDownload?: boolean
|
||||||
logger?: unknown
|
logger?: unknown
|
||||||
checkForUpdates(): Promise<ElectronUpdateCheckResult>
|
checkForUpdates(): Promise<ElectronUpdateCheckResult>
|
||||||
downloadUpdate(): Promise<unknown>
|
downloadUpdate(): Promise<unknown>
|
||||||
@ -38,6 +39,22 @@ export type ElectronUpdaterRuntimeOptions = {
|
|||||||
updateConfigPath?: string
|
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 {
|
export function normalizeUpdateInfo(info: ElectronUpdateInfo | undefined): ElectronUpdateMetadata | null {
|
||||||
if (!info?.version) return null
|
if (!info?.version) return null
|
||||||
const releaseNotes = Array.isArray(info.releaseNotes)
|
const releaseNotes = Array.isArray(info.releaseNotes)
|
||||||
@ -86,6 +103,9 @@ export class ElectronUpdaterService {
|
|||||||
this.proxyController = proxyController
|
this.proxyController = proxyController
|
||||||
this.updateConfigPath = runtimeOptions.updateConfigPath
|
this.updateConfigPath = runtimeOptions.updateConfigPath
|
||||||
this.updater.autoDownload = false
|
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
|
this.updater.logger = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -124,6 +124,7 @@ describe('Electron window service', () => {
|
|||||||
|
|
||||||
it('does not capture minimized windows', () => {
|
it('does not capture minimized windows', () => {
|
||||||
const window = {
|
const window = {
|
||||||
|
isDestroyed: () => false,
|
||||||
isMinimized: () => true,
|
isMinimized: () => true,
|
||||||
isMaximized: () => false,
|
isMaximized: () => false,
|
||||||
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
|
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
|
||||||
@ -132,6 +133,20 @@ describe('Electron window service', () => {
|
|||||||
expect(captureWindowState(window as never)).toBeNull()
|
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', () => {
|
it('restores persisted bounds and maximized state when reopening the window', () => {
|
||||||
const state = { x: 12, y: 34, width: 1400, height: 900, maximized: true }
|
const state = { x: 12, y: 34, width: 1400, height: 900, maximized: true }
|
||||||
const maximize = vi.fn()
|
const maximize = vi.fn()
|
||||||
@ -260,6 +275,7 @@ describe('Electron window service', () => {
|
|||||||
hide: vi.fn(),
|
hide: vi.fn(),
|
||||||
isSimpleFullScreen: () => false,
|
isSimpleFullScreen: () => false,
|
||||||
isFullScreen: () => false,
|
isFullScreen: () => false,
|
||||||
|
isDestroyed: () => false,
|
||||||
isMinimized: () => false,
|
isMinimized: () => false,
|
||||||
isMaximized: () => false,
|
isMaximized: () => false,
|
||||||
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
|
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
|
||||||
@ -398,6 +414,7 @@ describe('Electron window service', () => {
|
|||||||
handlers.set(event, handler)
|
handlers.set(event, handler)
|
||||||
}),
|
}),
|
||||||
hide: vi.fn(),
|
hide: vi.fn(),
|
||||||
|
isDestroyed: () => false,
|
||||||
isMinimized: () => false,
|
isMinimized: () => false,
|
||||||
isMaximized: () => false,
|
isMaximized: () => false,
|
||||||
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
|
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
|
||||||
@ -416,4 +433,44 @@ describe('Electron window service', () => {
|
|||||||
rmSync(tmp, { recursive: true, force: true })
|
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 })
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -129,6 +129,9 @@ export function writeWindowState(
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function captureWindowState(window: BrowserWindow): StoredWindowState | null {
|
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
|
if (window.isMinimized()) return null
|
||||||
const bounds = window.getBounds()
|
const bounds = window.getBounds()
|
||||||
const state = {
|
const state = {
|
||||||
|
|||||||
@ -4512,7 +4512,7 @@ function AboutSettings() {
|
|||||||
})()
|
})()
|
||||||
|
|
||||||
return (
|
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 */}
|
{/* Logo + App Name + Version */}
|
||||||
<img src={publicAssetPath('app-icon.png')} alt="Claude Code Haha" className="w-20 h-20 mb-4" />
|
<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>
|
<h1 className="text-xl font-bold text-[var(--color-text-primary)]">Claude Code Haha</h1>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user