From be4984009ce41adde5acd2b3e7e2c67aa7243cfb 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: Fri, 12 Jun 2026 10:16:46 +0800 Subject: [PATCH] fix(desktop): hide Windows subprocess consoles (#802) Ensure Electron sidecar launch and Windows taskkill calls hide console windows, and pass the same hidden-window spawn option through desktop CLI and scheduled-task subprocess launches. Tested: cd desktop && bun test ./electron/services/sidecarManager.test.ts Tested: bun test src/server/__tests__/conversation-service.test.ts src/server/__tests__/cron-scheduler-launcher.test.ts Tested: bun run check:native Tested: bun run check:server Not-tested: Windows GUI quit smoke Confidence: medium Scope-risk: narrow --- .../electron/services/sidecarManager.test.ts | 24 +++++++++++++++++-- desktop/electron/services/sidecarManager.ts | 18 ++++++++++---- .../__tests__/conversation-service.test.ts | 14 +++++++++++ .../__tests__/cron-scheduler-launcher.test.ts | 14 +++++++++++ src/server/services/conversationService.ts | 22 +++++++++++------ src/server/services/cronScheduler.ts | 22 +++++++++++------ 6 files changed, 93 insertions(+), 21 deletions(-) diff --git a/desktop/electron/services/sidecarManager.test.ts b/desktop/electron/services/sidecarManager.test.ts index 42b1449b..fbb12d3f 100644 --- a/desktop/electron/services/sidecarManager.test.ts +++ b/desktop/electron/services/sidecarManager.test.ts @@ -12,6 +12,7 @@ import { proxyUrlFromElectronProxyRules, pushStartupLog, resolveHostTriple, + spawnSidecar, windowsPowerShellOverride, type SidecarChild, } from './sidecarManager' @@ -145,7 +146,7 @@ describe('Electron sidecar manager', () => { const spawnAsync = vi.fn() const spawnSyncFn = vi.fn() killSidecar(child, false, { platform: 'win32', spawnAsync: spawnAsync as never, spawnSyncFn: spawnSyncFn as never }) - expect(spawnAsync).toHaveBeenCalledWith('taskkill', ['/F', '/T', '/PID', '777'], { stdio: 'ignore' }) + expect(spawnAsync).toHaveBeenCalledWith('taskkill', ['/F', '/T', '/PID', '777'], { stdio: 'ignore', windowsHide: true }) expect(spawnSyncFn).not.toHaveBeenCalled() expect(child.kill).not.toHaveBeenCalled() }) @@ -155,10 +156,29 @@ describe('Electron sidecar manager', () => { const spawnAsync = vi.fn() const spawnSyncFn = vi.fn() killSidecar(child, true, { platform: 'win32', spawnAsync: spawnAsync as never, spawnSyncFn: spawnSyncFn as never }) - expect(spawnSyncFn).toHaveBeenCalledWith('taskkill', ['/F', '/T', '/PID', '777'], { stdio: 'ignore' }) + expect(spawnSyncFn).toHaveBeenCalledWith('taskkill', ['/F', '/T', '/PID', '777'], { stdio: 'ignore', windowsHide: true }) expect(spawnAsync).not.toHaveBeenCalled() }) + it('hides Windows console windows when launching sidecars', () => { + const spawned = {} as SidecarChild + const spawnFn = vi.fn(() => spawned) + const existsSyncFn = vi.fn(() => true) + const plan = { + command: '/app/desktop/src-tauri/binaries/claude-sidecar-x86_64-pc-windows-msvc.exe', + args: ['server', '--port', '49321'], + env: { CLAUDE_H5_AUTO_PUBLIC_URL: '1' }, + } + + expect(spawnSidecar(plan, { existsSyncFn, spawnFn: spawnFn as never })).toBe(spawned) + expect(existsSyncFn).toHaveBeenCalledWith(plan.command) + expect(spawnFn).toHaveBeenCalledWith(plan.command, plan.args, { + env: plan.env, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }) + }) + it('forwards a PowerShell shell choice to the sidecar only on Windows', () => { expect(windowsPowerShellOverride('pwsh.exe', 'win32')).toBe('pwsh.exe') expect(windowsPowerShellOverride('powershell.exe', 'win32')).toBe('powershell.exe') diff --git a/desktop/electron/services/sidecarManager.ts b/desktop/electron/services/sidecarManager.ts index cefade33..5f90c1da 100644 --- a/desktop/electron/services/sidecarManager.ts +++ b/desktop/electron/services/sidecarManager.ts @@ -16,6 +16,11 @@ export type SidecarPlan = { env: NodeJS.ProcessEnv } +export type SpawnSidecarDeps = { + existsSyncFn?: typeof existsSync + spawnFn?: typeof spawn +} + const PROXY_ENV_KEYS = [ 'HTTP_PROXY', 'HTTPS_PROXY', @@ -225,13 +230,15 @@ export function createAdapterPlan({ } } -export function spawnSidecar(plan: SidecarPlan): SidecarChild { - if (!existsSync(plan.command)) { +export function spawnSidecar(plan: SidecarPlan, deps: SpawnSidecarDeps = {}): SidecarChild { + const exists = deps.existsSyncFn ?? existsSync + if (!exists(plan.command)) { throw new Error(`Electron sidecar binary not found: ${plan.command}. Run "cd desktop && bun run build:sidecars" first.`) } - return spawn(plan.command, plan.args, { + return (deps.spawnFn ?? spawn)(plan.command, plan.args, { env: plan.env, stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, }) } @@ -251,8 +258,9 @@ export function killSidecar(child: SidecarChild, sync = false, deps: KillSidecar const platform = deps.platform ?? process.platform if (platform === 'win32' && child.pid) { const args = ['/F', '/T', '/PID', String(child.pid)] - if (sync) (deps.spawnSyncFn ?? spawnSync)('taskkill', args, { stdio: 'ignore' }) - else (deps.spawnAsync ?? spawn)('taskkill', args, { stdio: 'ignore' }) + const options = { stdio: 'ignore', windowsHide: true } as const + if (sync) (deps.spawnSyncFn ?? spawnSync)('taskkill', args, options) + else (deps.spawnAsync ?? spawn)('taskkill', args, options) return } child.kill() diff --git a/src/server/__tests__/conversation-service.test.ts b/src/server/__tests__/conversation-service.test.ts index 1a73f287..67ce5f7d 100644 --- a/src/server/__tests__/conversation-service.test.ts +++ b/src/server/__tests__/conversation-service.test.ts @@ -3,6 +3,7 @@ import * as fs from 'node:fs/promises' import * as os from 'node:os' import * as path from 'node:path' import { + buildConversationCliSpawnOptions, ConversationService, DESKTOP_CLI_GRACEFUL_SHUTDOWN_TIMEOUT_MS, } from '../services/conversationService.js' @@ -181,6 +182,19 @@ describe('ConversationService', () => { await expect(fs.stat(path.dirname(env.CLAUDE_CODE_DIAGNOSTICS_FILE))).resolves.toBeTruthy() }) + test('builds hidden CLI spawn options for desktop session subprocesses', () => { + const env = { CLAUDECODE: '1' } + + expect(buildConversationCliSpawnOptions('/workspace/project', env)).toEqual({ + cwd: '/workspace/project', + env, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + windowsHide: true, + }) + }) + test('buildChildEnv pins desktop memory to the current sanitized project directory', async () => { const service = new ConversationService() as any const workDir = path.join(tmpDir, 'workspace', 'myself_code', 'claude-code-haha') diff --git a/src/server/__tests__/cron-scheduler-launcher.test.ts b/src/server/__tests__/cron-scheduler-launcher.test.ts index 364b0495..27a607a3 100644 --- a/src/server/__tests__/cron-scheduler-launcher.test.ts +++ b/src/server/__tests__/cron-scheduler-launcher.test.ts @@ -5,6 +5,7 @@ import * as path from 'path' import { buildCronCliArgs, + buildCronTaskSpawnOptions, CronScheduler, resolveCronProjectRoot, } from '../services/cronScheduler.js' @@ -146,6 +147,19 @@ describe('cron scheduler launcher resolution', () => { ]) }) + it('builds hidden CLI spawn options for scheduled task subprocesses', () => { + const env = { CLAUDECODE: '1' } + + expect(buildCronTaskSpawnOptions('/workspace/project', env)).toEqual({ + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + cwd: '/workspace/project', + env, + windowsHide: true, + }) + }) + it('prefers an explicit CC_HAHA_ROOT when it points at a source checkout', async () => { const sourceRoot = path.join(tmpDir, 'source') await createSourceRoot(sourceRoot) diff --git a/src/server/services/conversationService.ts b/src/server/services/conversationService.ts index 6cbcea5b..8792309d 100644 --- a/src/server/services/conversationService.ts +++ b/src/server/services/conversationService.ts @@ -62,6 +62,20 @@ export function cliExitSeverity(code: number | null): 'info' | 'error' { return 'error' } +export function buildConversationCliSpawnOptions( + cwd: string, + env: NodeJS.ProcessEnv, +) { + return { + cwd, + env, + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + windowsHide: true, + } as const +} + type AttachmentRef = { type: 'file' | 'image' name?: string @@ -285,13 +299,7 @@ export class ConversationService { let proc: ReturnType try { - proc = Bun.spawn(args, { - cwd: launchWorkDir, - env: childEnv, - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - }) + proc = Bun.spawn(args, buildConversationCliSpawnOptions(launchWorkDir, childEnv)) } catch (spawnErr) { void diagnosticsService.recordEvent({ type: 'cli_spawn_failed', diff --git a/src/server/services/cronScheduler.ts b/src/server/services/cronScheduler.ts index 36724869..919cb781 100644 --- a/src/server/services/cronScheduler.ts +++ b/src/server/services/cronScheduler.ts @@ -41,6 +41,20 @@ export type TaskRun = { sessionId?: string // links to a session for rich output rendering } +export function buildCronTaskSpawnOptions( + cwd: string, + env: NodeJS.ProcessEnv, +) { + return { + stdin: 'pipe', + stdout: 'pipe', + stderr: 'pipe', + cwd, + env, + windowsHide: true, + } as const +} + // ─── Output extraction ──────────────────────────────────────────────────────── /** @@ -518,13 +532,7 @@ export class CronScheduler { const childEnv = await this.buildTaskChildEnv(workDir, task) const proc = Bun.spawn( cliArgs, - { - stdin: 'pipe', - stdout: 'pipe', - stderr: 'pipe', - cwd: workDir, - env: childEnv, - }, + buildCronTaskSpawnOptions(workDir, childEnv), ) this.runningTasks.set(task.id, { proc, startedAt: Date.now(), runId })