//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 } // 兼容两种布局:\bin + \data,或 \aliases + \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 "" }