cc-haha/desktop/electron/services/windows.test.ts
程序员阿江(Relakkes) 16f4137954 fix(desktop): stabilize Electron proxy and fullscreen handling
Electron desktop runs network-sensitive OpenAI OAuth token exchange in the sidecar process, so the sidecar now receives system proxy env derived from Electron's cross-platform proxy resolver and the OAuth token client uses the existing proxy fetch options. General manual proxy settings also document and preserve authenticated proxy URLs.

The macOS fullscreen black-screen path is addressed by avoiding native fullscreen Spaces for app fullscreen toggles and by leaving fullscreen before hiding or closing the window.

Constraint: Electron packaged apps may not inherit shell HTTPS_PROXY env when launched from Finder.
Constraint: Manual authenticated proxies must remain standard HTTP(S) proxy URLs for Bun/undici compatibility.
Rejected: Store proxy username and password as separate fields | would require new secret-storage semantics and migration beyond this bugfix.
Rejected: Use native macOS fullscreen Spaces for the desktop app | reproduced black-screen behavior when hiding/closing from fullscreen.
Confidence: high
Scope-risk: moderate
Directive: Do not remove sidecar proxy env injection without retesting OpenAI OAuth from a packaged app launched outside a shell.
Tested: bun test src/services/openaiAuth/client.test.ts src/server/__tests__/haha-openai-oauth-service.test.ts
Tested: bun test src/server/__tests__/network-settings.test.ts
Tested: cd desktop && bun run test -- src/__tests__/generalSettings.test.tsx --run
Tested: cd desktop && bun run check:electron
Tested: git diff --check
Not-tested: bun run check:server blocked by expired quarantine entries server:cron-scheduler, server:providers-real, server:tasks, server:e2e:business-flow, server:e2e:full-flow
Not-tested: live OpenAI OAuth through a corporate authenticated proxy
2026-06-02 21:37:35 +08:00

286 lines
9.3 KiB
TypeScript

import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
import path from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it, vi } from 'vitest'
import {
captureWindowState,
hideWindowSafely,
hasMeaningfulIntersection,
installWindowLifecycle,
isPersistableWindowState,
isWindowStateVisibleOnAnyDisplay,
readWindowState,
restoreWindowMaximized,
showMainWindow,
toggleWindowFullScreen,
windowOptionsFromState,
windowStatePath,
writeWindowState,
} from './windows'
const fakeApp = (userData: string) => ({
getPath: vi.fn(() => userData),
})
describe('Electron window service', () => {
it('persists window state in CLAUDE_CONFIG_DIR when portable config is active', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-'))
try {
const app = fakeApp(path.join(tmp, 'user-data'))
const state = { x: 10, y: 20, width: 1280, height: 820, maximized: false }
writeWindowState(app as never, state, { CLAUDE_CONFIG_DIR: tmp })
const statePath = windowStatePath(app as never, { CLAUDE_CONFIG_DIR: tmp })
expect(JSON.parse(readFileSync(statePath, 'utf-8'))).toEqual(state)
expect(app.getPath).not.toHaveBeenCalled()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('rejects undersized or off-screen state before restore', () => {
expect(isPersistableWindowState({ x: 0, y: 0, width: 100, height: 100, maximized: false })).toBe(false)
expect(hasMeaningfulIntersection(
{ x: 5000, y: 5000, width: 1280, height: 820 },
{ x: 0, y: 0, width: 1440, height: 900 },
)).toBe(false)
expect(isWindowStateVisibleOnAnyDisplay(
{ x: 100, y: 100, width: 1280, height: 820, maximized: false },
[{ bounds: { x: 0, y: 0, width: 1440, height: 900 }, workArea: { x: 0, y: 0, width: 1440, height: 860 } }],
)).toBe(true)
})
it('reads only valid visible window state', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-state-read-'))
try {
const app = fakeApp(tmp)
const state = { x: 50, y: 60, width: 1280, height: 820, maximized: true }
writeWindowState(app as never, state, {})
expect(readWindowState(
app as never,
[{ bounds: { x: 0, y: 0, width: 1440, height: 900 }, workArea: { x: 0, y: 0, width: 1440, height: 860 } }],
{},
)).toEqual(state)
expect(readWindowState(
app as never,
[{ bounds: { x: 3000, y: 3000, width: 1440, height: 900 }, workArea: { x: 3000, y: 3000, width: 1440, height: 860 } }],
{},
)).toBeNull()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('does not capture minimized windows', () => {
const window = {
isMinimized: () => true,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
}
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()
expect(windowOptionsFromState(state)).toEqual({
x: 12,
y: 34,
width: 1400,
height: 900,
})
restoreWindowMaximized({ maximize } as never, state)
expect(maximize).toHaveBeenCalledTimes(1)
})
it('shows, restores, and focuses the hidden main window when a tray or notification action reopens it', () => {
const window = {
isVisible: () => false,
isMinimized: () => true,
show: vi.fn(),
restore: vi.fn(),
focus: vi.fn(),
}
showMainWindow(window as never)
expect(window.show).toHaveBeenCalledTimes(1)
expect(window.restore).toHaveBeenCalledTimes(1)
expect(window.focus).toHaveBeenCalledTimes(1)
})
it('hides instead of closing until the app is explicitly quitting', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-close-'))
try {
const handlers = new Map<string, (...args: never[]) => void>()
const preventDefault = vi.fn()
const window = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers.set(event, handler)
}),
hide: vi.fn(),
isSimpleFullScreen: () => false,
isFullScreen: () => false,
isMinimized: () => false,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
}
installWindowLifecycle({
app: fakeApp(tmp) as never,
window: window as never,
shouldQuit: () => false,
})
handlers.get('close')?.({ preventDefault } as never)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(window.hide).toHaveBeenCalledTimes(1)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('exits fullscreen before hiding on close to avoid a black macOS fullscreen Space', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-fullscreen-close-'))
try {
const handlers = new Map<string, (...args: never[]) => void>()
const onceHandlers = new Map<string, (...args: never[]) => void>()
const preventDefault = vi.fn()
const window = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers.set(event, handler)
}),
once: vi.fn((event: string, handler: (...args: never[]) => void) => {
onceHandlers.set(event, handler)
}),
hide: vi.fn(),
setFullScreen: vi.fn(),
isDestroyed: () => false,
isSimpleFullScreen: () => false,
isFullScreen: () => true,
isMinimized: () => false,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
}
installWindowLifecycle({
app: fakeApp(tmp) as never,
window: window as never,
shouldQuit: () => false,
})
handlers.get('close')?.({ preventDefault } as never)
expect(preventDefault).toHaveBeenCalledTimes(1)
expect(window.setFullScreen).toHaveBeenCalledWith(false)
expect(window.hide).not.toHaveBeenCalled()
onceHandlers.get('leave-full-screen')?.()
expect(window.hide).toHaveBeenCalledTimes(1)
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
it('runs follow-up hide actions only after fullscreen has been left', () => {
const onceHandlers = new Map<string, (...args: never[]) => void>()
const afterHide = vi.fn()
const window = {
once: vi.fn((event: string, handler: (...args: never[]) => void) => {
onceHandlers.set(event, handler)
}),
hide: vi.fn(),
setFullScreen: vi.fn(),
isDestroyed: () => false,
isSimpleFullScreen: () => false,
isFullScreen: () => true,
}
hideWindowSafely(window as never, afterHide)
expect(window.setFullScreen).toHaveBeenCalledWith(false)
expect(window.hide).not.toHaveBeenCalled()
expect(afterHide).not.toHaveBeenCalled()
onceHandlers.get('leave-full-screen')?.()
expect(window.hide).toHaveBeenCalledTimes(1)
expect(afterHide).toHaveBeenCalledTimes(1)
})
it('hides immediately after leaving simple fullscreen because it does not create a macOS Space', () => {
const afterHide = vi.fn()
const window = {
isSimpleFullScreen: () => true,
setSimpleFullScreen: vi.fn(),
hide: vi.fn(),
}
hideWindowSafely(window as never, afterHide)
expect(window.setSimpleFullScreen).toHaveBeenCalledWith(false)
expect(window.hide).toHaveBeenCalledTimes(1)
expect(afterHide).toHaveBeenCalledTimes(1)
})
it('toggles simple fullscreen on macOS instead of native fullscreen Spaces', () => {
const window = {
isSimpleFullScreen: () => false,
setSimpleFullScreen: vi.fn(),
isFullScreen: vi.fn(),
setFullScreen: vi.fn(),
}
toggleWindowFullScreen(window as never, 'darwin')
expect(window.setSimpleFullScreen).toHaveBeenCalledWith(true)
expect(window.setFullScreen).not.toHaveBeenCalled()
})
it('toggles native fullscreen on non-macOS platforms', () => {
const window = {
isSimpleFullScreen: vi.fn(),
setSimpleFullScreen: vi.fn(),
isFullScreen: () => false,
setFullScreen: vi.fn(),
}
toggleWindowFullScreen(window as never, 'linux')
expect(window.setFullScreen).toHaveBeenCalledWith(true)
expect(window.setSimpleFullScreen).not.toHaveBeenCalled()
})
it('allows the window to close normally once the app is explicitly quitting', () => {
const tmp = mkdtempSync(path.join(tmpdir(), 'electron-window-quit-'))
try {
const handlers = new Map<string, (...args: never[]) => void>()
const preventDefault = vi.fn()
const window = {
on: vi.fn((event: string, handler: (...args: never[]) => void) => {
handlers.set(event, handler)
}),
hide: vi.fn(),
isMinimized: () => false,
isMaximized: () => false,
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
}
installWindowLifecycle({
app: fakeApp(tmp) as never,
window: window as never,
shouldQuit: () => true,
})
handlers.get('close')?.({ preventDefault } as never)
expect(preventDefault).not.toHaveBeenCalled()
expect(window.hide).not.toHaveBeenCalled()
} finally {
rmSync(tmp, { recursive: true, force: true })
}
})
})