fix(win32): suppress Command Prompt window on IDE launches (#11907)
* 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. * fix(win32): suppress Command Prompt window on IDE launches Prevent lingering Command Prompt windows when launching JetBrains IDEs on Windows. Use `start "" /B cmd /d /c` so the nested shell exits with the batch script, but only for JetBrains shims—VS Code and Cursor keep the waiting form because `start` re-parses arguments and breaks remote paths with spaces. Prefer colocated `*64.exe` executables beside the resolved `.cmd` shim over PATH lookups to avoid stale installations. * fix(win32): extend IDE launcher console suppression to direct paths Support IDE paths stored directly in settings (e.g., idea.exe, webstorm.cmd). Detect console idea.exe stubs alongside batch shims for upgrade to GUI *64.exe. Fix start command title escaping: use empty string instead of '""' to prevent libuv re-quoting.
This commit is contained in:
parent
b04c695750
commit
786d7048a1
|
|
@ -0,0 +1,44 @@
|
|||
import { basename, win32 } from 'node:path'
|
||||
|
||||
export function stripMatchingQuotes(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
const quote = trimmed[0]
|
||||
if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export function hasMatchingOuterQuotes(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
const quote = trimmed[0]
|
||||
return (quote === '"' || quote === "'") && trimmed.endsWith(quote)
|
||||
}
|
||||
|
||||
function getLeadingShellCommandToken(command: string): string {
|
||||
const trimmed = command.trim()
|
||||
const quote = trimmed[0]
|
||||
if (quote === '"' || quote === "'") {
|
||||
const closingIndex = trimmed.indexOf(quote, 1)
|
||||
if (closingIndex > 0) {
|
||||
return trimmed.slice(1, closingIndex)
|
||||
}
|
||||
}
|
||||
return trimmed.split(/\s+/, 1)[0] ?? ''
|
||||
}
|
||||
|
||||
/** Lowercased launcher name without directory or Windows executable extension. */
|
||||
export function getLauncherBaseName(
|
||||
command: string,
|
||||
options: { shellCommand?: boolean } = {}
|
||||
): string {
|
||||
const normalized = options.shellCommand
|
||||
? getLeadingShellCommandToken(command)
|
||||
: stripMatchingQuotes(command)
|
||||
const name = normalized.includes('\\') ? win32.basename(normalized) : basename(normalized)
|
||||
return name.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase()
|
||||
}
|
||||
|
||||
export function isWindowsBatchLauncher(command: string): boolean {
|
||||
return /\.(?:cmd|bat)$/i.test(stripMatchingQuotes(command))
|
||||
}
|
||||
|
|
@ -32,6 +32,168 @@ describe('resolveExternalEditorLaunchSpec', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('prefers the JetBrains *64.exe colocated with the resolved idea.cmd on Windows', () => {
|
||||
const installBin = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin'
|
||||
resolveCliCommandMock.mockImplementation((command: string) =>
|
||||
command === 'idea' ? `${installBin}\\idea.cmd` : command
|
||||
)
|
||||
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', {
|
||||
platform: 'win32',
|
||||
fileExists: (candidate) => candidate === `${installBin}\\idea64.exe`
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: true,
|
||||
spawnCmd: `${installBin}\\idea64.exe`,
|
||||
spawnArgs: ['C:\\workspaces\\orca']
|
||||
})
|
||||
// Why: a bare PATH `idea64` may belong to a different, stale install.
|
||||
expect(resolveCliCommandMock).toHaveBeenCalledWith('idea', { platform: 'win32' })
|
||||
expect(resolveCliCommandMock).not.toHaveBeenCalledWith('idea64', expect.anything())
|
||||
})
|
||||
|
||||
it('prefers a colocated .exe over an idea64.cmd shim when the user names idea64', () => {
|
||||
const installBin = 'C:\\Program Files\\JetBrains\\GoLand\\bin'
|
||||
resolveCliCommandMock.mockImplementation((command: string) =>
|
||||
command === 'goland64' ? `${installBin}\\goland64.cmd` : command
|
||||
)
|
||||
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec('goland64', 'C:\\workspaces\\orca', {
|
||||
platform: 'win32',
|
||||
fileExists: (candidate) => candidate === `${installBin}\\goland64.exe`
|
||||
}).spawnCmd
|
||||
).toBe(`${installBin}\\goland64.exe`)
|
||||
})
|
||||
|
||||
it('keeps the Toolbox idea.cmd shim and detaches it when no GUI exe sits beside it', () => {
|
||||
const toolboxShim = 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd'
|
||||
resolveCliCommandMock.mockImplementation((command: string) =>
|
||||
command === 'idea' ? toolboxShim : command
|
||||
)
|
||||
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec('idea', 'C:\\workspaces\\orca', {
|
||||
platform: 'win32',
|
||||
fileExists: () => false
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: true,
|
||||
detachedGui: true,
|
||||
spawnCmd: toolboxShim,
|
||||
spawnArgs: ['C:\\workspaces\\orca']
|
||||
})
|
||||
})
|
||||
|
||||
it('detaches a directly configured JetBrains shim path when no GUI exe is beside it', () => {
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec('C:\\Tools\\WebStorm\\bin\\webstorm.bat', 'C:\\ws', {
|
||||
platform: 'win32',
|
||||
fileExists: () => false
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: true,
|
||||
detachedGui: true,
|
||||
spawnCmd: 'C:\\Tools\\WebStorm\\bin\\webstorm.bat',
|
||||
spawnArgs: ['C:\\ws']
|
||||
})
|
||||
})
|
||||
|
||||
it('upgrades a direct JetBrains .cmd path to the colocated *64.exe', () => {
|
||||
const installBin = 'C:\\Program Files\\JetBrains\\WebStorm\\bin'
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec(`${installBin}\\webstorm.cmd`, 'C:\\ws', {
|
||||
platform: 'win32',
|
||||
fileExists: (candidate) => candidate === `${installBin}\\webstorm64.exe`
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: true,
|
||||
spawnCmd: `${installBin}\\webstorm64.exe`,
|
||||
spawnArgs: ['C:\\ws']
|
||||
})
|
||||
})
|
||||
|
||||
it('upgrades a short-name console idea.exe stub to the colocated idea64.exe', () => {
|
||||
const installBin = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin'
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec(`${installBin}\\idea.exe`, 'C:\\ws', {
|
||||
platform: 'win32',
|
||||
fileExists: (candidate) => candidate === `${installBin}\\idea64.exe`
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: true,
|
||||
spawnCmd: `${installBin}\\idea64.exe`,
|
||||
spawnArgs: ['C:\\ws']
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves a direct idea64.exe path unchanged even when a sibling exists', () => {
|
||||
const guiExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe'
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec(guiExe, 'C:\\ws', {
|
||||
platform: 'win32',
|
||||
fileExists: () => true
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: true,
|
||||
spawnCmd: guiExe,
|
||||
spawnArgs: ['C:\\ws']
|
||||
})
|
||||
})
|
||||
|
||||
it('does not rewrite non-JetBrains .exe launchers via colocation', () => {
|
||||
const codeExe = 'C:\\Program Files\\Microsoft VS Code\\Code.exe'
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec(codeExe, 'C:\\ws', {
|
||||
platform: 'win32',
|
||||
fileExists: () => true
|
||||
}).spawnCmd
|
||||
).toBe(codeExe)
|
||||
})
|
||||
|
||||
it('keeps idea.exe when no colocated idea64.exe exists', () => {
|
||||
const consoleExe = 'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea.exe'
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec(consoleExe, 'C:\\ws', {
|
||||
platform: 'win32',
|
||||
fileExists: () => false
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: true,
|
||||
spawnCmd: consoleExe,
|
||||
spawnArgs: ['C:\\ws']
|
||||
})
|
||||
})
|
||||
|
||||
// Why: `start` re-parses argv, so only JetBrains shims may take that path.
|
||||
it.each([
|
||||
['code', 'C:\\Tools\\code.cmd', ['C:\\workspaces\\orca']],
|
||||
[
|
||||
'cursor',
|
||||
'C:\\Users\\me\\AppData\\Local\\Programs\\cursor\\bin\\cursor.cmd',
|
||||
['--new-window', 'C:\\workspaces\\orca']
|
||||
]
|
||||
])('does not detach the %s batch shim through start', (command, resolvedCommand, spawnArgs) => {
|
||||
resolveCliCommandMock.mockImplementation(() => resolvedCommand)
|
||||
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec(command, 'C:\\workspaces\\orca', { platform: 'win32' })
|
||||
).toEqual({
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: true,
|
||||
spawnCmd: resolvedCommand,
|
||||
spawnArgs
|
||||
})
|
||||
})
|
||||
|
||||
it('appends escaped paths to compound macOS open commands', () => {
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec('open -a "Typora"', "/tmp/note's.md", {
|
||||
|
|
@ -85,6 +247,19 @@ describe('resolveExternalEditorLaunchSpec', () => {
|
|||
})
|
||||
})
|
||||
|
||||
it('runs GUI compound Windows commands verbatim instead of re-parsing them under start', () => {
|
||||
expect(
|
||||
resolveExternalEditorLaunchSpec('code --reuse-window', 'C:\\workspaces\\orca', {
|
||||
platform: 'win32'
|
||||
})
|
||||
).toEqual({
|
||||
kind: 'shell',
|
||||
hideWindowsConsole: true,
|
||||
spawnCmd: getCmdExePath(),
|
||||
spawnArgs: ['/d', '/s', '/c', '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 +420,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(
|
||||
|
|
|
|||
|
|
@ -1,26 +1,46 @@
|
|||
import { spawn } from 'node:child_process'
|
||||
import { existsSync } from 'node:fs'
|
||||
import { basename, posix, win32 } from 'node:path'
|
||||
import { 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 {
|
||||
getLauncherBaseName,
|
||||
hasMatchingOuterQuotes,
|
||||
stripMatchingQuotes
|
||||
} from './editor-launcher-name'
|
||||
import {
|
||||
isJetBrainsConsoleShim,
|
||||
resolveColocatedJetBrainsGuiExecutable
|
||||
} from './jetbrains-windows-gui-launchers'
|
||||
import { getCmdExePath, getSpawnArgsForWindows } from './win32-utils'
|
||||
|
||||
export const EXTERNAL_EDITOR_CLI_COMMAND = 'code'
|
||||
const WINDOWS_CONSOLE_EDITORS = new Set(['nvim', 'vim'])
|
||||
|
||||
export type ExternalEditorExecutableLaunchSpec = {
|
||||
kind: 'executable'
|
||||
hideWindowsConsole: boolean
|
||||
/**
|
||||
* Set only for Windows batch shims that would otherwise leave a Command
|
||||
* Prompt behind; routes the spawn through `start` with an empty title and
|
||||
* `/B`. Left unset elsewhere because `start` re-parses quoted argv.
|
||||
*/
|
||||
detachedGui?: boolean
|
||||
spawnCmd: string
|
||||
spawnArgs: string[]
|
||||
}
|
||||
|
||||
export type ExternalEditorShellLaunchSpec = {
|
||||
kind: 'shell'
|
||||
hideWindowsConsole: boolean
|
||||
spawnCmd: string
|
||||
spawnArgs: string[]
|
||||
}
|
||||
|
||||
export type ExternalEditorLaunchSpec =
|
||||
| {
|
||||
kind: 'executable'
|
||||
hideWindowsConsole: boolean
|
||||
spawnCmd: string
|
||||
spawnArgs: string[]
|
||||
}
|
||||
| {
|
||||
kind: 'shell'
|
||||
hideWindowsConsole: boolean
|
||||
spawnCmd: string
|
||||
spawnArgs: string[]
|
||||
}
|
||||
| ExternalEditorExecutableLaunchSpec
|
||||
| ExternalEditorShellLaunchSpec
|
||||
|
||||
function escapePosixPathForShell(pathValue: string): string {
|
||||
if (/^[a-zA-Z0-9_./@:-]+$/.test(pathValue)) {
|
||||
|
|
@ -39,41 +59,6 @@ function escapePathForShell(pathValue: string, platform: NodeJS.Platform): strin
|
|||
: escapePosixPathForShell(pathValue)
|
||||
}
|
||||
|
||||
function getLauncherBaseName(command: string, options: { shellCommand?: boolean } = {}): string {
|
||||
const normalized = options.shellCommand
|
||||
? getLeadingShellCommandToken(command)
|
||||
: stripMatchingQuotes(command)
|
||||
const name = normalized.includes('\\') ? win32.basename(normalized) : basename(normalized)
|
||||
return name.replace(/\.(?:cmd|exe|bat)$/i, '').toLowerCase()
|
||||
}
|
||||
|
||||
function getLeadingShellCommandToken(command: string): string {
|
||||
const trimmed = command.trim()
|
||||
const quote = trimmed[0]
|
||||
if (quote === '"' || quote === "'") {
|
||||
const closingIndex = trimmed.indexOf(quote, 1)
|
||||
if (closingIndex > 0) {
|
||||
return trimmed.slice(1, closingIndex)
|
||||
}
|
||||
}
|
||||
return trimmed.split(/\s+/, 1)[0] ?? ''
|
||||
}
|
||||
|
||||
function stripMatchingQuotes(value: string): string {
|
||||
const trimmed = value.trim()
|
||||
const quote = trimmed[0]
|
||||
if ((quote === '"' || quote === "'") && trimmed.endsWith(quote)) {
|
||||
return trimmed.slice(1, -1)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
function hasMatchingOuterQuotes(value: string): boolean {
|
||||
const trimmed = value.trim()
|
||||
const quote = trimmed[0]
|
||||
return (quote === '"' || quote === "'") && trimmed.endsWith(quote)
|
||||
}
|
||||
|
||||
function isWindowsExecutablePath(command: string): boolean {
|
||||
return win32.isAbsolute(command) && /\.(?:cmd|exe|bat|com)$/i.test(command)
|
||||
}
|
||||
|
|
@ -133,6 +118,46 @@ function isCompoundShellCommand(command: string): boolean {
|
|||
return /\s/.test(command)
|
||||
}
|
||||
|
||||
function preferJetBrainsGuiExecutable(
|
||||
editorCommand: string,
|
||||
platform: NodeJS.Platform,
|
||||
fileExists: (path: string) => boolean
|
||||
): string {
|
||||
if (platform !== 'win32') {
|
||||
return editorCommand
|
||||
}
|
||||
return resolveColocatedJetBrainsGuiExecutable(editorCommand, fileExists) ?? editorCommand
|
||||
}
|
||||
|
||||
function resolveSimpleEditorCommand(
|
||||
command: string,
|
||||
platform: NodeJS.Platform,
|
||||
fileExists: (path: string) => boolean
|
||||
): string {
|
||||
return preferJetBrainsGuiExecutable(
|
||||
resolveCliCommand(command, { platform }),
|
||||
platform,
|
||||
fileExists
|
||||
)
|
||||
}
|
||||
|
||||
function buildExecutableLaunchSpec(
|
||||
editorCommand: string,
|
||||
pathValue: string,
|
||||
platform: NodeJS.Platform
|
||||
): ExternalEditorExecutableLaunchSpec {
|
||||
const spec: ExternalEditorExecutableLaunchSpec = {
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform),
|
||||
spawnCmd: editorCommand,
|
||||
spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform)
|
||||
}
|
||||
if (spec.hideWindowsConsole && isJetBrainsConsoleShim(editorCommand, platform)) {
|
||||
spec.detachedGui = true
|
||||
}
|
||||
return spec
|
||||
}
|
||||
|
||||
function buildShellLaunchSpec(
|
||||
command: string,
|
||||
pathValue: string,
|
||||
|
|
@ -140,6 +165,11 @@ function buildShellLaunchSpec(
|
|||
): ExternalEditorLaunchSpec {
|
||||
const shellCommand = `${command} ${escapePathForShell(pathValue, platform)}`
|
||||
if (platform === 'win32') {
|
||||
// Why: no `start` wrap here. `start` re-parses the line — it swallows the
|
||||
// first quoted operand as a window title and mangles remote paths with
|
||||
// spaces — and on a batch target it leaves a resident nested `cmd /K`.
|
||||
// User-authored compound commands run verbatim; users who want a detached
|
||||
// launch already write `start` themselves.
|
||||
return {
|
||||
kind: 'shell',
|
||||
hideWindowsConsole: !shouldShowWindowsConsole(command, platform, { shellCommand: true }),
|
||||
|
|
@ -165,26 +195,24 @@ export function resolveExternalEditorLaunchSpec(
|
|||
const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND
|
||||
|
||||
if (isDirectExecutablePath(trimmed, platform, fileExists)) {
|
||||
const editorCommand = stripMatchingQuotes(trimmed)
|
||||
return {
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform),
|
||||
spawnCmd: editorCommand,
|
||||
spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform)
|
||||
}
|
||||
// Why: settings often store a full path to idea.cmd / idea.exe; upgrade
|
||||
// those the same way as PATH-resolved names when *64.exe is colocated.
|
||||
return buildExecutableLaunchSpec(
|
||||
preferJetBrainsGuiExecutable(stripMatchingQuotes(trimmed), platform, fileExists),
|
||||
pathValue,
|
||||
platform
|
||||
)
|
||||
}
|
||||
|
||||
if (isCompoundShellCommand(trimmed)) {
|
||||
return buildShellLaunchSpec(trimmed, pathValue, platform)
|
||||
}
|
||||
|
||||
const editorCommand = resolveCliCommand(trimmed, { platform })
|
||||
return {
|
||||
kind: 'executable',
|
||||
hideWindowsConsole: !shouldShowWindowsConsole(editorCommand, platform),
|
||||
spawnCmd: editorCommand,
|
||||
spawnArgs: buildExecutableArgs(editorCommand, pathValue, platform)
|
||||
}
|
||||
return buildExecutableLaunchSpec(
|
||||
resolveSimpleEditorCommand(trimmed, platform, fileExists),
|
||||
pathValue,
|
||||
platform
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveVsCodeRemoteSshLaunchSpec(
|
||||
|
|
@ -217,3 +245,52 @@ export function resolveVsCodeRemoteSshLaunchSpec(
|
|||
spawnArgs: ['--remote', `ssh-remote+${authority}`, pathValue]
|
||||
}
|
||||
}
|
||||
|
||||
function resolveExternalEditorSpawn(launchSpec: ExternalEditorLaunchSpec): {
|
||||
spawnCmd: string
|
||||
spawnArgs: string[]
|
||||
windowsHide: boolean
|
||||
} {
|
||||
// Why: only shims flagged during resolution (JetBrains) take the start /B
|
||||
// detach; every other launcher keeps the waiting form so its argv survives.
|
||||
if (launchSpec.kind === 'executable') {
|
||||
const spawned = getSpawnArgsForWindows(launchSpec.spawnCmd, launchSpec.spawnArgs, {
|
||||
detachedGui: launchSpec.detachedGui === true
|
||||
})
|
||||
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)
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: false
|
||||
}
|
||||
)
|
||||
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: false
|
||||
}
|
||||
)
|
||||
expect(spawnMock).toHaveBeenCalledWith('editor-cli', [normalize(workspacePath)], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
|
|
@ -334,16 +342,25 @@ describe('registerShellHandlers', () => {
|
|||
})
|
||||
|
||||
it('uses a provided launcher command', async () => {
|
||||
resolveCliCommandMock.mockReturnValueOnce('custom-editor')
|
||||
const workspacePath = resolve('workspace')
|
||||
const handler = getHandler('shell:openInExternalEditor')
|
||||
|
||||
await expect(handler({}, { path: workspacePath, command: 'cursor' })).resolves.toEqual({
|
||||
ok: true
|
||||
await expect(handler({}, { path: workspacePath, command: 'custom-editor' })).resolves.toEqual(
|
||||
{
|
||||
ok: true
|
||||
}
|
||||
)
|
||||
expect(resolveCliCommandMock).toHaveBeenCalledWith('custom-editor', {
|
||||
platform: process.platform
|
||||
})
|
||||
expect(resolveCliCommandMock).toHaveBeenCalledWith('cursor', { platform: process.platform })
|
||||
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith('editor-cli', [
|
||||
normalize(workspacePath)
|
||||
])
|
||||
expect(getSpawnArgsForWindowsMock).toHaveBeenCalledWith(
|
||||
'custom-editor',
|
||||
[normalize(workspacePath)],
|
||||
{
|
||||
detachedGui: false
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it.runIf(process.platform === 'win32')(
|
||||
|
|
@ -357,11 +374,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: false
|
||||
}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
|
|
@ -377,9 +396,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',
|
||||
|
|
@ -392,6 +415,37 @@ describe('registerShellHandlers', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('detaches JetBrains batch shims on Windows but leaves other launchers waiting', async () => {
|
||||
const platformDescriptor = Object.getOwnPropertyDescriptor(process, 'platform')
|
||||
Object.defineProperty(process, 'platform', { configurable: true, value: 'win32' })
|
||||
const workspacePath = normalize(resolve('workspace'))
|
||||
const ideaShim = 'C:\\Users\\me\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\idea.cmd'
|
||||
const codeShim = 'C:\\Tools\\code.cmd'
|
||||
const handler = getHandler('shell:openInExternalEditor')
|
||||
|
||||
try {
|
||||
resolveCliCommandMock.mockReturnValueOnce(ideaShim)
|
||||
await expect(handler({}, { path: workspacePath, command: 'idea' })).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith(ideaShim, [workspacePath], {
|
||||
detachedGui: true
|
||||
})
|
||||
|
||||
resolveCliCommandMock.mockReturnValueOnce(codeShim)
|
||||
await expect(handler({}, { path: workspacePath, command: 'code' })).resolves.toEqual({
|
||||
ok: true
|
||||
})
|
||||
expect(getSpawnArgsForWindowsMock).toHaveBeenLastCalledWith(codeShim, [workspacePath], {
|
||||
detachedGui: false
|
||||
})
|
||||
} finally {
|
||||
if (platformDescriptor) {
|
||||
Object.defineProperty(process, 'platform', platformDescriptor)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
it('forces Cursor launcher folders into a new window', async () => {
|
||||
resolveCliCommandMock.mockReturnValueOnce('/usr/local/bin/cursor')
|
||||
const workspacePath = resolve('workspace')
|
||||
|
|
@ -400,18 +454,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: false
|
||||
}
|
||||
)
|
||||
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: false
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to VS Code when command is blank', async () => {
|
||||
|
|
@ -438,9 +498,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: false
|
||||
}
|
||||
)
|
||||
expect(spawnMock).toHaveBeenCalledWith('platform-runner', ['platform-arg'], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
|
|
@ -510,11 +574,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: false
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves Windows-form SSH paths and uses the manual port-22 authority', async () => {
|
||||
|
|
@ -535,11 +601,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: false
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('opens a manual port-22 target with a host-only authority when username is blank', async () => {
|
||||
|
|
@ -558,11 +626,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: false
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
it('rejects relative SSH paths before resolving or spawning a launcher', async () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,91 @@
|
|||
import { win32 } from 'node:path'
|
||||
import {
|
||||
getLauncherBaseName,
|
||||
isWindowsBatchLauncher,
|
||||
stripMatchingQuotes
|
||||
} from './editor-launcher-name'
|
||||
|
||||
// Why: JetBrains installers ship `*64.exe` GUI-subsystem binaries. The
|
||||
// Toolbox/installer `.cmd`/`.bat` shims (and short-name console `idea.exe`
|
||||
// stubs) chain through helpers that allocate a visible Command Prompt even
|
||||
// when the parent spawn uses windowsHide — the STA-3040 prompt-window loop.
|
||||
// Maps each short console launcher name to its GUI sibling.
|
||||
const JETBRAINS_WINDOWS_CONSOLE_TO_GUI: 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'
|
||||
}
|
||||
|
||||
const JETBRAINS_WINDOWS_GUI_NAMES = new Set(Object.values(JETBRAINS_WINDOWS_CONSOLE_TO_GUI))
|
||||
|
||||
/** GUI binary basename for a console or GUI launcher name, or null if unknown. */
|
||||
function getGuiExecutableName(launcherBaseName: string): string | null {
|
||||
if (JETBRAINS_WINDOWS_GUI_NAMES.has(launcherBaseName)) {
|
||||
return launcherBaseName
|
||||
}
|
||||
return JETBRAINS_WINDOWS_CONSOLE_TO_GUI[launcherBaseName] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The GUI `*64.exe` beside an already-resolved console entry point, or null.
|
||||
*
|
||||
* Why sibling-only: a bare PATH lookup for `idea64` can land in a different,
|
||||
* stale install, and it costs a second full PATH walk on Electron's main
|
||||
* thread. Toolbox script directories ship no exe, so those keep the shim and
|
||||
* rely on the detached-GUI spawn instead.
|
||||
*
|
||||
* Upgrades:
|
||||
* - `idea.cmd` / `idea.bat` / `idea64.cmd` → colocated `idea64.exe` when present
|
||||
* - short-name console stubs `idea.exe` → colocated `idea64.exe` when present
|
||||
* - leaves `idea64.exe` and non-JetBrains launchers unchanged
|
||||
*/
|
||||
export function resolveColocatedJetBrainsGuiExecutable(
|
||||
resolvedCommand: string,
|
||||
fileExists: (path: string) => boolean
|
||||
): string | null {
|
||||
const unquoted = stripMatchingQuotes(resolvedCommand)
|
||||
const baseName = getLauncherBaseName(unquoted)
|
||||
const isBatch = /\.(?:cmd|bat)$/i.test(unquoted)
|
||||
const isExe = /\.exe$/i.test(unquoted)
|
||||
if (!isBatch && !isExe) {
|
||||
return null
|
||||
}
|
||||
|
||||
// .exe: only short console names (idea.exe), never the GUI binary itself.
|
||||
if (isExe) {
|
||||
const guiName = JETBRAINS_WINDOWS_CONSOLE_TO_GUI[baseName]
|
||||
if (!guiName) {
|
||||
return null
|
||||
}
|
||||
const candidate = win32.join(win32.dirname(unquoted), `${guiName}.exe`)
|
||||
return fileExists(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
// .cmd/.bat: idea.cmd and idea64.cmd both prefer idea64.exe when colocated.
|
||||
const guiName = getGuiExecutableName(baseName)
|
||||
if (!guiName) {
|
||||
return null
|
||||
}
|
||||
const candidate = win32.join(win32.dirname(unquoted), `${guiName}.exe`)
|
||||
return fileExists(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
// Why: only JetBrains batch shims chain through console helpers that outlive
|
||||
// the batch and re-open a prompt. Other GUI shims (code.cmd, cursor.cmd) exit
|
||||
// on their own, and running them under `start` would re-parse their quoted
|
||||
// WSL/SSH remote argv. detachedGui only rewrites batch targets.
|
||||
export function isJetBrainsConsoleShim(command: string, platform: NodeJS.Platform): boolean {
|
||||
return (
|
||||
platform === 'win32' &&
|
||||
isWindowsBatchLauncher(command) &&
|
||||
getGuiExecutableName(getLauncherBaseName(command)) !== null
|
||||
)
|
||||
}
|
||||
|
|
@ -82,6 +82,55 @@ describe('getSpawnArgsForWindows', () => {
|
|||
}
|
||||
})
|
||||
|
||||
it('routes GUI Open In .cmd launches through start /B with an inner cmd /c', () => {
|
||||
withPlatform('win32', () => {
|
||||
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(
|
||||
'C:\\Tools\\idea.cmd',
|
||||
['C:\\workspaces\\orca'],
|
||||
{ detachedGui: true }
|
||||
)
|
||||
expect(spawnCmd).toBe(getCmdExePath())
|
||||
// Why: `start` runs a batch target under a nested `cmd /K` that never
|
||||
// exits; the inner `cmd /d /c` is what keeps the hidden shell from leaking.
|
||||
// Title is empty string so libuv emits `""` — not the two-char `'""'`.
|
||||
expect(spawnArgs).toEqual([
|
||||
'/d',
|
||||
'/c',
|
||||
'start',
|
||||
'',
|
||||
'/B',
|
||||
getCmdExePath(),
|
||||
'/d',
|
||||
'/c',
|
||||
'C:\\Tools\\idea.cmd',
|
||||
'C:\\workspaces\\orca'
|
||||
])
|
||||
expect(spawnArgs[3]).toBe('')
|
||||
expect(spawnArgs).not.toContain('/K')
|
||||
expect(spawnArgs).not.toContain('""')
|
||||
expect(spawnArgs[spawnArgs.indexOf('/B') + 1]).not.toMatch(/\.(?:cmd|bat)$/i)
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the waiting form for batch launches without detachedGui', () => {
|
||||
withPlatform('win32', () => {
|
||||
const { spawnArgs } = getSpawnArgsForWindows('C:\\Tools\\idea.cmd', ['C:\\workspaces\\orca'])
|
||||
expect(spawnArgs).toEqual(['/d', '/c', 'C:\\Tools\\idea.cmd', 'C:\\workspaces\\orca'])
|
||||
})
|
||||
})
|
||||
|
||||
it('leaves .exe GUI launches alone even when detachedGui is requested', () => {
|
||||
withPlatform('win32', () => {
|
||||
const { spawnCmd, spawnArgs } = getSpawnArgsForWindows(
|
||||
'C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe',
|
||||
['C:\\workspaces\\orca'],
|
||||
{ detachedGui: true }
|
||||
)
|
||||
expect(spawnCmd).toBe('C:\\Program Files\\JetBrains\\IntelliJ IDEA\\bin\\idea64.exe')
|
||||
expect(spawnArgs).toEqual(['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', [
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -39,9 +39,23 @@ 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.
|
||||
*
|
||||
* Opt-in only: `start` re-parses the command line, so callers whose argv can
|
||||
* carry quoted operands (VS Code `--remote` authorities and remote paths with
|
||||
* spaces) must leave this off.
|
||||
*/
|
||||
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 +65,24 @@ export function getSpawnArgsForWindows(
|
|||
}
|
||||
|
||||
// Why: separate argv entries let Node quote spaces without breaking cmd.
|
||||
if (options.detachedGui) {
|
||||
// Why: `start` launches a batch target through a nested `cmd /K`, which
|
||||
// stays resident after the script ends — `/B` only suppresses a *new*
|
||||
// console, so the shim leaks a hidden cmd.exe. Handing `start` an inner
|
||||
// `cmd /d /c` makes that interpreter exit with the script.
|
||||
//
|
||||
// Window title must be an *empty argv entry* (`''`). libuv's Windows
|
||||
// quoter turns empty into `""` on the CreateProcess command line — the
|
||||
// empty title `start` requires so a later quoted path is not eaten as
|
||||
// the title. The two-character string `'""'` is wrong: libuv re-escapes
|
||||
// it to `"\"\""`. (Default ComSpec has no spaces, so the bad form often
|
||||
// still "works"; quoted Program Files paths are where it breaks.)
|
||||
const cmdExePath = getCmdExePath()
|
||||
return {
|
||||
spawnCmd: cmdExePath,
|
||||
spawnArgs: ['/d', '/c', 'start', '', '/B', cmdExePath, '/d', '/c', command, ...args]
|
||||
}
|
||||
}
|
||||
return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] }
|
||||
}
|
||||
return { spawnCmd: command, spawnArgs: args }
|
||||
|
|
|
|||
Loading…
Reference in New Issue