mirror of
https://github.com/NanmiCoder/cc-haha
synced 2026-07-17 13:13:35 +08:00
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
This commit is contained in:
parent
232855fe77
commit
16f4137954
@ -85,6 +85,7 @@ function getServerRuntime() {
|
||||
desktopRoot: unpackedRoot(),
|
||||
appRoot: appRoot(),
|
||||
h5DistDir: path.join(unpackedRoot(), 'dist'),
|
||||
resolveSystemProxy: (url) => session.defaultSession.resolveProxy(url),
|
||||
})
|
||||
return serverRuntime
|
||||
}
|
||||
@ -287,6 +288,7 @@ async function createMainWindow() {
|
||||
minHeight: MIN_WINDOW_HEIGHT,
|
||||
show: false,
|
||||
titleBarStyle: process.platform === 'darwin' ? 'hiddenInset' : 'default',
|
||||
fullscreenable: process.platform !== 'darwin',
|
||||
webPreferences: {
|
||||
preload: preloadPath(),
|
||||
contextIsolation: true,
|
||||
|
||||
@ -56,6 +56,57 @@ describe('Electron application menu service', () => {
|
||||
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]
|
||||
@ -90,4 +141,65 @@ describe('Electron application menu service', () => {
|
||||
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()
|
||||
})
|
||||
})
|
||||
|
||||
@ -1,12 +1,19 @@
|
||||
import type { App, BrowserWindow, MenuItemConstructorOptions } from 'electron'
|
||||
import { ELECTRON_EVENT_CHANNELS } from '../ipc/channels'
|
||||
import { hideWindowSafely, toggleWindowFullScreen } from './windows'
|
||||
|
||||
export type NativeMenuDestination = 'about' | 'settings'
|
||||
type ApplicationMenuActions = {
|
||||
hide?: () => void
|
||||
close?: () => void
|
||||
toggleFullScreen?: () => void
|
||||
}
|
||||
|
||||
export function buildApplicationMenuTemplate(
|
||||
appName: string,
|
||||
onNavigate: (destination: NativeMenuDestination) => void,
|
||||
platform = process.platform,
|
||||
actions: ApplicationMenuActions = {},
|
||||
): MenuItemConstructorOptions[] {
|
||||
const appMenu: MenuItemConstructorOptions[] = platform === 'darwin'
|
||||
? [{
|
||||
@ -18,7 +25,7 @@ export function buildApplicationMenuTemplate(
|
||||
{ type: 'separator' },
|
||||
{ role: 'services' },
|
||||
{ type: 'separator' },
|
||||
{ role: 'hide' },
|
||||
{ label: `Hide ${appName}`, accelerator: 'Command+H', click: () => actions.hide?.() },
|
||||
{ role: 'hideOthers' },
|
||||
{ role: 'unhide' },
|
||||
{ type: 'separator' },
|
||||
@ -51,7 +58,11 @@ export function buildApplicationMenuTemplate(
|
||||
{
|
||||
label: 'View',
|
||||
submenu: [
|
||||
{ role: 'togglefullscreen' },
|
||||
{
|
||||
label: 'Toggle Full Screen',
|
||||
accelerator: platform === 'darwin' ? 'Ctrl+Command+F' : 'F11',
|
||||
click: () => actions.toggleFullScreen?.(),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@ -59,7 +70,7 @@ export function buildApplicationMenuTemplate(
|
||||
submenu: [
|
||||
{ role: 'minimize' },
|
||||
{ role: 'zoom' },
|
||||
{ role: 'close' },
|
||||
{ label: 'Close Window', accelerator: 'CmdOrCtrl+W', click: () => actions.close?.() },
|
||||
],
|
||||
},
|
||||
]
|
||||
@ -69,6 +80,22 @@ export async function installApplicationMenu(app: App, getMainWindow: () => Brow
|
||||
const { Menu } = await import('electron')
|
||||
const template = buildApplicationMenuTemplate(app.name || 'Claude Code Haha', destination => {
|
||||
getMainWindow()?.webContents.send(ELECTRON_EVENT_CHANNELS.nativeMenuNavigate, destination)
|
||||
}, process.platform, {
|
||||
hide: () => {
|
||||
const window = getMainWindow()
|
||||
if (!window) {
|
||||
app.hide?.()
|
||||
return
|
||||
}
|
||||
hideWindowSafely(window, () => app.hide?.())
|
||||
},
|
||||
close: () => {
|
||||
getMainWindow()?.close()
|
||||
},
|
||||
toggleFullScreen: () => {
|
||||
const window = getMainWindow()
|
||||
if (window) toggleWindowFullScreen(window)
|
||||
},
|
||||
})
|
||||
Menu.setApplicationMenu(Menu.buildFromTemplate(template))
|
||||
}
|
||||
|
||||
@ -4,6 +4,8 @@ import {
|
||||
createServerPlan,
|
||||
formatStartupError,
|
||||
killSidecar,
|
||||
mergeProxyEnv,
|
||||
proxyUrlFromElectronProxyRules,
|
||||
pushStartupLog,
|
||||
reserveLocalPort,
|
||||
SERVER_BIND_HOST,
|
||||
@ -17,12 +19,15 @@ type ServerRuntimeOptions = {
|
||||
desktopRoot: string
|
||||
appRoot?: string
|
||||
h5DistDir?: string
|
||||
resolveSystemProxy?: (url: string) => Promise<string>
|
||||
}
|
||||
|
||||
export class ElectronServerRuntime {
|
||||
private readonly desktopRoot: string
|
||||
private readonly appRoot: string
|
||||
private readonly h5DistDir: string
|
||||
private readonly resolveSystemProxy?: (url: string) => Promise<string>
|
||||
private sidecarEnvPromise: Promise<NodeJS.ProcessEnv> | null = null
|
||||
private server: { url: string, child: SidecarChild } | null = null
|
||||
private adapters: SidecarChild[] = []
|
||||
private startupError: string | null = null
|
||||
@ -32,6 +37,7 @@ export class ElectronServerRuntime {
|
||||
this.desktopRoot = options.desktopRoot
|
||||
this.appRoot = options.appRoot ?? options.desktopRoot
|
||||
this.h5DistDir = options.h5DistDir ?? path.join(options.desktopRoot, 'dist')
|
||||
this.resolveSystemProxy = options.resolveSystemProxy
|
||||
}
|
||||
|
||||
async startServer(): Promise<string> {
|
||||
@ -55,7 +61,7 @@ export class ElectronServerRuntime {
|
||||
async restartAdaptersSidecars(): Promise<void> {
|
||||
this.stopAdaptersSidecars()
|
||||
const serverUrl = await this.getServerUrl()
|
||||
this.startAdaptersSidecars(serverUrl)
|
||||
await this.startAdaptersSidecars(serverUrl)
|
||||
}
|
||||
|
||||
stopAll() {
|
||||
@ -70,11 +76,13 @@ export class ElectronServerRuntime {
|
||||
const port = await reserveLocalPort(SERVER_BIND_HOST)
|
||||
const url = `http://${SERVER_CONTROL_HOST}:${port}`
|
||||
const logs: string[] = []
|
||||
const env = await this.resolveSidecarBaseEnv()
|
||||
const plan = createServerPlan({
|
||||
desktopRoot: this.desktopRoot,
|
||||
appRoot: this.appRoot,
|
||||
port,
|
||||
h5DistDir: this.h5DistDir,
|
||||
env,
|
||||
})
|
||||
|
||||
try {
|
||||
@ -83,7 +91,7 @@ export class ElectronServerRuntime {
|
||||
await waitForServer(SERVER_CONTROL_HOST, port)
|
||||
this.server = { url, child }
|
||||
this.startupError = null
|
||||
this.startAdaptersSidecars(url)
|
||||
await this.startAdaptersSidecars(url)
|
||||
return url
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
@ -92,7 +100,8 @@ export class ElectronServerRuntime {
|
||||
}
|
||||
}
|
||||
|
||||
private startAdaptersSidecars(serverUrl: string) {
|
||||
private async startAdaptersSidecars(serverUrl: string): Promise<void> {
|
||||
const env = await this.resolveSidecarBaseEnv()
|
||||
for (const [label, flag] of [
|
||||
['feishu', '--feishu'],
|
||||
['telegram', '--telegram'],
|
||||
@ -106,6 +115,7 @@ export class ElectronServerRuntime {
|
||||
h5DistDir: this.h5DistDir,
|
||||
serverUrl,
|
||||
flag,
|
||||
env,
|
||||
}))
|
||||
this.captureLogs(child, `claude-adapters:${label}`)
|
||||
this.adapters.push(child)
|
||||
@ -140,4 +150,24 @@ export class ElectronServerRuntime {
|
||||
if (startupLogs) pushStartupLog(startupLogs, `[exit] ${line}`)
|
||||
})
|
||||
}
|
||||
|
||||
private async resolveSidecarBaseEnv(): Promise<NodeJS.ProcessEnv> {
|
||||
this.sidecarEnvPromise ??= this.resolveSidecarBaseEnvOnce()
|
||||
return await this.sidecarEnvPromise
|
||||
}
|
||||
|
||||
private async resolveSidecarBaseEnvOnce(): Promise<NodeJS.ProcessEnv> {
|
||||
if (!this.resolveSystemProxy) return process.env
|
||||
|
||||
try {
|
||||
const rules = await this.resolveSystemProxy('https://auth.openai.com/')
|
||||
return mergeProxyEnv(
|
||||
process.env,
|
||||
proxyUrlFromElectronProxyRules(rules),
|
||||
)
|
||||
} catch (error) {
|
||||
console.error('[desktop] failed to resolve system proxy for sidecars', error)
|
||||
return process.env
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -7,6 +7,8 @@ import {
|
||||
createAdapterPlan,
|
||||
createServerPlan,
|
||||
httpToWebSocketUrl,
|
||||
mergeProxyEnv,
|
||||
proxyUrlFromElectronProxyRules,
|
||||
pushStartupLog,
|
||||
resolveHostTriple,
|
||||
} from './sidecarManager'
|
||||
@ -75,6 +77,29 @@ describe('Electron sidecar manager', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('converts Electron system proxy rules into sidecar proxy env', () => {
|
||||
expect(proxyUrlFromElectronProxyRules('DIRECT')).toBeUndefined()
|
||||
expect(proxyUrlFromElectronProxyRules('SOCKS5 127.0.0.1:7891; DIRECT')).toBeUndefined()
|
||||
expect(proxyUrlFromElectronProxyRules('PROXY 127.0.0.1:7897; DIRECT')).toBe('http://127.0.0.1:7897')
|
||||
expect(proxyUrlFromElectronProxyRules('HTTPS proxy.example:8443; DIRECT')).toBe('https://proxy.example:8443')
|
||||
|
||||
const env = mergeProxyEnv({}, 'http://127.0.0.1:7897')
|
||||
expect(env.HTTP_PROXY).toBe('http://127.0.0.1:7897')
|
||||
expect(env.HTTPS_PROXY).toBe('http://127.0.0.1:7897')
|
||||
expect(env.http_proxy).toBe('http://127.0.0.1:7897')
|
||||
expect(env.https_proxy).toBe('http://127.0.0.1:7897')
|
||||
expect(env.NO_PROXY).toContain('127.0.0.1')
|
||||
})
|
||||
|
||||
it('does not override explicit sidecar proxy environment', () => {
|
||||
const env = mergeProxyEnv(
|
||||
{ HTTPS_PROXY: 'http://manual.example:8080' },
|
||||
'http://system.example:8080',
|
||||
)
|
||||
|
||||
expect(env).toEqual({ HTTPS_PROXY: 'http://manual.example:8080' })
|
||||
})
|
||||
|
||||
it('keeps startup logs bounded', () => {
|
||||
const logs: string[] = []
|
||||
for (let index = 0; index < 85; index++) {
|
||||
|
||||
@ -16,6 +16,13 @@ export type SidecarPlan = {
|
||||
env: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
const PROXY_ENV_KEYS = [
|
||||
'HTTP_PROXY',
|
||||
'HTTPS_PROXY',
|
||||
'http_proxy',
|
||||
'https_proxy',
|
||||
] as const
|
||||
|
||||
export function resolveHostTriple(platform = process.platform, arch = process.arch): string {
|
||||
if (platform === 'darwin' && arch === 'arm64') return 'aarch64-apple-darwin'
|
||||
if (platform === 'darwin' && arch === 'x64') return 'x86_64-apple-darwin'
|
||||
@ -96,6 +103,43 @@ export function formatStartupError(message: string, logs: string[]): string {
|
||||
return `${message}\n\nRecent server logs:\n${logText}`
|
||||
}
|
||||
|
||||
export function proxyUrlFromElectronProxyRules(rules: string | undefined): string | undefined {
|
||||
if (!rules) return undefined
|
||||
|
||||
for (const rawRule of rules.split(';')) {
|
||||
const rule = rawRule.trim()
|
||||
if (!rule || /^DIRECT$/i.test(rule)) continue
|
||||
|
||||
const match = rule.match(/^(PROXY|HTTPS)\s+(.+)$/i)
|
||||
if (!match) continue
|
||||
|
||||
const scheme = match[1]!.toUpperCase() === 'HTTPS' ? 'https' : 'http'
|
||||
const hostPort = match[2]!.trim()
|
||||
if (!hostPort) continue
|
||||
|
||||
return `${scheme}://${hostPort}`
|
||||
}
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
export function mergeProxyEnv(
|
||||
baseEnv: NodeJS.ProcessEnv,
|
||||
proxyUrl: string | undefined,
|
||||
): NodeJS.ProcessEnv {
|
||||
if (!proxyUrl) return baseEnv
|
||||
if (PROXY_ENV_KEYS.some(key => baseEnv[key])) return baseEnv
|
||||
|
||||
return {
|
||||
...baseEnv,
|
||||
HTTP_PROXY: proxyUrl,
|
||||
HTTPS_PROXY: proxyUrl,
|
||||
http_proxy: proxyUrl,
|
||||
https_proxy: proxyUrl,
|
||||
NO_PROXY: baseEnv.NO_PROXY || baseEnv.no_proxy || 'localhost,127.0.0.1,::1',
|
||||
}
|
||||
}
|
||||
|
||||
export function buildSidecarEnv(baseEnv: NodeJS.ProcessEnv, h5DistDir: string): NodeJS.ProcessEnv {
|
||||
const env: NodeJS.ProcessEnv = {
|
||||
...baseEnv,
|
||||
|
||||
@ -4,6 +4,7 @@ import { tmpdir } from 'node:os'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
captureWindowState,
|
||||
hideWindowSafely,
|
||||
hasMeaningfulIntersection,
|
||||
installWindowLifecycle,
|
||||
isPersistableWindowState,
|
||||
@ -11,6 +12,7 @@ import {
|
||||
readWindowState,
|
||||
restoreWindowMaximized,
|
||||
showMainWindow,
|
||||
toggleWindowFullScreen,
|
||||
windowOptionsFromState,
|
||||
windowStatePath,
|
||||
writeWindowState,
|
||||
@ -122,6 +124,8 @@ describe('Electron window service', () => {
|
||||
handlers.set(event, handler)
|
||||
}),
|
||||
hide: vi.fn(),
|
||||
isSimpleFullScreen: () => false,
|
||||
isFullScreen: () => false,
|
||||
isMinimized: () => false,
|
||||
isMaximized: () => false,
|
||||
getBounds: () => ({ x: 0, y: 0, width: 1280, height: 820 }),
|
||||
@ -141,6 +145,115 @@ describe('Electron window service', () => {
|
||||
}
|
||||
})
|
||||
|
||||
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 {
|
||||
|
||||
@ -116,6 +116,37 @@ export function saveWindowState(app: App, window: BrowserWindow) {
|
||||
if (state) writeWindowState(app, state)
|
||||
}
|
||||
|
||||
export function hideWindowSafely(window: BrowserWindow, afterHide?: () => void) {
|
||||
if (window.isSimpleFullScreen()) {
|
||||
window.setSimpleFullScreen(false)
|
||||
window.hide()
|
||||
afterHide?.()
|
||||
return
|
||||
}
|
||||
|
||||
if (!window.isFullScreen()) {
|
||||
window.hide()
|
||||
afterHide?.()
|
||||
return
|
||||
}
|
||||
|
||||
window.once('leave-full-screen', () => {
|
||||
if (!window.isDestroyed()) {
|
||||
window.hide()
|
||||
afterHide?.()
|
||||
}
|
||||
})
|
||||
window.setFullScreen(false)
|
||||
}
|
||||
|
||||
export function toggleWindowFullScreen(window: BrowserWindow, platform = process.platform) {
|
||||
if (platform === 'darwin') {
|
||||
window.setSimpleFullScreen(!window.isSimpleFullScreen())
|
||||
return
|
||||
}
|
||||
window.setFullScreen(!window.isFullScreen())
|
||||
}
|
||||
|
||||
export function showMainWindow(window: BrowserWindow | null) {
|
||||
if (!window) return
|
||||
if (!window.isVisible()) window.show()
|
||||
@ -136,7 +167,7 @@ export function installWindowLifecycle({
|
||||
saveWindowState(app, window)
|
||||
if (shouldQuit()) return
|
||||
event.preventDefault()
|
||||
window.hide()
|
||||
hideWindowSafely(window)
|
||||
})
|
||||
|
||||
window.on('move', () => saveWindowState(app, window))
|
||||
|
||||
@ -361,7 +361,8 @@ describe('Settings > General tab', () => {
|
||||
expect(screen.getByText('Enter an HTTP or HTTPS proxy URL.')).toBeInTheDocument()
|
||||
expect(saveButton).toBeDisabled()
|
||||
|
||||
fireEvent.change(proxyInput, { target: { value: ' http://127.0.0.1:7890 ' } })
|
||||
fireEvent.change(proxyInput, { target: { value: ' http://user:p%40ss@127.0.0.1:7890 ' } })
|
||||
expect(screen.getByText('HTTP and HTTPS proxy URLs are supported. For authenticated proxies, use http://user:password@127.0.0.1:7890; the URL is saved with network settings.')).toBeInTheDocument()
|
||||
const timeoutInput = screen.getByLabelText('AI request timeout')
|
||||
expect(timeoutInput).toHaveAttribute('type', 'number')
|
||||
expect(screen.queryByRole('slider', { name: 'AI request timeout' })).not.toBeInTheDocument()
|
||||
@ -376,7 +377,7 @@ describe('Settings > General tab', () => {
|
||||
aiRequestTimeoutMs: 180_000,
|
||||
proxy: {
|
||||
mode: 'manual',
|
||||
url: 'http://127.0.0.1:7890',
|
||||
url: 'http://user:p%40ss@127.0.0.1:7890',
|
||||
},
|
||||
})
|
||||
expect(useUIStore.getState().toasts[useUIStore.getState().toasts.length - 1]).toMatchObject({
|
||||
|
||||
@ -983,7 +983,7 @@ export const en = {
|
||||
'settings.general.networkProxyModeManual': 'Manual proxy',
|
||||
'settings.general.networkProxyModeManualDescription': 'Use the HTTP or HTTPS proxy URL entered below.',
|
||||
'settings.general.networkProxyUrl': 'Proxy URL',
|
||||
'settings.general.networkProxyUrlHint': 'HTTP and HTTPS proxy URLs are supported, for example http://127.0.0.1:7890.',
|
||||
'settings.general.networkProxyUrlHint': 'HTTP and HTTPS proxy URLs are supported. For authenticated proxies, use http://user:password@127.0.0.1:7890; the URL is saved with network settings.',
|
||||
'settings.general.networkProxyUrlInvalid': 'Enter an HTTP or HTTPS proxy URL.',
|
||||
'settings.general.networkProxyUrlRequired': 'Enter a proxy URL.',
|
||||
'settings.general.networkTimeout': 'AI request timeout',
|
||||
|
||||
@ -985,7 +985,7 @@ export const zh: Record<TranslationKey, string> = {
|
||||
'settings.general.networkProxyModeManual': '手动代理',
|
||||
'settings.general.networkProxyModeManualDescription': '使用下方填写的 HTTP 或 HTTPS 代理地址。',
|
||||
'settings.general.networkProxyUrl': '代理地址',
|
||||
'settings.general.networkProxyUrlHint': '支持 HTTP 和 HTTPS 代理,例如 http://127.0.0.1:7890。',
|
||||
'settings.general.networkProxyUrlHint': '支持 HTTP 和 HTTPS 代理。需要认证时可填写 http://user:password@127.0.0.1:7890;该 URL 会随网络设置保存。',
|
||||
'settings.general.networkProxyUrlInvalid': '请输入 HTTP 或 HTTPS 代理地址。',
|
||||
'settings.general.networkProxyUrlRequired': '请输入代理地址。',
|
||||
'settings.general.networkTimeout': 'AI 请求超时',
|
||||
|
||||
@ -12,6 +12,7 @@ import {
|
||||
getHahaOpenAIOAuthFilePath,
|
||||
type StoredOpenAIOAuthTokens,
|
||||
} from '../services/hahaOpenAIOAuthService.js'
|
||||
import { resetSettingsCache } from '../../utils/settings/settingsCache.js'
|
||||
|
||||
let tmpDir: string
|
||||
let originalConfigDir: string | undefined
|
||||
@ -75,6 +76,7 @@ async function setup() {
|
||||
)
|
||||
originalConfigDir = process.env.CLAUDE_CONFIG_DIR
|
||||
process.env.CLAUDE_CONFIG_DIR = tmpDir
|
||||
resetSettingsCache()
|
||||
callbackPort = await getFreePort()
|
||||
service = new HahaOpenAIOAuthService({ callbackPort })
|
||||
}
|
||||
@ -86,6 +88,7 @@ async function teardown() {
|
||||
} else {
|
||||
process.env.CLAUDE_CONFIG_DIR = originalConfigDir
|
||||
}
|
||||
resetSettingsCache()
|
||||
await fs.rm(tmpDir, { recursive: true, force: true })
|
||||
}
|
||||
|
||||
@ -250,6 +253,53 @@ describe('HahaOpenAIOAuthService — session management', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('callback listener uses saved manual network proxy for token exchange', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
await fs.writeFile(
|
||||
path.join(tmpDir, 'settings.json'),
|
||||
JSON.stringify({
|
||||
network: {
|
||||
aiRequestTimeoutMs: 45_000,
|
||||
proxy: {
|
||||
mode: 'manual',
|
||||
url: ' http://127.0.0.1:7890 ',
|
||||
},
|
||||
},
|
||||
}),
|
||||
'utf-8',
|
||||
)
|
||||
resetSettingsCache()
|
||||
|
||||
const session = await service.startSession({ serverPort: 54321 })
|
||||
let tokenRequestInit: RequestInit | undefined
|
||||
|
||||
globalThis.fetch = (async (_url, init) => {
|
||||
tokenRequestInit = init
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'openai-access-token',
|
||||
refresh_token: 'openai-refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
const res = await getLocalCallback(
|
||||
`/auth/callback?code=auth-code&state=${session.state}`,
|
||||
)
|
||||
|
||||
expect(res.status).toBe(200)
|
||||
expect((tokenRequestInit as { proxy?: string } | undefined)?.proxy).toBe(
|
||||
'http://127.0.0.1:7890',
|
||||
)
|
||||
expect(tokenRequestInit?.signal).toBeInstanceOf(AbortSignal)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test('callback listener renders an error page when token exchange fails', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
const session = await service.startSession({ serverPort: 54321 })
|
||||
|
||||
@ -98,4 +98,21 @@ describe('network settings', () => {
|
||||
https_proxy: 'http://127.0.0.1:7890',
|
||||
})
|
||||
})
|
||||
|
||||
it('preserves authenticated manual proxy URLs for provider requests', () => {
|
||||
const settings = normalizeNetworkSettings({
|
||||
network: {
|
||||
proxy: {
|
||||
mode: 'manual',
|
||||
url: ' https://user:p%40ss@proxy.example.com:8443 ',
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
expect(getManualNetworkProxyUrl(settings)).toBe('https://user:p%40ss@proxy.example.com:8443')
|
||||
expect(buildNetworkEnvironment(settings)).toMatchObject({
|
||||
HTTP_PROXY: 'https://user:p%40ss@proxy.example.com:8443',
|
||||
HTTPS_PROXY: 'https://user:p%40ss@proxy.example.com:8443',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@ -24,8 +24,13 @@ import {
|
||||
withRefreshedAccessToken,
|
||||
OPENAI_CODEX_REDIRECT_PATH,
|
||||
OPENAI_CODEX_OAUTH_PORT,
|
||||
type OpenAITokenFetchOptions,
|
||||
} from '../../services/openaiAuth/client.js'
|
||||
import type { OpenAIOAuthTokenResponse } from '../../services/openaiAuth/types.js'
|
||||
import {
|
||||
getManualNetworkProxyUrl,
|
||||
loadNetworkSettings,
|
||||
} from './networkSettings.js'
|
||||
|
||||
export type StoredOpenAIOAuthTokens = {
|
||||
accessToken: string
|
||||
@ -49,6 +54,7 @@ export type OpenAIOAuthSession = {
|
||||
|
||||
type OpenAIRefreshFn = (
|
||||
refreshToken: string,
|
||||
options?: OpenAITokenFetchOptions,
|
||||
) => Promise<OpenAIOAuthTokenResponse>
|
||||
|
||||
const SESSION_TTL_MS = 5 * 60 * 1000
|
||||
@ -289,6 +295,7 @@ export class HahaOpenAIOAuthService {
|
||||
code: authorizationCode,
|
||||
redirectUri: session.redirectUri,
|
||||
codeVerifier: session.codeVerifier,
|
||||
...(await this.getOpenAITokenFetchOptions()),
|
||||
})
|
||||
|
||||
const normalized = normalizeOpenAITokens(response)
|
||||
@ -316,7 +323,10 @@ export class HahaOpenAIOAuthService {
|
||||
if (!tokens.refreshToken) return null
|
||||
|
||||
try {
|
||||
const refreshed = await this.refreshFn(tokens.refreshToken)
|
||||
const refreshed = await this.refreshFn(
|
||||
tokens.refreshToken,
|
||||
await this.getOpenAITokenFetchOptions(),
|
||||
)
|
||||
const normalized = withRefreshedAccessToken(
|
||||
{
|
||||
accessToken: tokens.accessToken,
|
||||
@ -353,6 +363,14 @@ export class HahaOpenAIOAuthService {
|
||||
const tokens = await this.ensureFreshTokens()
|
||||
return tokens?.accessToken ?? null
|
||||
}
|
||||
|
||||
private async getOpenAITokenFetchOptions(): Promise<OpenAITokenFetchOptions> {
|
||||
const networkSettings = await loadNetworkSettings()
|
||||
return {
|
||||
proxyUrl: getManualNetworkProxyUrl(networkSettings),
|
||||
timeoutMs: networkSettings.aiRequestTimeoutMs,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const hahaOpenAIOAuthService = new HahaOpenAIOAuthService()
|
||||
|
||||
@ -89,6 +89,40 @@ describe('OpenAI Codex OAuth client', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('exchanges authorization code through configured proxy fetch options', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let tokenRequestInit: RequestInit | undefined
|
||||
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
tokenRequestInit = init
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'access-token',
|
||||
refresh_token: 'refresh-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
await exchangeOpenAICodeForTokens({
|
||||
code: 'auth-code',
|
||||
redirectUri: 'http://localhost:1455/auth/callback',
|
||||
codeVerifier: 'verifier',
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
|
||||
expect((tokenRequestInit as { proxy?: string } | undefined)?.proxy).toBe(
|
||||
'http://127.0.0.1:7890',
|
||||
)
|
||||
expect(tokenRequestInit?.signal).toBeInstanceOf(AbortSignal)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test('refreshes tokens with Codex-compatible token request headers', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let tokenRequestBody = ''
|
||||
@ -121,6 +155,36 @@ describe('OpenAI Codex OAuth client', () => {
|
||||
}
|
||||
})
|
||||
|
||||
test('refreshes tokens through configured proxy fetch options', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
let tokenRequestInit: RequestInit | undefined
|
||||
|
||||
globalThis.fetch = (async (_input, init) => {
|
||||
tokenRequestInit = init
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
access_token: 'access-token',
|
||||
expires_in: 3600,
|
||||
}),
|
||||
{ status: 200, headers: { 'Content-Type': 'application/json' } },
|
||||
)
|
||||
}) as typeof fetch
|
||||
|
||||
try {
|
||||
await refreshOpenAITokens('refresh-token', {
|
||||
proxyUrl: 'http://127.0.0.1:7890',
|
||||
timeoutMs: 30_000,
|
||||
})
|
||||
|
||||
expect((tokenRequestInit as { proxy?: string } | undefined)?.proxy).toBe(
|
||||
'http://127.0.0.1:7890',
|
||||
)
|
||||
expect(tokenRequestInit?.signal).toBeInstanceOf(AbortSignal)
|
||||
} finally {
|
||||
globalThis.fetch = originalFetch
|
||||
}
|
||||
})
|
||||
|
||||
test('includes sanitized token error response details for diagnostics', async () => {
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
|
||||
@ -5,6 +5,7 @@ import type {
|
||||
OpenAIOAuthTokenResponse,
|
||||
OpenAIOAuthTokens,
|
||||
} from './types.js'
|
||||
import { getProxyFetchOptions } from '../../utils/proxy.js'
|
||||
|
||||
export const OPENAI_AUTH_ISSUER = 'https://auth.openai.com'
|
||||
export const OPENAI_CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann'
|
||||
@ -23,6 +24,22 @@ const OPENAI_TOKEN_REQUEST_HEADERS = {
|
||||
'User-Agent': OPENAI_CODEX_TOKEN_USER_AGENT,
|
||||
} as const
|
||||
|
||||
export type OpenAITokenFetchOptions = {
|
||||
proxyUrl?: string | null
|
||||
timeoutMs?: number
|
||||
}
|
||||
|
||||
function buildOpenAITokenFetchInit(
|
||||
init: RequestInit,
|
||||
options: OpenAITokenFetchOptions = {},
|
||||
): RequestInit {
|
||||
return {
|
||||
...init,
|
||||
...(options.timeoutMs ? { signal: AbortSignal.timeout(options.timeoutMs) } : {}),
|
||||
...getProxyFetchOptions({ proxyUrl: options.proxyUrl }),
|
||||
}
|
||||
}
|
||||
|
||||
export function generateOpenAIState(): string {
|
||||
return randomBytes(32).toString('hex')
|
||||
}
|
||||
@ -55,18 +72,26 @@ export async function exchangeOpenAICodeForTokens(input: {
|
||||
code: string
|
||||
redirectUri: string
|
||||
codeVerifier: string
|
||||
proxyUrl?: string | null
|
||||
timeoutMs?: number
|
||||
}): Promise<OpenAIOAuthTokenResponse> {
|
||||
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: OPENAI_TOKEN_REQUEST_HEADERS,
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: input.code,
|
||||
redirect_uri: input.redirectUri,
|
||||
client_id: OPENAI_CODEX_CLIENT_ID,
|
||||
code_verifier: input.codeVerifier,
|
||||
}).toString(),
|
||||
})
|
||||
const response = await fetch(
|
||||
`${OPENAI_AUTH_ISSUER}/oauth/token`,
|
||||
buildOpenAITokenFetchInit(
|
||||
{
|
||||
method: 'POST',
|
||||
headers: OPENAI_TOKEN_REQUEST_HEADERS,
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: input.code,
|
||||
redirect_uri: input.redirectUri,
|
||||
client_id: OPENAI_CODEX_CLIENT_ID,
|
||||
code_verifier: input.codeVerifier,
|
||||
}).toString(),
|
||||
},
|
||||
input,
|
||||
),
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw await buildOpenAITokenHttpError('exchange', response)
|
||||
@ -77,17 +102,24 @@ export async function exchangeOpenAICodeForTokens(input: {
|
||||
|
||||
export async function refreshOpenAITokens(
|
||||
refreshToken: string,
|
||||
options: OpenAITokenFetchOptions = {},
|
||||
): Promise<OpenAIOAuthTokenResponse> {
|
||||
const response = await fetch(`${OPENAI_AUTH_ISSUER}/oauth/token`, {
|
||||
method: 'POST',
|
||||
headers: OPENAI_TOKEN_REQUEST_HEADERS,
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: OPENAI_CODEX_CLIENT_ID,
|
||||
scope: 'openid profile email',
|
||||
}).toString(),
|
||||
})
|
||||
const response = await fetch(
|
||||
`${OPENAI_AUTH_ISSUER}/oauth/token`,
|
||||
buildOpenAITokenFetchInit(
|
||||
{
|
||||
method: 'POST',
|
||||
headers: OPENAI_TOKEN_REQUEST_HEADERS,
|
||||
body: new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
refresh_token: refreshToken,
|
||||
client_id: OPENAI_CODEX_CLIENT_ID,
|
||||
scope: 'openid profile email',
|
||||
}).toString(),
|
||||
},
|
||||
options,
|
||||
),
|
||||
)
|
||||
|
||||
if (!response.ok) {
|
||||
throw await buildOpenAITokenHttpError('refresh', response)
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user