From 9fbfd906d04b825f2a243297bac8468b531a9a50 Mon Sep 17 00:00:00 2001 From: Relakkes Yang Date: Sat, 16 May 2026 21:17:39 +0800 Subject: [PATCH] fix: open Windows external targets reliably --- .../__tests__/open-target-service.test.ts | 67 ++++++ src/server/services/openTargetService.ts | 208 ++++++++++-------- 2 files changed, 189 insertions(+), 86 deletions(-) diff --git a/src/server/__tests__/open-target-service.test.ts b/src/server/__tests__/open-target-service.test.ts index 9fbc80b0..afd66d8b 100644 --- a/src/server/__tests__/open-target-service.test.ts +++ b/src/server/__tests__/open-target-service.test.ts @@ -121,6 +121,38 @@ describe('openTargetService', () => { expect(result.targets[0]?.iconUrl).toBe('/api/open-targets/icons/explorer') }) + it('does not include stale Windows command shims without the app executable', async () => { + const { service } = createService('win32', { + commandPaths: { + 'code.cmd': 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\bin\\code.cmd', + 'goland.cmd': 'C:\\Users\\nanmi\\AppData\\Local\\JetBrains\\Toolbox\\scripts\\goland.cmd', + }, + }) + + const result = await service.listTargets() + + expect(result.targets.map((target) => target.id)).toEqual(['explorer']) + expect(result.primaryTargetId).toBe('explorer') + }) + + it('detects Windows IDEs only when a real executable is resolved', async () => { + const commandPath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\bin\\code.cmd' + const executablePath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe' + const { service } = createService('win32', { + commandPaths: { + 'code.cmd': commandPath, + }, + paths: { + [executablePath]: true, + }, + }) + + const result = await service.listTargets() + + expect(result.targets.map((target) => target.id)).toEqual(['vscode', 'explorer']) + expect(result.primaryTargetId).toBe('vscode') + }) + it('only includes the Linux file-manager fallback when xdg-open is available', async () => { const withoutXdg = createService('linux') expect((await withoutXdg.service.listTargets()).targets).toEqual([]) @@ -213,6 +245,41 @@ describe('openTargetService', () => { } }) + it('opens Windows command-shim targets through the resolved executable', async () => { + const dir = await makeDir() + const commandPath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\bin\\code.cmd' + const executablePath = 'C:\\Users\\nanmi\\AppData\\Local\\Programs\\Microsoft VS Code\\Code.exe' + const { service, launched } = createService('win32', { + commandPaths: { + 'code.cmd': commandPath, + }, + paths: { + [executablePath]: true, + }, + }) + + try { + await service.openTarget({ targetId: 'vscode', path: dir }) + + expect(launched).toEqual([{ command: executablePath, args: [dir] }]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + + it('opens Windows Explorer through the file-manager fallback', async () => { + const dir = await makeDir() + const { service, launched } = createService('win32') + + try { + await service.openTarget({ targetId: 'explorer', path: dir }) + + expect(launched).toEqual([{ command: 'cmd.exe', args: ['/d', '/c', 'start', '', dir] }]) + } finally { + await rm(dir, { recursive: true, force: true }) + } + }) + it('reports launch failures instead of returning success', async () => { const dir = await makeDir() const { service } = createService('darwin', { diff --git a/src/server/services/openTargetService.ts b/src/server/services/openTargetService.ts index dc6029b3..ba9d0744 100644 --- a/src/server/services/openTargetService.ts +++ b/src/server/services/openTargetService.ts @@ -1,7 +1,7 @@ -import { execFile as execFileCallback } from 'node:child_process' +import { execFile as execFileCallback, spawn } from 'node:child_process' import { mkdtemp, readFile, readdir, rm, stat } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' -import { basename, extname, join, resolve, win32 as winPath } from 'node:path' +import { extname, join, posix as posixPath, resolve, win32 as winPath } from 'node:path' import { promisify } from 'node:util' import { ApiError } from '../middleware/errorHandler.js' @@ -88,7 +88,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [ appPaths: { darwin: [ '/Applications/Visual Studio Code.app', - join(homedir(), 'Applications', 'Visual Studio Code.app'), + posixPath.join(homedir(), 'Applications', 'Visual Studio Code.app'), ], }, }, @@ -105,7 +105,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [ }, windowsExecutableNames: ['Cursor.exe'], appPaths: { - darwin: ['/Applications/Cursor.app', join(homedir(), 'Applications', 'Cursor.app')], + darwin: ['/Applications/Cursor.app', posixPath.join(homedir(), 'Applications', 'Cursor.app')], }, }, { @@ -121,7 +121,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [ }, windowsExecutableNames: ['sublime_text.exe', 'subl.exe'], appPaths: { - darwin: ['/Applications/Sublime Text.app', join(homedir(), 'Applications', 'Sublime Text.app')], + darwin: ['/Applications/Sublime Text.app', posixPath.join(homedir(), 'Applications', 'Sublime Text.app')], }, }, { @@ -134,7 +134,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [ darwin: ['antigravity'], }, appPaths: { - darwin: ['/Applications/Antigravity.app', join(homedir(), 'Applications', 'Antigravity.app')], + darwin: ['/Applications/Antigravity.app', posixPath.join(homedir(), 'Applications', 'Antigravity.app')], }, }, { @@ -150,7 +150,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [ }, windowsExecutableNames: ['goland64.exe', 'goland.exe'], appPaths: { - darwin: ['/Applications/GoLand.app', join(homedir(), 'Applications', 'GoLand.app')], + darwin: ['/Applications/GoLand.app', posixPath.join(homedir(), 'Applications', 'GoLand.app')], }, }, { @@ -166,7 +166,7 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [ }, windowsExecutableNames: ['pycharm64.exe', 'pycharm.exe'], appPaths: { - darwin: ['/Applications/PyCharm.app', join(homedir(), 'Applications', 'PyCharm.app')], + darwin: ['/Applications/PyCharm.app', posixPath.join(homedir(), 'Applications', 'PyCharm.app')], }, }, { @@ -201,13 +201,13 @@ const TARGET_DEFINITIONS: TargetDefinition[] = [ const LINUX_APPLICATION_DIRS = [ '/usr/share/applications', '/usr/local/share/applications', - join(homedir(), '.local', 'share', 'applications'), + posixPath.join(homedir(), '.local', 'share', 'applications'), '/var/lib/flatpak/exports/share/applications', - join(homedir(), '.local', 'share', 'flatpak', 'exports', 'share', 'applications'), + posixPath.join(homedir(), '.local', 'share', 'flatpak', 'exports', 'share', 'applications'), ] const LINUX_ICON_ROOTS = [ - join(homedir(), '.local', 'share', 'icons'), + posixPath.join(homedir(), '.local', 'share', 'icons'), '/usr/local/share/icons', '/usr/share/icons', ] @@ -261,29 +261,98 @@ async function defaultPathExists(targetPath: string): Promise { } async function defaultLaunch(command: string, args: string[]): Promise { - try { - const { stdout, stderr } = await execFile(command, args, { - timeout: 10_000, - windowsHide: true, - }) - return { - code: 0, - stdout: String(stdout ?? ''), - stderr: String(stderr ?? ''), + return await new Promise((resolveLaunch) => { + let settled = false + const settle = (result: OpenTargetLaunchResult) => { + if (settled) return + settled = true + resolveLaunch(result) } - } catch (error) { - const err = error as { - code?: unknown - stdout?: unknown - stderr?: unknown - message?: string - } - return { - code: typeof err.code === 'number' ? err.code : 1, - stdout: String(err.stdout ?? ''), - stderr: String(err.stderr ?? err.message ?? ''), + + try { + const child = spawn(command, args, { + detached: true, + stdio: 'ignore', + windowsHide: true, + }) + + child.once('error', (error) => { + settle({ + code: 1, + stdout: '', + stderr: error.message, + }) + }) + + child.once('spawn', () => { + child.unref() + settle({ + code: 0, + stdout: '', + stderr: '', + }) + }) + } catch (error) { + const err = error as { message?: string } + settle({ + code: 1, + stdout: '', + stderr: String(err.message ?? error), + }) } + }) +} + +async function resolveWindowsApplicationPath( + definition: TargetDefinition, + runtime: Runtime, +): Promise { + for (const appPath of definition.appPaths?.win32 ?? []) { + if (await runtime.pathExists(appPath)) return appPath } + + for (const command of definition.commands?.win32 ?? []) { + const commandPath = await runtime.resolveCommand(command) + if (!commandPath) continue + + const executablePath = await resolveWindowsExecutablePath(commandPath, definition, runtime) + if (executablePath) return executablePath + } + + return null +} + +async function resolveWindowsExecutablePath( + commandPath: string, + definition: TargetDefinition, + runtime: Runtime, +): Promise { + const extension = winPath.extname(commandPath).toLowerCase() + if (extension === '.exe' && await runtime.pathExists(commandPath)) { + return commandPath + } + + if (extension !== '.cmd' && extension !== '.bat') { + return null + } + + const executableNames = definition.windowsExecutableNames + ?? definition.commands?.win32?.filter((command) => winPath.extname(command).toLowerCase() === '.exe') + ?? [] + + let currentDir = winPath.dirname(commandPath) + for (let depth = 0; depth < 5; depth += 1) { + for (const executableName of executableNames) { + const candidate = winPath.join(currentDir, executableName) + if (await runtime.pathExists(candidate)) return candidate + } + + const nextDir = winPath.dirname(currentDir) + if (!nextDir || nextDir === currentDir) break + currentDir = nextDir + } + + return null } async function defaultReadDirNames(targetPath: string): Promise { @@ -466,6 +535,10 @@ async function isDetected(definition: TargetDefinition, runtime: Runtime): Promi return false } + if (runtime.platform === 'win32') { + return (await resolveWindowsApplicationPath(definition, runtime)) !== null + } + for (const appPath of definition.appPaths?.[runtime.platform] ?? []) { if (await runtime.pathExists(appPath)) { return true @@ -495,7 +568,7 @@ async function resolveLaunchPlan( case 'darwin': return { command: 'open', args: [targetPath] } case 'win32': - return { command: 'explorer.exe', args: [targetPath] } + return { command: 'cmd.exe', args: ['/d', '/c', 'start', '', targetPath] } case 'linux': return { command: 'xdg-open', args: [targetPath] } default: @@ -511,6 +584,11 @@ async function resolveLaunchPlan( } } + if (runtime.platform === 'win32') { + const applicationPath = await resolveWindowsApplicationPath(definition, runtime) + return applicationPath ? { command: applicationPath, args: [targetPath] } : null + } + for (const command of definition.commands?.[runtime.platform] ?? []) { if (await runtime.commandExists(command)) { return { command, args: [targetPath] } @@ -555,8 +633,8 @@ async function findDarwinBundleIconPath( definition: TargetDefinition, runtime: Runtime, ): Promise { - const resourcesPath = join(appPath, 'Contents', 'Resources') - const plistPath = join(appPath, 'Contents', 'Info.plist') + const resourcesPath = posixPath.join(appPath, 'Contents', 'Resources') + const plistPath = posixPath.join(appPath, 'Contents', 'Info.plist') const plistIcon = await runtime.readPlistValue(plistPath, 'CFBundleIconFile') const candidates = [ @@ -566,7 +644,7 @@ async function findDarwinBundleIconPath( ].filter((value): value is string => Boolean(value)) for (const fileName of candidates) { - const iconPath = join(resourcesPath, fileName) + const iconPath = posixPath.join(resourcesPath, fileName) if (await runtime.pathExists(iconPath)) return iconPath } @@ -576,7 +654,7 @@ async function findDarwinBundleIconPath( .find((fileName) => !/document/i.test(fileName)) ?? null if (!firstIcon) return null - const fallbackPath = join(resourcesPath, firstIcon) + const fallbackPath = posixPath.join(resourcesPath, firstIcon) return await runtime.pathExists(fallbackPath) ? fallbackPath : null } @@ -610,50 +688,8 @@ async function resolveWindowsIconPath( if (await runtime.pathExists(iconPath)) return iconPath } - for (const appPath of definition.appPaths?.win32 ?? []) { - if (await runtime.pathExists(appPath)) return appPath - } - - for (const command of definition.commands?.win32 ?? []) { - const commandPath = await runtime.resolveCommand(command) - if (!commandPath) continue - - const directIconPath = await resolveWindowsExecutableIconPath(commandPath, definition, runtime) - if (directIconPath) return directIconPath - } - - return null -} - -async function resolveWindowsExecutableIconPath( - commandPath: string, - definition: TargetDefinition, - runtime: Runtime, -): Promise { - const extension = winPath.extname(commandPath).toLowerCase() - if ((extension === '.exe' || extension === '.ico') && await runtime.pathExists(commandPath)) { - return commandPath - } - - if (extension !== '.cmd' && extension !== '.bat') { - return null - } - - const executableNames = definition.windowsExecutableNames - ?? definition.commands?.win32?.filter((command) => winPath.extname(command).toLowerCase() === '.exe') - ?? [] - - let currentDir = winPath.dirname(commandPath) - for (let depth = 0; depth < 5; depth += 1) { - for (const executableName of executableNames) { - const candidate = winPath.join(currentDir, executableName) - if (await runtime.pathExists(candidate)) return candidate - } - - const nextDir = winPath.dirname(currentDir) - if (!nextDir || nextDir === currentDir) break - currentDir = nextDir - } + const applicationPath = await resolveWindowsApplicationPath(definition, runtime) + if (applicationPath) return applicationPath return null } @@ -702,7 +738,7 @@ async function findLinuxDesktopEntries( for (const fileName of fileNames) { if (!fileName.endsWith('.desktop')) continue - const filePath = join(directory, fileName) + const filePath = posixPath.join(directory, fileName) const text = await runtime.readTextFile(filePath) if (!text) continue @@ -757,7 +793,7 @@ function linuxDesktopEntryMatchesDefinition( definition: TargetDefinition, ): boolean { const commandNames = new Set( - (definition.commands?.linux ?? []).map((command) => basename(command).toLowerCase()), + (definition.commands?.linux ?? []).map((command) => posixPath.basename(command).toLowerCase()), ) const normalizedNeedles = [ definition.id, @@ -790,7 +826,7 @@ function extractLinuxDesktopExecCommand(execValue: string): string | null { for (const token of tokens) { if (!token || token.includes('=')) continue - const command = basename(token).toLowerCase() + const command = posixPath.basename(token).toLowerCase() if (command === 'env') continue return command } @@ -816,11 +852,11 @@ async function resolveLinuxIconName( const directRoots = [ '/usr/share/pixmaps', '/usr/local/share/pixmaps', - join(homedir(), '.local', 'share', 'pixmaps'), + posixPath.join(homedir(), '.local', 'share', 'pixmaps'), ] for (const root of directRoots) { for (const candidateExtension of extensions) { - const candidate = join(root, `${baseName}${candidateExtension}`) + const candidate = posixPath.join(root, `${baseName}${candidateExtension}`) if (await runtime.pathExists(candidate)) return candidate } } @@ -830,7 +866,7 @@ async function resolveLinuxIconName( for (const themeName of themeNames) { for (const subdir of LINUX_ICON_THEME_SUBDIRS) { for (const candidateExtension of extensions) { - const candidate = join(root, themeName, subdir, `${baseName}${candidateExtension}`) + const candidate = posixPath.join(root, themeName, subdir, `${baseName}${candidateExtension}`) if (await runtime.pathExists(candidate)) return candidate } }