mirror of
https://github.com/stablyai/orca.git
synced 2026-10-20 08:03:28 +00:00
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:
@@ -88,7 +88,7 @@ describe('resolveExternalEditorLaunchSpec', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('detaches a directly configured JetBrains shim path', () => {
|
||||
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',
|
||||
@@ -103,6 +103,76 @@ describe('resolveExternalEditorLaunchSpec', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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']],
|
||||
|
||||
@@ -23,8 +23,8 @@ export type ExternalEditorExecutableLaunchSpec = {
|
||||
hideWindowsConsole: boolean
|
||||
/**
|
||||
* Set only for Windows batch shims that would otherwise leave a Command
|
||||
* Prompt behind; routes the spawn through `start "" /B`. Left unset
|
||||
* elsewhere because `start` re-parses quoted argv.
|
||||
* 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
|
||||
@@ -118,16 +118,27 @@ 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 {
|
||||
const resolved = resolveCliCommand(command, { platform })
|
||||
if (platform !== 'win32') {
|
||||
return resolved
|
||||
}
|
||||
return resolveColocatedJetBrainsGuiExecutable(resolved, fileExists) ?? resolved
|
||||
return preferJetBrainsGuiExecutable(
|
||||
resolveCliCommand(command, { platform }),
|
||||
platform,
|
||||
fileExists
|
||||
)
|
||||
}
|
||||
|
||||
function buildExecutableLaunchSpec(
|
||||
@@ -184,7 +195,13 @@ export function resolveExternalEditorLaunchSpec(
|
||||
const trimmed = command?.trim() || EXTERNAL_EDITOR_CLI_COMMAND
|
||||
|
||||
if (isDirectExecutablePath(trimmed, platform, fileExists)) {
|
||||
return buildExecutableLaunchSpec(stripMatchingQuotes(trimmed), 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)) {
|
||||
|
||||
@@ -342,15 +342,20 @@ 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',
|
||||
'custom-editor',
|
||||
[normalize(workspacePath)],
|
||||
{
|
||||
detachedGui: false
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
import { win32 } from 'node:path'
|
||||
import { getLauncherBaseName, isWindowsBatchLauncher } from './editor-launcher-name'
|
||||
import {
|
||||
getLauncherBaseName,
|
||||
isWindowsBatchLauncher,
|
||||
stripMatchingQuotes
|
||||
} from './editor-launcher-name'
|
||||
|
||||
// Why: JetBrains installers ship `*64.exe` GUI-subsystem binaries. The
|
||||
// Toolbox/installer `.cmd`/`.bat` shims chain through console helpers
|
||||
// (`java.exe`) that allocate a visible Command Prompt even when the parent
|
||||
// spawn uses windowsHide — the STA-3040 "prompt window" loop on Windows.
|
||||
// Maps each console launcher name to its GUI sibling.
|
||||
const JETBRAINS_WINDOWS_GUI_LAUNCHERS: Readonly<Record<string, string>> = {
|
||||
// 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',
|
||||
@@ -20,42 +24,64 @@ const JETBRAINS_WINDOWS_GUI_LAUNCHERS: Readonly<Record<string, string>> = {
|
||||
studio: 'studio64'
|
||||
}
|
||||
|
||||
const JETBRAINS_WINDOWS_GUI_EXECUTABLES = new Set(Object.values(JETBRAINS_WINDOWS_GUI_LAUNCHERS))
|
||||
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_EXECUTABLES.has(launcherBaseName)) {
|
||||
if (JETBRAINS_WINDOWS_GUI_NAMES.has(launcherBaseName)) {
|
||||
return launcherBaseName
|
||||
}
|
||||
return JETBRAINS_WINDOWS_GUI_LAUNCHERS[launcherBaseName] ?? null
|
||||
return JETBRAINS_WINDOWS_CONSOLE_TO_GUI[launcherBaseName] ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* The GUI `*64.exe` beside an already-resolved console shim, or 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 {
|
||||
if (!isWindowsBatchLauncher(resolvedCommand)) {
|
||||
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
|
||||
}
|
||||
const guiName = getGuiExecutableName(getLauncherBaseName(resolvedCommand))
|
||||
|
||||
// .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(resolvedCommand), `${guiName}.exe`)
|
||||
const candidate = win32.join(win32.dirname(unquoted), `${guiName}.exe`)
|
||||
return fileExists(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
// Why: only JetBrains 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.
|
||||
// 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' &&
|
||||
|
||||
@@ -92,11 +92,12 @@ describe('getSpawnArgsForWindows', () => {
|
||||
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',
|
||||
@@ -104,7 +105,9 @@ describe('getSpawnArgsForWindows', () => {
|
||||
'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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -69,12 +69,18 @@ export function getSpawnArgsForWindows(
|
||||
// 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. The empty
|
||||
// title (`""`) keeps `start` from eating a quoted path as a window title.
|
||||
// `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]
|
||||
spawnArgs: ['/d', '/c', 'start', '', '/B', cmdExePath, '/d', '/c', command, ...args]
|
||||
}
|
||||
}
|
||||
return { spawnCmd: getCmdExePath(), spawnArgs: ['/d', '/c', command, ...args] }
|
||||
|
||||
Reference in New Issue
Block a user