222 lines
6.7 KiB
Go
222 lines
6.7 KiB
Go
|
|
//go:build windows
|
|||
|
|
|
|||
|
|
// Windows 版 Node 环境探测:GUI 进程往往没有继承 shell 注入的 Node 路径
|
|||
|
|
// (fnm/nvm 等按 shell 修改 PATH),因此合并注册表 PATH 与常见安装目录。
|
|||
|
|
package cursor
|
|||
|
|
|
|||
|
|
import (
|
|||
|
|
"os"
|
|||
|
|
"path/filepath"
|
|||
|
|
"strings"
|
|||
|
|
|
|||
|
|
"cursor/internal/winexec"
|
|||
|
|
|
|||
|
|
"golang.org/x/sys/windows/registry"
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
// mergeNodeSearchPath 合并进程 PATH、注册表用户/系统 PATH 与常见 Node 安装目录。
|
|||
|
|
// 返回合并后的 PATH,以及是否发现了额外的候选目录。
|
|||
|
|
func mergeNodeSearchPath() (string, bool) {
|
|||
|
|
parts := filepath.SplitList(os.Getenv("PATH"))
|
|||
|
|
seen := make(map[string]struct{}, len(parts))
|
|||
|
|
for _, p := range parts {
|
|||
|
|
seen[strings.ToLower(strings.TrimSpace(p))] = struct{}{}
|
|||
|
|
}
|
|||
|
|
found := false
|
|||
|
|
add := func(dir string) {
|
|||
|
|
dir = strings.TrimSpace(dir)
|
|||
|
|
if dir == "" {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
if info, err := os.Stat(dir); err != nil || !info.IsDir() {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
key := strings.ToLower(dir)
|
|||
|
|
if _, exists := seen[key]; exists {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
seen[key] = struct{}{}
|
|||
|
|
parts = append(parts, dir)
|
|||
|
|
found = true
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 注册表中的用户与系统 PATH(GUI 进程可能未刷新)。
|
|||
|
|
for _, source := range []struct {
|
|||
|
|
root registry.Key
|
|||
|
|
path string
|
|||
|
|
}{
|
|||
|
|
{registry.CURRENT_USER, `Environment`},
|
|||
|
|
{registry.LOCAL_MACHINE, `SYSTEM\CurrentControlSet\Control\Session Manager\Environment`},
|
|||
|
|
} {
|
|||
|
|
value, err := registryExpandString(source.root, source.path, "Path")
|
|||
|
|
if err != nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
for _, dir := range filepath.SplitList(value) {
|
|||
|
|
add(dir)
|
|||
|
|
probeFnmDataDirs(dir, add)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// 常见 Node 安装目录兜底(fnm/nvm/volta/scoop/官方安装器)。
|
|||
|
|
homeDir, _ := os.UserHomeDir()
|
|||
|
|
localAppData := os.Getenv("LOCALAPPDATA")
|
|||
|
|
fnmDir := os.Getenv("FNM_DIR")
|
|||
|
|
candidates := []string{
|
|||
|
|
filepath.Join(os.Getenv("ProgramFiles"), "nodejs"),
|
|||
|
|
filepath.Join(localAppData, "Programs", "nodejs"),
|
|||
|
|
filepath.Join(homeDir, "AppData", "Roaming", "nvm"),
|
|||
|
|
filepath.Join(homeDir, "scoop", "apps", "nodejs", "current"),
|
|||
|
|
filepath.Join(homeDir, ".volta", "bin"),
|
|||
|
|
}
|
|||
|
|
if localAppData != "" {
|
|||
|
|
base := fnmDir
|
|||
|
|
if base == "" {
|
|||
|
|
base = filepath.Join(localAppData, "fnm")
|
|||
|
|
}
|
|||
|
|
// fnm:aliases/default 指向默认版本;node-versions 下枚举各版本。
|
|||
|
|
candidates = append(candidates,
|
|||
|
|
filepath.Join(base, "aliases", "default"),
|
|||
|
|
filepath.Join(base, "node-versions"),
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
for _, dir := range candidates {
|
|||
|
|
add(dir)
|
|||
|
|
probeFnmDataDirs(dir, add)
|
|||
|
|
if strings.EqualFold(filepath.Base(dir), "node-versions") {
|
|||
|
|
if versions, err := os.ReadDir(dir); err == nil {
|
|||
|
|
for _, version := range versions {
|
|||
|
|
add(filepath.Join(dir, version.Name(), "installation"))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return strings.Join(parts, string(os.PathListSeparator)), found
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// probeFnmDataDirs 在 PATH 候选目录中发现 fnm.exe 时,追加探测其数据目录下的
|
|||
|
|
// node 版本(fnm 的 FNM_DIR 是 shell 级环境变量,GUI 进程通常拿不到)。
|
|||
|
|
func probeFnmDataDirs(dir string, add func(string)) {
|
|||
|
|
if _, err := os.Stat(filepath.Join(dir, "fnm.exe")); err != nil {
|
|||
|
|
return
|
|||
|
|
}
|
|||
|
|
// 兼容两种布局:<root>\bin + <root>\data,或 <root>\aliases + <root>\node-versions。
|
|||
|
|
for _, dataDir := range []string{
|
|||
|
|
filepath.Join(filepath.Dir(dir), "data"),
|
|||
|
|
dir,
|
|||
|
|
} {
|
|||
|
|
add(filepath.Join(dataDir, "aliases", "default"))
|
|||
|
|
versionsDir := filepath.Join(dataDir, "node-versions")
|
|||
|
|
if versions, err := os.ReadDir(versionsDir); err == nil {
|
|||
|
|
for _, version := range versions {
|
|||
|
|
add(filepath.Join(versionsDir, version.Name(), "installation"))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// registryExpandString 读取注册表字符串值并展开 %VAR% 引用。
|
|||
|
|
func registryExpandString(root registry.Key, keyPath string, valueName string) (string, error) {
|
|||
|
|
key, err := registry.OpenKey(root, keyPath, registry.QUERY_VALUE)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
defer key.Close()
|
|||
|
|
value, _, err := key.GetStringValue(valueName)
|
|||
|
|
if err != nil {
|
|||
|
|
return "", err
|
|||
|
|
}
|
|||
|
|
return os.ExpandEnv(value), nil
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// detectCursorInstallPathFromRegistry 从 Windows 卸载项中查找 Cursor 安装位置。
|
|||
|
|
func detectCursorInstallPathFromRegistry() string {
|
|||
|
|
for _, source := range []struct {
|
|||
|
|
root registry.Key
|
|||
|
|
path string
|
|||
|
|
}{
|
|||
|
|
{registry.CURRENT_USER, `Software\Microsoft\Windows\CurrentVersion\Uninstall`},
|
|||
|
|
{registry.LOCAL_MACHINE, `Software\Microsoft\Windows\CurrentVersion\Uninstall`},
|
|||
|
|
{registry.LOCAL_MACHINE, `Software\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall`},
|
|||
|
|
} {
|
|||
|
|
root, err := registry.OpenKey(source.root, source.path, registry.QUERY_VALUE)
|
|||
|
|
if err != nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
names, err := root.ReadSubKeyNames(-1)
|
|||
|
|
root.Close()
|
|||
|
|
if err != nil {
|
|||
|
|
// 部分安全软件会拦截 Go 进程的注册表枚举,降级用 reg.exe 查询子键名。
|
|||
|
|
names = regQuerySubKeyNames(registryKeyPath(source.root, source.path))
|
|||
|
|
}
|
|||
|
|
for _, name := range names {
|
|||
|
|
sub, err := registry.OpenKey(source.root, source.path+`\`+name, registry.QUERY_VALUE)
|
|||
|
|
if err != nil {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
// 安装器(如 Inno Setup)常用 GUID 作键名,DisplayName 在值里;
|
|||
|
|
// 不含 cursor 的条目仅在 InstallLocation 指向有效 Cursor 目录时采用。
|
|||
|
|
dir := readCursorInstallFromKey(sub)
|
|||
|
|
sub.Close()
|
|||
|
|
if dir != "" {
|
|||
|
|
return dir
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return ""
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// registryKeyPath 返回 reg.exe 可识别的根键路径表示。
|
|||
|
|
func registryKeyPath(root registry.Key, path string) string {
|
|||
|
|
switch root {
|
|||
|
|
case registry.CURRENT_USER:
|
|||
|
|
return `HKCU\` + path
|
|||
|
|
case registry.LOCAL_MACHINE:
|
|||
|
|
return `HKLM\` + path
|
|||
|
|
default:
|
|||
|
|
return path
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// regQuerySubKeyNames 通过 reg.exe 枚举子键名,作为 Go 注册表枚举被拦截时的降级方案。
|
|||
|
|
func regQuerySubKeyNames(fullPath string) []string {
|
|||
|
|
out, err := winexec.Command("reg", "query", fullPath).Output()
|
|||
|
|
if err != nil {
|
|||
|
|
return nil
|
|||
|
|
}
|
|||
|
|
prefix := strings.ToUpper(fullPath) + `\`
|
|||
|
|
names := make([]string, 0, 16)
|
|||
|
|
for _, line := range strings.Split(string(out), "\n") {
|
|||
|
|
line = strings.TrimSpace(line)
|
|||
|
|
if !strings.HasPrefix(strings.ToUpper(line), prefix) {
|
|||
|
|
continue
|
|||
|
|
}
|
|||
|
|
name := strings.TrimSpace(line[len(prefix):])
|
|||
|
|
if name != "" && !strings.Contains(name, " ") {
|
|||
|
|
names = append(names, name)
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return names
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
// readCursorInstallFromKey 从单个卸载项读取并校验安装目录。
|
|||
|
|
func readCursorInstallFromKey(key registry.Key) string {
|
|||
|
|
candidates := make([]string, 0, 2)
|
|||
|
|
if value, _, err := key.GetStringValue("InstallLocation"); err == nil {
|
|||
|
|
candidates = append(candidates, value)
|
|||
|
|
}
|
|||
|
|
if value, _, err := key.GetStringValue("DisplayIcon"); err == nil {
|
|||
|
|
// DisplayIcon 形如 "D:\path\Cursor.exe" 或带 ",0" 后缀。
|
|||
|
|
icon := strings.SplitN(value, ",", 2)[0]
|
|||
|
|
icon = strings.Trim(icon, `"`)
|
|||
|
|
candidates = append(candidates, filepath.Dir(icon))
|
|||
|
|
}
|
|||
|
|
for _, dir := range candidates {
|
|||
|
|
if isCursorInstallDir(strings.TrimSpace(dir)) {
|
|||
|
|
return filepath.Clean(strings.TrimSpace(dir))
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
return ""
|
|||
|
|
}
|