程序员阿江(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

206 lines
7.7 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from 'vitest'
import type { MenuItemConstructorOptions } from 'electron'
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
import { buildApplicationMenuTemplate, installApplicationMenu } from './menu'
const menuMocksKey = '__electronMenuMocks'
function createElectronMenuMocks() {
return {
buildFromTemplate: vi.fn((template: unknown) => ({ template })),
setApplicationMenu: vi.fn(),
}
}
function getElectronMenuMocks() {
const store = globalThis as Record<string, unknown>
const existing = store[menuMocksKey] as ReturnType<typeof createElectronMenuMocks> | undefined
if (existing) return existing
const created = createElectronMenuMocks()
store[menuMocksKey] = created
return created
}
vi.mock('electron', () => {
const mocks = getElectronMenuMocks()
return {
Menu: {
buildFromTemplate: mocks.buildFromTemplate,
setApplicationMenu: mocks.setApplicationMenu,
},
}
})
describe('Electron application menu service', () => {
afterEach(() => {
const mocks = getElectronMenuMocks()
mocks.buildFromTemplate.mockClear()
mocks.setApplicationMenu.mockClear()
})
it('emits native navigation destinations from macOS app menu items', () => {
const onNavigate = vi.fn()
const template = buildApplicationMenuTemplate('Claude Code Haha', onNavigate, 'darwin')
const appMenu = template[0]
expect(appMenu).toBeDefined()
const submenu = appMenu!.submenu as MenuItemConstructorOptions[]
const aboutItem = submenu[0]
const settingsItem = submenu[2]
expect(aboutItem).toBeDefined()
expect(settingsItem).toBeDefined()
aboutItem!.click?.({} as never, {} as never, {} as never)
settingsItem!.click?.({} as never, {} as never, {} as never)
expect(onNavigate).toHaveBeenNthCalledWith(1, 'about')
expect(onNavigate).toHaveBeenNthCalledWith(2, 'settings')
})
it('routes macOS Hide through the provided safe hide action', () => {
const hide = vi.fn()
const template = buildApplicationMenuTemplate('Claude Code Haha', vi.fn(), 'darwin', { hide })
const appMenu = template[0]
const submenu = appMenu!.submenu as MenuItemConstructorOptions[]
const hideItem = submenu.find(item => item.label === 'Hide Claude Code Haha')
expect(hideItem).toBeDefined()
expect(hideItem?.accelerator).toBe('Command+H')
hideItem?.click?.({} as never, {} as never, {} as never)
expect(hide).toHaveBeenCalledTimes(1)
})
it('routes the Window close accelerator through the provided close action', () => {
const close = vi.fn()
const template = buildApplicationMenuTemplate('Claude Code Haha', vi.fn(), 'darwin', { close })
const closeItem = template
.flatMap(item => (item.submenu as MenuItemConstructorOptions[] | undefined) ?? [])
.find(item => item.label === 'Close Window')
expect(closeItem).toBeDefined()
expect(closeItem?.accelerator).toBe('CmdOrCtrl+W')
closeItem?.click?.({} as never, {} as never, {} as never)
expect(close).toHaveBeenCalledTimes(1)
})
it('routes the View fullscreen accelerator through the provided fullscreen action', () => {
const toggleFullScreen = vi.fn()
const template = buildApplicationMenuTemplate('Claude Code Haha', vi.fn(), 'darwin', { toggleFullScreen })
const fullScreenItem = template
.flatMap(item => (item.submenu as MenuItemConstructorOptions[] | undefined) ?? [])
.find(item => item.label === 'Toggle Full Screen')
expect(fullScreenItem).toBeDefined()
expect(fullScreenItem?.accelerator).toBe('Ctrl+Command+F')
fullScreenItem?.click?.({} as never, {} as never, {} as never)
expect(toggleFullScreen).toHaveBeenCalledTimes(1)
})
it('uses F11 for custom fullscreen on non-macOS platforms', () => {
const template = buildApplicationMenuTemplate('Claude Code Haha', vi.fn(), 'linux', {})
const fullScreenItem = template
.flatMap(item => (item.submenu as MenuItemConstructorOptions[] | undefined) ?? [])
.find(item => item.label === 'Toggle Full Screen')
expect(fullScreenItem?.accelerator).toBe('F11')
})
it('keeps a settings entry available on non-macOS platforms', () => {
const template = buildApplicationMenuTemplate('Claude Code Haha', vi.fn(), 'win32')
const fileMenu = template[0]
expect(fileMenu).toBeDefined()
const fileSubmenu = fileMenu!.submenu as MenuItemConstructorOptions[]
expect(fileSubmenu.some(item => item.label === 'Settings...')).toBe(true)
})
it('installs a native menu that forwards settings navigation to the renderer event channel', async () => {
const menuMocks = getElectronMenuMocks()
menuMocks.buildFromTemplate.mockClear()
menuMocks.setApplicationMenu.mockClear()
const send = vi.fn()
await installApplicationMenu(
{ name: 'Claude Code Haha' } as never,
() => ({ webContents: { send } }) as never,
)
expect(menuMocks.buildFromTemplate).toHaveBeenCalledTimes(1)
expect(menuMocks.setApplicationMenu).toHaveBeenCalledWith({
template: menuMocks.buildFromTemplate.mock.calls[0]?.[0],
})
const template = menuMocks.buildFromTemplate.mock.calls[0]?.[0] as MenuItemConstructorOptions[]
const settingsItem = template
.flatMap(item => (item.submenu as MenuItemConstructorOptions[] | undefined) ?? [])
.find(item => item.label === 'Settings...')
expect(settingsItem).toBeDefined()
settingsItem?.click?.({} as never, {} as never, {} as never)
expect(send).toHaveBeenCalledWith(ELECTRON_EVENT_CHANNELS.nativeMenuNavigate, 'settings')
})
it('installs hide as a safe fullscreen-aware window hide before app hide', async () => {
const appHide = vi.fn()
const onceHandlers = new Map<string, (...args: never[]) => void>()
const window = {
isFullScreen: () => true,
isSimpleFullScreen: () => false,
once: vi.fn((event: string, handler: (...args: never[]) => void) => {
onceHandlers.set(event, handler)
}),
setFullScreen: vi.fn(),
hide: vi.fn(),
isDestroyed: () => false,
webContents: { send: vi.fn() },
}
const menuMocks = getElectronMenuMocks()
await installApplicationMenu(
{ name: 'Claude Code Haha', hide: appHide } as never,
() => window as never,
)
const template = menuMocks.buildFromTemplate.mock.calls[0]?.[0] as MenuItemConstructorOptions[]
const hideItem = template
.flatMap(item => (item.submenu as MenuItemConstructorOptions[] | undefined) ?? [])
.find(item => item.label === 'Hide Claude Code Haha')
hideItem?.click?.({} as never, {} as never, {} as never)
expect(window.setFullScreen).toHaveBeenCalledWith(false)
expect(window.hide).not.toHaveBeenCalled()
expect(appHide).not.toHaveBeenCalled()
onceHandlers.get('leave-full-screen')?.()
expect(window.hide).toHaveBeenCalledTimes(1)
expect(appHide).toHaveBeenCalledTimes(1)
})
it('installs fullscreen as simple fullscreen on macOS instead of native Spaces', async () => {
const window = {
isSimpleFullScreen: () => false,
setSimpleFullScreen: vi.fn(),
isFullScreen: vi.fn(),
setFullScreen: vi.fn(),
webContents: { send: vi.fn() },
}
const menuMocks = getElectronMenuMocks()
await installApplicationMenu(
{ name: 'Claude Code Haha' } as never,
() => window as never,
)
const template = menuMocks.buildFromTemplate.mock.calls[0]?.[0] as MenuItemConstructorOptions[]
const fullScreenItem = template
.flatMap(item => (item.submenu as MenuItemConstructorOptions[] | undefined) ?? [])
.find(item => item.label === 'Toggle Full Screen')
fullScreenItem?.click?.({} as never, {} as never, {} as never)
expect(window.setSimpleFullScreen).toHaveBeenCalledWith(true)
expect(window.setFullScreen).not.toHaveBeenCalled()
})
})