fix(win32): suppress Command Prompt window on IDE launches

- Prefer JetBrains GUI executables (`*64.exe`) over `.cmd` shims to avoid
  console allocation (STA-3040).
- Use `start "" /B` when launching GUI apps via batch scripts; shims chain
  through console helpers that allocate a visible prompt even with
  `windowsHide`. `start /B` returns immediately, preventing the lingering window.
This commit is contained in:
Jinjing
2026-08-01 01:49:04 -07:00
parent c8a22ad0a6
commit 4ff8527920
7 changed files with 273 additions and 98 deletions
+59 -2
View File
@@ -32,6 +32,50 @@ describe('resolveExternalEditorLaunchSpec', () => {
})
})
it('prefers JetBrains *64.exe GUI launchers over idea.cmd on Windows', () => {
resolveCliCommandMock.mockImplementation((command: string) => {
if (command === 'idea64') {
return 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe'
}
if (command === 'idea') {
return 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd'
}
return command
})
expect(
resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', { platform: 'win32' })
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe',
spawnArgs: ['C:\\workspaces\\orca']
})
expect(resolveCliCommandMock).toHaveBeenCalledWith('idea64', { platform: 'win32' })
expect(resolveCliCommandMock).not.toHaveBeenCalledWith('idea', expect.anything())
})
it('falls back to the idea.cmd shim when idea64 is not installed', () => {
resolveCliCommandMock.mockImplementation((command: string) => {
if (command === 'idea64') {
return 'idea64'
}
if (command === 'idea') {
return 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd'
}
return command
})
expect(
resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', { platform: 'win32' })
).toEqual({
kind: 'executable',
hideWindowsConsole: true,
spawnCmd: 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd',
spawnArgs: ['C:\\workspaces\\orca']
})
})
it('appends escaped paths to compound macOS open commands', () => {
expect(
resolveExternalEditorLaunchSpec('open -a "Typora"', "/tmp/note's.md", {
@@ -85,6 +129,19 @@ describe('resolveExternalEditorLaunchSpec', () => {
})
})
it('wraps non-start GUI compound Windows commands with start /B', () => {
expect(
resolveExternalEditorLaunchSpec('code --reuse-window', 'C:\\workspaces\\orca', {
platform: 'win32'
})
).toEqual({
kind: 'shell',
hideWindowsConsole: true,
spawnCmd: getCmdExePath(),
spawnArgs: ['/d', '/s', '/c', 'start "" /B code --reuse-window C:\\workspaces\\orca']
})
})
it('quotes Windows paths with spaces in compound commands', () => {
expect(
resolveExternalEditorLaunchSpec('start "" notepad', 'C:\\my notes.md', { platform: 'win32' })
@@ -245,7 +302,7 @@ describe('resolveExternalEditorLaunchSpec', () => {
).toEqual([pathValue])
})
it('does not rewrite compound VS Code commands', () => {
it('does not rewrite compound VS Code commands into WSL remote args', () => {
const pathValue = '\\\\wsl.localhost\\Ubuntu\\home\\ada\\project'
expect(
@@ -254,7 +311,7 @@ describe('resolveExternalEditorLaunchSpec', () => {
kind: 'shell',
hideWindowsConsole: true,
spawnCmd: getCmdExePath(),
spawnArgs: ['/d', '/s', '/c', `code --reuse-window ${pathValue}`]
spawnArgs: ['/d', '/s', '/c', `start "" /B code --reuse-window ${pathValue}`]
})
})
})
+94 -4
View File
@@ -1,13 +1,32 @@
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { basename, posix, win32 } from 'node:path'
import { parseWslUncPath } from '../shared/wsl-paths'
import { isVsCodeLauncherExecutable } from '../shared/vscode-remote-ssh-launcher'
import { resolveCliCommand } from './codex-cli/command'
import { getCmdExePath } from './win32-utils'
import { getCmdExePath, getSpawnArgsForWindows } from './win32-utils'
export const EXTERNAL_EDITOR_CLI_COMMAND = 'code'
const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim'])
// Why: JetBrains installers ship `*64.exe` GUI-subsystem binaries. The
// Toolbox/installer `.cmd`/`.bat` shims often chain through console helpers
// (`java.exe`), which allocate a visible Command Prompt even when the parent
// spawn uses windowsHide — the STA-3040 "prompt window" loop on Windows.
const JETBRAINS_WINDOWS_GUI_LAUNCHERS: Readonly<Record<string, string>> = {
idea: 'idea64',
webstorm: 'webstorm64',
pycharm: 'pycharm64',
phpstorm: 'phpstorm64',
goland: 'goland64',
rider: 'rider64',
clion: 'clion64',
rubymine: 'rubymine64',
datagrip: 'datagrip64',
rustrover: 'rustrover64',
studio: 'studio64'
}
export type ExternalEditorLaunchSpec =
| {
kind: 'executable'
@@ -133,6 +152,20 @@ function isCompoundShellCommand(command: string): boolean {
return /\s/.test(command)
}
function resolveSimpleEditorCommand(command: string, platform: NodeJS.Platform): string {
if (platform === 'win32') {
const jetbrainsGui = JETBRAINS_WINDOWS_GUI_LAUNCHERS[command.toLowerCase()]
if (jetbrainsGui) {
const resolvedGui = resolveCliCommand(jetbrainsGui, { platform })
// Why: resolveCliCommand returns the input name when nothing is found.
if (resolvedGui !== jetbrainsGui) {
return resolvedGui
}
}
}
return resolveCliCommand(command, { platform })
}
function buildShellLaunchSpec(
command: string,
pathValue: string,
@@ -140,11 +173,19 @@ function buildShellLaunchSpec(
): ExternalEditorLaunchSpec {
const shellCommand = `${command} ${escapePathForShell(pathValue, platform)}`
if (platform === 'win32') {
const hideWindowsConsole = !shouldShowWindowsConsole(command, platform, {
shellCommand: true
})
// Why: GUI compound commands (not terminal editors) use start /B so a
// Command Prompt does not linger; skip when the user already used `start`.
const alreadyUsesStart = /^\s*start(\.exe)?\b/i.test(command)
const wrappedCommand =
hideWindowsConsole && !alreadyUsesStart ? `start "" /B ${shellCommand}` : shellCommand
return {
kind: 'shell',
hideWindowsConsole: !shouldShowWindowsConsole(command, platform, { shellCommand: true }),
hideWindowsConsole,
spawnCmd: getCmdExePath(),
spawnArgs: ['/d', '/s', '/c', shellCommand]
spawnArgs: ['/d', '/s', '/c', wrappedCommand]
}
}
return {
@@ -178,7 +219,7 @@ export function resolveExternalEditorLaunchSpec(
return buildShellLaunchSpec(trimmed, pathValue, platform)
}
const editorCommand = resolveCliCommand(trimmed, { platform })
const editorCommand = resolveSimpleEditorCommand(trimmed, platform)
return {
kind: 'executable',
hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform),
@@ -217,3 +258,52 @@ export function resolveVsCodeRemoteSshLaunchSpec(
spawnArgs: ['--remote', `ssh-remote+${authority}`, pathValue]
}
}
function resolveExternalEditorSpawn(launchSpec: ExternalEditorLaunchSpec): {
spawnCmd: string
spawnArgs: string[]
windowsHide: boolean
} {
// Why: GUI Open In .cmd shims (idea.cmd) use start /B so no Command Prompt
// lingers; terminal editors keep the waiting form (nvim needs a console).
if (launchSpec.kind === 'executable') {
const spawned = getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs, {
detachedGui: launchSpec.hideWindowsConsole
})
return { ...spawned, windowsHide: launchSpec.hideWindowsConsole }
}
return {
spawnCmd: launchSpec.spawnCmd,
spawnArgs: launchSpec.spawnArgs,
windowsHide: launchSpec.hideWindowsConsole
}
}
export async function launchExternalEditor(launchSpec: ExternalEditorLaunchSpec): Promise<void> {
const { spawnCmd, spawnArgs, windowsHide } = resolveExternalEditorSpawn(launchSpec)
await new Promise<void>((resolvePromise, rejectPromise) => {
const child = spawn(spawnCmd, spawnArgs, { detached: true, stdio: 'ignore', windowsHide })
let settled = false
function cleanup(): void {
child.off('error', onError)
child.off('spawn', onSpawn)
}
function settle(callback: () => void): void {
if (settled) {
return
}
settled = true
cleanup()
callback()
}
function onError(error: Error): void {
settle(() => rejectPromise(error))
}
function onSpawn(): void {
child.unref()
settle(resolvePromise)
}
child.once('error', onError)
child.once('spawn', onSpawn)
})
}
+77 -43
View File
@@ -297,9 +297,13 @@ describe('registerShellHandlers', () => {
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'editor-cli',
[normalize(workspacePath)],
{
detachedGui: true
}
)
expect(spawnMock).toHaveBeenCalledWith('editor-cli', [normalize(workspacePath)], {
detached: true,
stdio: 'ignore',
@@ -320,9 +324,13 @@ describe('registerShellHandlers', () => {
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'editor-cli',
[normalize(workspacePath)],
{
detachedGui: true
}
)
expect(spawnMock).toHaveBeenCalledWith('editor-cli', [normalize(workspacePath)], {
detached: true,
stdio: 'ignore',
@@ -341,9 +349,13 @@ describe('registerShellHandlers', () => {
ok: true
})
expect(resolveCliCommandMock).toHaveBeenCalledWith('cursor', { platform: process.platform })
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'editor-cli',
[normalize(workspacePath)],
{
detachedGui: true
}
)
})
it.runIf(process.platform === 'win32')(
@@ -357,11 +369,13 @@ describe('registerShellHandlers', () => {
await expect(handler({}, { path: workspacePath, command: 'code' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(codeShim, [
'--remote',
'wsl+Ubuntu Preview',
'/home/Ada Lovelace/project'
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
codeShim,
['--remote', 'wsl+Ubuntu Preview', '/home/Ada Lovelace/project'],
{
detachedGui: true
}
)
}
)
@@ -377,9 +391,13 @@ describe('registerShellHandlers', () => {
ok: true
})
expect(resolveCliCommandMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(nvimPath, [
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
nvimPath,
[normalize(workspacePath)],
{
detachedGui: false
}
)
expect(spawnMock).toHaveBeenCalledWith(nvimPath, [normalize(workspacePath)], {
detached: true,
stdio: 'ignore',
@@ -400,18 +418,24 @@ describe('registerShellHandlers', () => {
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/cursor', [
'--new-window',
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'/usr/local/bin/cursor',
['--new-window', normalize(workspacePath)],
{
detachedGui: true
}
)
resolveCliCommandMock.mockReturnValueOnce('C:\\Cursor\\cursor.cmd')
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
ok: true
})
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith('C:\\Cursor\\cursor.cmd', [
'--new-window',
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith(
'C:\\Cursor\\cursor.cmd',
['--new-window', normalize(workspacePath)],
{
detachedGui: true
}
)
})
it('falls back to VS Code when command is blank', async () => {
@@ -438,9 +462,13 @@ describe('registerShellHandlers', () => {
expect(resolveCliCommandMock).toHaveBeenCalledWith(EXTERNAL_EDITOR_CLI_COMMAND, {
platform: process.platform
})
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
normalize(workspacePath)
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'editor-cli',
[normalize(workspacePath)],
{
detachedGui: true
}
)
expect(spawnMock).toHaveBeenCalledWith('platform-runner', ['platform-arg'], {
detached: true,
stdio: 'ignore',
@@ -510,11 +538,13 @@ describe('registerShellHandlers', () => {
handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: true })
expect(statMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/code', [
'--remote',
'ssh-remote+builder',
remotePath
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'/usr/local/bin/code',
['--remote', 'ssh-remote+builder', remotePath],
{
detachedGui: true
}
)
})
it('preserves Windows-form SSH paths and uses the manual port-22 authority', async () => {
@@ -535,11 +565,13 @@ describe('registerShellHandlers', () => {
handler({}, { path: remotePath, command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: true })
expect(statMock).not.toHaveBeenCalled()
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('C:\\Tools\\code.cmd', [
'--remote',
'ssh-remote+Ada@win-builder.example.com',
remotePath
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'C:\\Tools\\code.cmd',
['--remote', 'ssh-remote+Ada@win-builder.example.com', remotePath],
{
detachedGui: true
}
)
})
it('opens a manual port-22 target with a host-only authority when username is blank', async () => {
@@ -558,11 +590,13 @@ describe('registerShellHandlers', () => {
await expect(
handler({}, { path: '/srv/project', command: 'code', connectionId: 'ssh-1' })
).resolves.toEqual({ ok: true })
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('/usr/local/bin/code', [
'--remote',
'ssh-remote+builder.example.com',
'/srv/project'
])
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
'/usr/local/bin/code',
['--remote', 'ssh-remote+builder.example.com', '/srv/project'],
{
detachedGui: true
}
)
})
it('rejects relative SSH paths before resolving or spawning a launcher', async () => {
+2 -47
View File
@@ -1,5 +1,4 @@
import { ipcMain, shell, dialog } from 'electron'
import { spawn } from 'node:child_process'
import { constants, copyFile, readFile, stat } from 'node:fs/promises'
import { basename, extname, isAbsolute, normalize, posix, win32 } from 'node:path'
import { fileURLToPath } from 'node:url'
@@ -10,12 +9,11 @@ import type {
} from '../../shared/shell-open-types'
import { MAX_REPO_ICON_UPLOAD_BYTES } from '../../shared/repo-icon'
import type { Store } from '../persistence'
import { getSpawnArgsForWindows } from '../win32-utils'
import {
EXTERNAL_EDITOR_CLI_COMMAND,
launchExternalEditor,
resolveExternalEditorLaunchSpec,
resolveVsCodeRemoteSshLaunchSpec,
type ExternalEditorLaunchSpec
resolveVsCodeRemoteSshLaunchSpec
} from '../external-editor-launch'
import { resolveVsCodeSshAuthority } from '../ssh/vscode-ssh-authority'
@@ -72,49 +70,6 @@ async function openInFileManager(
}
}
async function launchExternalEditor(launchSpec: ExternalEditorLaunchSpec): Promise<void> {
const { spawnCmd, spawnArgs } =
launchSpec.kind === 'executable'
? getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs)
: { spawnCmd: launchSpec.spawnCmd, spawnArgs: launchSpec.spawnArgs }
await new Promise<void>((resolvePromise, rejectPromise) => {
const child = spawn(spawnCmd, spawnArgs, {
detached: true,
stdio: 'ignore',
// Why: terminal editors such as nvim need a visible console on Windows;
// GUI editor launches stay hidden to avoid command-shim flashes.
windowsHide: launchSpec.hideWindowsConsole
})
let settled = false
function cleanup(): void {
child.off('error', onError)
child.off('spawn', onSpawn)
}
function settle(callback: () => void): void {
if (settled) {
return
}
settled = true
cleanup()
callback()
}
function onError(error: Error): void {
settle(() => rejectPromise(error))
}
function onSpawn(): void {
child.unref()
settle(resolvePromise)
}
child.once('error', onError)
child.once('spawn', onSpawn)
})
}
async function openInExternalEditor(
store: Store,
request: ShellOpenExternalEditorRequest
+20
View File
@@ -82,6 +82,26 @@ describe('getSpawnArgsForWindows', () => {
}
})
it('routes GUI Open In .cmd launches through start /B so no prompt window lingers', () => {
withPlatform('win32', () => {
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(
'C:\\Tools\\idea.cmd',
['C:\\workspaces\\orca'],
{ detachedGui: true }
)
expect(spawnCmd).toBe(getCmdExePath())
expect(spawnArgs).toEqual([
'/d',
'/c',
'start',
'""',
'/B',
'C:\\Tools\\idea.cmd',
'C:\\workspaces\\orca'
])
})
})
it('preserves VS Code WSL remote arguments with spaces through .cmd launchers', () => {
withPlatform('win32', () => {
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows('C:\\tools\\code.cmd', [
+2 -1
View File
@@ -8,7 +8,8 @@ export {
isWindowsBatchScript,
WINDOWS_BATCH_UNSAFE_ARGUMENTS_ERROR,
WINDOWS_BATCH_UNSAFE_CHARACTERS_LABEL,
UnsafeWindowsBatchArgumentsError
UnsafeWindowsBatchArgumentsError,
type GetSpawnArgsForWindowsOptions
} from '../shared/windows-batch-spawn'
function execFileWithoutBlocking(
+19 -1
View File
@@ -39,9 +39,19 @@ function hasUnsafeWindowsBatchSyntax(value: string): boolean {
return UNSAFE_WINDOWS_BATCH_SYNTAX.test(value)
}
export type GetSpawnArgsForWindowsOptions = {
/**
* GUI launchers (Open In apps) should not leave a lingering Command Prompt.
* `start "" /B` returns immediately and keeps console-subsystem children of
* `.cmd`/`.bat` shims from allocating a fresh visible prompt window.
*/
detachedGui?: boolean
}
export function getSpawnArgsForWindows(
command: string,
args: string[]
args: string[],
options: GetSpawnArgsForWindowsOptions = {}
): { spawnCmd: string; spawnArgs: string[] } {
if (isWindowsBatchScript(command)) {
for (const value of [command, ...args]) {
@@ -51,6 +61,14 @@ export function getSpawnArgsForWindows(
}
// Why: separate argv entries let Node quote spaces without breaking cmd.
if (options.detachedGui) {
// Why: empty title (`""`) is required so `start` does not treat the first
// quoted path as a window title; `/B` avoids a new console window.
return {
spawnCmd: getCmdExePath(),
spawnArgs: ['/d', '/c', 'start', '""', '/B', command, ...args]
}
}
return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] }
}
return { spawnCmd: command, spawnArgs: args }