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
This commit is contained in:
程序员阿江(Relakkes) 2026-06-12 10:16:46 +08:00
parent dd72901ba3
commit be4984009c
6 changed files with 93 additions and 21 deletions

View File

@ -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')

View File

@ -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()

View File

@ -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')

View File

@ -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)

View File

@ -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<typeof Bun.spawn>
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',

View File

@ -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 })