Skip to content

Commit 2f8bf3a

Browse files
feat(windows): registry-based IDE discovery fallback
When hardcoded WinPaths don't match (e.g., user installed to a custom path like D:\Tools\VSCode), fall back to querying the Windows registry Uninstall keys for InstallLocation. Zero performance impact for standard installs — registry is only queried when hardcoded paths fail. - Add registryInstallInfo struct + readRegistryInstallInfo (parses both DisplayVersion and InstallLocation from reg query output) - Refactor readRegistryVersion to delegate (backward-compatible) - Add RegistryName field to ideSpec for ambiguous app names - Refactor detectWindows into Phase 1 (hardcoded) + Phase 2 (registry) - 8 new tests covering custom paths, missing dirs, HKCU, spaces
1 parent 57a002f commit 2f8bf3a

2 files changed

Lines changed: 321 additions & 39 deletions

File tree

internal/detector/ide.go

Lines changed: 123 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -12,14 +12,23 @@ import (
1212
)
1313

1414
type ideSpec struct {
15-
AppName string
16-
IDEType string
17-
Vendor string
18-
AppPath string // macOS: /Applications/X.app
19-
BinaryPath string // macOS: relative to AppPath
20-
WinPaths []string // Windows: candidate install dirs (may contain %ENVVAR% and glob patterns)
21-
WinBinary string // Windows: binary relative to install dir
22-
VersionFlag string
15+
AppName string
16+
IDEType string
17+
Vendor string
18+
AppPath string // macOS: /Applications/X.app
19+
BinaryPath string // macOS: relative to AppPath
20+
WinPaths []string // Windows: candidate install dirs (may contain %ENVVAR% and glob patterns)
21+
WinBinary string // Windows: binary relative to install dir
22+
VersionFlag string
23+
RegistryName string // Windows: override for registry search if DisplayName differs from AppName
24+
}
25+
26+
// registrySearchName returns the name to use for registry searches.
27+
func (s ideSpec) registrySearchName() string {
28+
if s.RegistryName != "" {
29+
return s.RegistryName
30+
}
31+
return s.AppName
2332
}
2433

2534
var ideDefinitions = []ideSpec{
@@ -97,9 +106,10 @@ var ideDefinitions = []ideSpec{
97106
WinPaths: []string{`%PROGRAMFILES%\JetBrains\GoLand *`},
98107
},
99108
{
100-
AppName: "Rider", IDEType: "rider", Vendor: "JetBrains",
101-
AppPath: "/Applications/Rider.app",
102-
WinPaths: []string{`%PROGRAMFILES%\JetBrains\JetBrains Rider *`},
109+
AppName: "Rider", IDEType: "rider", Vendor: "JetBrains",
110+
AppPath: "/Applications/Rider.app",
111+
WinPaths: []string{`%PROGRAMFILES%\JetBrains\JetBrains Rider *`},
112+
RegistryName: "JetBrains Rider",
103113
},
104114
{
105115
AppName: "PhpStorm", IDEType: "phpstorm", Vendor: "JetBrains",
@@ -198,6 +208,7 @@ func (d *IDEDetector) detectDarwin(ctx context.Context, spec ideSpec) (model.IDE
198208
}
199209

200210
func (d *IDEDetector) detectWindows(ctx context.Context, spec ideSpec) (model.IDE, bool) {
211+
// Phase 1: Try hardcoded paths (fast, no registry)
201212
for _, winPath := range spec.WinPaths {
202213
resolved := resolveEnvPath(d.exec, winPath)
203214

@@ -206,39 +217,81 @@ func (d *IDEDetector) detectWindows(ctx context.Context, spec ideSpec) (model.ID
206217
continue
207218
}
208219

209-
version := "unknown"
210-
211-
// Try version from binary
212-
if spec.WinBinary != "" && spec.VersionFlag != "" {
213-
binaryFull := filepath.Join(installDir, spec.WinBinary)
214-
if d.exec.FileExists(binaryFull) {
215-
version = runVersionCmd(ctx, d.exec, binaryFull, spec.VersionFlag)
216-
}
217-
}
218-
219-
// Fallback: product-info.json (JetBrains IDEs)
220-
if version == "unknown" {
221-
version = readProductInfoVersion(d.exec, filepath.Join(installDir, "product-info.json"))
222-
}
223-
224-
// Fallback: .eclipseproduct
225-
if version == "unknown" {
226-
version = readEclipseProductVersion(d.exec, filepath.Join(installDir, ".eclipseproduct"))
227-
}
228-
229-
// Fallback: registry
230-
if version == "unknown" {
231-
version = readRegistryVersion(ctx, d.exec, spec.AppName)
232-
}
220+
version := d.resolveWindowsVersion(ctx, spec, installDir)
221+
return model.IDE{
222+
IDEType: spec.IDEType, Version: version, InstallPath: installDir,
223+
Vendor: spec.Vendor, IsInstalled: true,
224+
}, true
225+
}
233226

227+
// Phase 2: Registry fallback — discover install path from Uninstall keys.
228+
// Catches IDEs installed at non-standard paths (e.g., D:\Tools\VSCode).
229+
if installDir, version, ok := d.discoverViaRegistry(ctx, spec); ok {
234230
return model.IDE{
235231
IDEType: spec.IDEType, Version: version, InstallPath: installDir,
236232
Vendor: spec.Vendor, IsInstalled: true,
237233
}, true
238234
}
235+
239236
return model.IDE{}, false
240237
}
241238

239+
// resolveWindowsVersion determines the IDE version using multiple strategies.
240+
func (d *IDEDetector) resolveWindowsVersion(ctx context.Context, spec ideSpec, installDir string) string {
241+
version := d.resolveWindowsVersionFromDir(ctx, spec, installDir)
242+
if version == "unknown" {
243+
version = readRegistryVersion(ctx, d.exec, spec.registrySearchName())
244+
}
245+
return version
246+
}
247+
248+
// resolveWindowsVersionFromDir tries binary, product-info.json, and .eclipseproduct.
249+
// Does NOT query the registry (caller handles that to avoid redundant queries).
250+
func (d *IDEDetector) resolveWindowsVersionFromDir(ctx context.Context, spec ideSpec, installDir string) string {
251+
version := "unknown"
252+
253+
if spec.WinBinary != "" && spec.VersionFlag != "" {
254+
binaryFull := filepath.Join(installDir, spec.WinBinary)
255+
if d.exec.FileExists(binaryFull) {
256+
version = runVersionCmd(ctx, d.exec, binaryFull, spec.VersionFlag)
257+
}
258+
}
259+
260+
if version == "unknown" {
261+
version = readProductInfoVersion(d.exec, filepath.Join(installDir, "product-info.json"))
262+
}
263+
264+
if version == "unknown" {
265+
version = readEclipseProductVersion(d.exec, filepath.Join(installDir, ".eclipseproduct"))
266+
}
267+
268+
return version
269+
}
270+
271+
// discoverViaRegistry attempts to find an IDE's install location from Windows
272+
// Uninstall registry keys. This is a fallback for IDEs installed at non-standard paths.
273+
func (d *IDEDetector) discoverViaRegistry(ctx context.Context, spec ideSpec) (string, string, bool) {
274+
info := readRegistryInstallInfo(ctx, d.exec, spec.registrySearchName())
275+
276+
if info.InstallLocation == "" {
277+
return "", "", false
278+
}
279+
280+
if !d.exec.DirExists(info.InstallLocation) {
281+
return "", "", false
282+
}
283+
284+
// Resolve version from the discovered directory
285+
version := d.resolveWindowsVersionFromDir(ctx, spec, info.InstallLocation)
286+
287+
// Use registry DisplayVersion as final fallback (avoids redundant registry query)
288+
if version == "unknown" && info.Version != "" {
289+
version = info.Version
290+
}
291+
292+
return info.InstallLocation, version, true
293+
}
294+
242295
// resolveInstallDir resolves a Windows path to an install directory.
243296
// Supports glob patterns (e.g., "C:\Program Files\JetBrains\GoLand *")
244297
// for IDEs that embed version numbers in folder names.
@@ -351,8 +404,15 @@ func readPlistVersion(ctx context.Context, exec executor.Executor, plistPath str
351404
return "unknown"
352405
}
353406

354-
// readRegistryVersion searches Windows Uninstall registry keys for DisplayVersion.
355-
func readRegistryVersion(ctx context.Context, exec executor.Executor, appName string) string {
407+
// registryInstallInfo holds version and install path from Windows Uninstall registry keys.
408+
type registryInstallInfo struct {
409+
Version string
410+
InstallLocation string
411+
}
412+
413+
// readRegistryInstallInfo searches Windows Uninstall registry keys and extracts
414+
// both DisplayVersion and InstallLocation for the given app name.
415+
func readRegistryInstallInfo(ctx context.Context, exec executor.Executor, appName string) registryInstallInfo {
356416
for _, root := range []string{
357417
`HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall`,
358418
`HKLM\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`,
@@ -362,16 +422,40 @@ func readRegistryVersion(ctx context.Context, exec executor.Executor, appName st
362422
if err != nil {
363423
continue
364424
}
365-
// Parse "DisplayVersion REG_SZ x.y.z" from reg query output
425+
426+
var info registryInstallInfo
366427
for _, line := range strings.Split(stdout, "\n") {
367428
line = strings.TrimSpace(line)
368429
if strings.Contains(line, "DisplayVersion") {
369430
parts := strings.Fields(line)
370431
if len(parts) >= 3 {
371-
return parts[len(parts)-1]
432+
info.Version = parts[len(parts)-1]
433+
}
434+
}
435+
if strings.Contains(line, "InstallLocation") {
436+
// InstallLocation may contain spaces, so split on REG_SZ and trim
437+
parts := strings.SplitN(line, "REG_SZ", 2)
438+
if len(parts) == 2 {
439+
loc := strings.TrimSpace(parts[1])
440+
if loc != "" {
441+
info.InstallLocation = loc
442+
}
372443
}
373444
}
374445
}
446+
447+
if info.Version != "" || info.InstallLocation != "" {
448+
return info
449+
}
450+
}
451+
return registryInstallInfo{}
452+
}
453+
454+
// readRegistryVersion searches Windows Uninstall registry keys for DisplayVersion.
455+
func readRegistryVersion(ctx context.Context, exec executor.Executor, appName string) string {
456+
info := readRegistryInstallInfo(ctx, exec, appName)
457+
if info.Version != "" {
458+
return info.Version
375459
}
376460
return "unknown"
377461
}

0 commit comments

Comments
 (0)