From 16f4137954d9d15bc251cc601cb5031d632c1e93 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: Tue, 2 Jun 2026 21:37:35 +0800 Subject: [PATCH] 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 --- desktop/electron/main.ts | 2 + desktop/electron/services/menu.test.ts | 112 +++++++++++++++++ desktop/electron/services/menu.ts | 33 ++++- desktop/electron/services/serverRuntime.ts | 36 +++++- .../electron/services/sidecarManager.test.ts | 25 ++++ desktop/electron/services/sidecarManager.ts | 44 +++++++ desktop/electron/services/windows.test.ts | 113 ++++++++++++++++++ desktop/electron/services/windows.ts | 33 ++++- .../src/__tests__/generalSettings.test.tsx | 5 +- desktop/src/i18n/locales/en.ts | 2 +- desktop/src/i18n/locales/zh.ts | 2 +- .../haha-openai-oauth-service.test.ts | 50 ++++++++ src/server/__tests__/network-settings.test.ts | 17 +++ src/server/services/hahaOpenAIOAuthService.ts | 20 +++- src/services/openaiAuth/client.test.ts | 64 ++++++++++ src/services/openaiAuth/client.ts | 74 ++++++++---- 16 files changed, 599 insertions(+), 33 deletions(-) diff --git a/desktop/electron/main.ts b/desktop/electron/main.ts index e2d5a0d8..15d072ea 100644 --- a/desktop/electron/main.ts +++ b/desktop/electron/main.ts @@ -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, diff --git a/desktop/electron/services/menu.test.ts b/desktop/electron/services/menu.test.ts index 5330181d..4b826237 100644 --- a/desktop/electron/services/menu.test.ts +++ b/desktop/electron/services/menu.test.ts @@ -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 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() + }) }) diff --git a/desktop/electron/services/menu.ts b/desktop/electron/services/menu.ts index 2f750c90..b65c5ab1 100644 --- a/desktop/electron/services/menu.ts +++ b/desktop/electron/services/menu.ts @@ -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)) } diff --git a/desktop/electron/services/serverRuntime.ts b/desktop/electron/services/serverRuntime.ts index e16134c3..5840d36f 100644 --- a/desktop/electron/services/serverRuntime.ts +++ b/desktop/electron/services/serverRuntime.ts @@ -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 } export class ElectronServerRuntime { private readonly desktopRoot: string private readonly appRoot: string private readonly h5DistDir: string + private readonly resolveSystemProxy?: (url: string) => Promise + private sidecarEnvPromise: Promise | 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 { @@ -55,7 +61,7 @@ export class ElectronServerRuntime { async restartAdaptersSidecars(): Promise { 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 { + 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 { + this.sidecarEnvPromise ??= this.resolveSidecarBaseEnvOnce() + return await this.sidecarEnvPromise + } + + private async resolveSidecarBaseEnvOnce(): Promise { + 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 + } + } } diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts index 98bb0fc4..c2a69c07 100644 --- a/desktop/electron/services/sidecarManager.test.ts +++ b/desktop/electron/services/sidecarManager.test.ts @@ -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++) { diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts index 4a5207c1..55ad51a5 100644 --- a/desktop/electron/services/sidecarManager.ts +++ b/desktop/electron/services/sidecarManager.ts @@ -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, diff --git a/desktop/electron/services/windows.test.ts b/desktop/electron/services/windows.test.ts index 8193a1d8..b2c1cadd 100644 --- a/desktop/electron/services/windows.test.ts +++ b/desktop/electron/services/windows.test.ts @@ -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 void>() + const onceHandlers = new Map 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 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 { diff --git a/desktop/electron/services/windows.ts b/desktop/electron/services/windows.ts index 879bbbe3..7d5cf667 100644 --- a/desktop/electron/services/windows.ts +++ b/desktop/electron/services/windows.ts @@ -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)) diff --git a/desktop/src/__tests__/generalSettings.test.tsx b/desktop/src/__tests__/generalSettings.test.tsx index 119e89c7..63a7ad30 100644 --- a/desktop/src/__tests__/generalSettings.test.tsx +++ b/desktop/src/__tests__/generalSettings.test.tsx @@ -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({ diff --git a/desktop/src/i18n/locales/en.ts b/desktop/src/i18n/locales/en.ts index cb8270ca..c5509b54 100644 --- a/desktop/src/i18n/locales/en.ts +++ b/desktop/src/i18n/locales/en.ts @@ -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', diff --git a/desktop/src/i18n/locales/zh.ts b/desktop/src/i18n/locales/zh.ts index 9f563821..299e831f 100644 --- a/desktop/src/i18n/locales/zh.ts +++ b/desktop/src/i18n/locales/zh.ts @@ -985,7 +985,7 @@ export const zh: Record = { '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 请求超时', diff --git a/src/server/__tests__/haha-openai-oauth-service.test.ts b/src/server/__tests__/haha-openai-oauth-service.test.ts index aac45c50..44bf512e 100644 --- a/src/server/__tests__/haha-openai-oauth-service.test.ts +++ b/src/server/__tests__/haha-openai-oauth-service.test.ts @@ -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 }) diff --git a/src/server/__tests__/network-settings.test.ts b/src/server/__tests__/network-settings.test.ts index 10abef34..69dad9e0 100644 --- a/src/server/__tests__/network-settings.test.ts +++ b/src/server/__tests__/network-settings.test.ts @@ -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', + }) + }) }) diff --git a/src/server/services/hahaOpenAIOAuthService.ts b/src/server/services/hahaOpenAIOAuthService.ts index 2b82d9e8..b812dcdf 100644 --- a/src/server/services/hahaOpenAIOAuthService.ts +++ b/src/server/services/hahaOpenAIOAuthService.ts @@ -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 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 { + const networkSettings = await loadNetworkSettings() + return { + proxyUrl: getManualNetworkProxyUrl(networkSettings), + timeoutMs: networkSettings.aiRequestTimeoutMs, + } + } } export const hahaOpenAIOAuthService = new HahaOpenAIOAuthService() diff --git a/src/services/openaiAuth/client.test.ts b/src/services/openaiAuth/client.test.ts index edbafff4..f7abe58d 100644 --- a/src/services/openaiAuth/client.test.ts +++ b/src/services/openaiAuth/client.test.ts @@ -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 diff --git a/src/services/openaiAuth/client.ts b/src/services/openaiAuth/client.ts index 11ef6e82..eb0765e5 100644 --- a/src/services/openaiAuth/client.ts +++ b/src/services/openaiAuth/client.ts @@ -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 { - 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 { - 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)