Files
Local-Cursor/internal/cursor/localize_windows.go
FuDie0915 db73cf5a39 chore: import Local-Cursor v1.1.8 source snapshot
Includes the current working directory changes. Historical commits and release binaries are excluded to limit Gitea storage.
Source base: d7515973fbfd6dd2001fa156c483455b8081c7cb
2026-11-10 01:24:28 +08:00

222 lines
6.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//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 ""
}