// localize.go 提供 Cursor IDE 汉化能力。 // 通过关闭 Cursor 进程后运行社区汉化包,将语言包写入 Cursor 安装目录。 package cursor import ( "bytes" "context" "fmt" "os" "os/exec" "path/filepath" "runtime" "strings" "sync" "time" "cursor/internal/logger" "cursor/internal/winexec" ) // localizeLanguagePack 是社区汉化包的 npm 包名。 const localizeLanguagePack = "@polang233/cursor-language-pack@latest" // localizeCommandTimeout 限制汉化命令的最长执行时间。 const localizeCommandTimeout = 10 * time.Minute // DetectCursorInstallPath 检测 Cursor IDE 本体的安装目录。 // Windows 优先读取注册表卸载项,再探测常见路径;返回包含 Cursor 可执行文件的目录。 // 成功结果会被缓存,后续调用仅在缓存目录失效时重新探测。 func DetectCursorInstallPath() string { if cached := cachedCursorInstallPath(); cached != "" { return cached } if runtime.GOOS == "windows" { if dir := detectCursorInstallPathFromRegistry(); dir != "" { logger.Infof("cursor install detected source=registry path=%s", dir) cacheCursorInstallPath(dir) return dir } } if dir := detectCursorInstallPathFromCandidates(); dir != "" { logger.Infof("cursor install detected source=candidates path=%s", dir) cacheCursorInstallPath(dir) return dir } return "" } func detectCursorInstallPathFromCandidates() string { if dir := scanKnownCandidates(); dir != "" { return dir } if runtime.GOOS == "windows" { if dir := scanDriveRootsForCursor(); dir != "" { return dir } } return "" } // scanKnownCandidates 探测常见安装路径。 func scanKnownCandidates() string { homeDir, err := os.UserHomeDir() if err != nil { return "" } var candidates []string switch runtime.GOOS { case "windows": localAppData := strings.TrimSpace(os.Getenv("LOCALAPPDATA")) if localAppData == "" { localAppData = filepath.Join(homeDir, "AppData", "Local") } candidates = append(candidates, filepath.Join(localAppData, "Programs", "cursor"), filepath.Join(localAppData, "Programs", "Cursor"), filepath.Join(localAppData, "cursor"), ) case "darwin": candidates = append(candidates, "/Applications/Cursor.app") case "linux": candidates = append(candidates, "/opt/Cursor", "/opt/cursor", "/usr/share/cursor", filepath.Join(homeDir, ".local", "share", "cursor"), ) } for _, dir := range candidates { if isCursorInstallDir(dir) { return dir } } return "" } // isCursorInstallDir 判断目录是否为有效的 Cursor 安装目录。 func isCursorInstallDir(dir string) bool { info, err := os.Stat(dir) if err != nil || !info.IsDir() { return false } if runtime.GOOS == "darwin" { return true } exe := "cursor" if runtime.GOOS == "windows" { exe = "Cursor.exe" } if _, err := os.Stat(filepath.Join(dir, exe)); err == nil { return true } return false } // installPathCache 缓存已探测成功的安装目录,避免心跳重复执行全量扫描。 var installPathCache struct { sync.RWMutex dir string } // cachedCursorInstallPath 返回缓存中仍有效的安装目录。 func cachedCursorInstallPath() string { installPathCache.RLock() dir := installPathCache.dir installPathCache.RUnlock() if dir != "" && isCursorInstallDir(dir) { return dir } installPathCache.Lock() if installPathCache.dir == dir { installPathCache.dir = "" } installPathCache.Unlock() return "" } // cacheCursorInstallPath 缓存探测成功的安装目录。 func cacheCursorInstallPath(dir string) { if dir == "" { return } installPathCache.Lock() installPathCache.dir = dir installPathCache.Unlock() } // killCursorProcesses 关闭所有 Cursor 进程。 func killCursorProcesses() error { var cmd *exec.Cmd switch runtime.GOOS { case "windows": // taskkill 不会因为目标不存在而返回有意义的错误,这里只关心执行本身。 cmd = winexec.Command("taskkill", "/IM", "Cursor.exe", "/F") case "darwin": cmd = exec.Command("pkill", "-x", "Cursor") case "linux": cmd = exec.Command("pkill", "-f", "cursor") default: return fmt.Errorf("不支持的平台: %s", runtime.GOOS) } // 未找到进程时 pkill/taskkill 返回非零值,属于正常情况。 _ = cmd.Run() return nil } // KillCursorProcesses 强制结束所有 Cursor 进程(供退出联动调用)。 func KillCursorProcesses() error { return killCursorProcesses() } // OpenCursor 启动 Cursor IDE。 // installDir 为空时自动检测安装目录。已运行时重复调用会打开新窗口(与官方行为一致)。 func OpenCursor(installDir string) error { installDir = strings.TrimSpace(installDir) if installDir == "" { installDir = DetectCursorInstallPath() } if installDir == "" { return fmt.Errorf("未检测到 Cursor 安装目录,请手动指定后重试") } if !isCursorInstallDir(installDir) { return fmt.Errorf("指定目录不是有效的 Cursor 安装目录: %s", installDir) } exeName := "Cursor.exe" if runtime.GOOS != "windows" { exeName = "Cursor" } exePath := filepath.Join(installDir, exeName) if _, err := os.Stat(exePath); err != nil { return fmt.Errorf("找不到 Cursor 可执行文件: %s", exePath) } // Cursor 是 GUI 子系统程序,直接启动不会弹出控制台; // 不使用 winexec(SW_HIDE 会隐藏 GUI 主窗口)。 cmd := exec.Command(exePath) cmd.Dir = installDir return cmd.Start() } // LocalizeCursor 关闭 Cursor 进程并运行汉化包。 // installDir 为空时自动检测安装目录;非空时使用用户指定的目录。 // 返回命令输出摘要。 func LocalizeCursor(installDir string) (string, error) { nodeEnv, err := resolveNodeRuntime() if err != nil { return "", err } installDir = strings.TrimSpace(installDir) if installDir == "" { installDir = DetectCursorInstallPath() } else if !isCursorInstallDir(installDir) { return "", fmt.Errorf("指定目录不是有效的 Cursor 安装目录(缺少 Cursor 可执行文件): %s", installDir) } if installDir == "" { return "", fmt.Errorf("未检测到 Cursor 安装目录,请手动指定后重试") } logger.Infof("localize requested install_dir=%s", installDir) npmRegistry, npxRegistry, err := inspectNPMRegistries(nodeEnv, installDir) if err != nil { logger.Errorf("npm registry inspection failed: %v", err) return "", err } logger.Infof("npm registry detected npm=%s npx=%s", npmRegistry, npxRegistry) restoreRegistry := useOfficialNPMRegistry() defer restoreRegistry() if err := killCursorProcesses(); err != nil { return "", fmt.Errorf("关闭 Cursor 进程失败: %w", err) } // 给进程退出留出时间,避免文件被占用。 time.Sleep(2 * time.Second) // 直接通过 node 执行 npx-cli.js,避免 cmd 对含空格安装路径的二次解析。 args := []string{nodeEnv.npxCLI, "-y", localizeLanguagePack, "--dir=" + installDir} output, err := runWithTimeout(nodeEnv.nodePath, args, installDir, localizeCommandTimeout) if err != nil { return output, commandError("汉化包执行失败", output, err) } summary := strings.TrimSpace(output) if summary == "" { summary = "汉化完成,请重新启动 Cursor" } logger.Infof("localize completed install_dir=%s summary=%s", installDir, summary) return summary, nil } const officialNPMRegistry = "https://registry.npmjs.org/" // nodeRuntime 固定 node 与 npm/npx CLI 入口,避免依赖 cmd/PowerShell 的脚本关联。 type nodeRuntime struct { nodePath string npmCLI string npxCLI string } // resolveNodeRuntime 在执行任何破坏性操作前探测 Node.js 与 npm/npx CLI。 func resolveNodeRuntime() (nodeRuntime, error) { if mergedPath, found := mergeNodeSearchPath(); found { os.Setenv("PATH", mergedPath) } nodePath, err := exec.LookPath("node") if err != nil { return nodeRuntime{}, fmt.Errorf("未检测到 Node.js 环境,汉化功能依赖 npx 执行,请先安装 Node.js") } npmCLI := findNodeCLI(nodePath, "npm-cli.js") npxCLI := findNodeCLI(nodePath, "npx-cli.js") if npmCLI == "" || npxCLI == "" { return nodeRuntime{}, fmt.Errorf("未检测到完整的 npm/npx CLI,请确认 Node.js 已完整安装") } logger.Infof("node runtime detected node=%s npm_cli=%s npx_cli=%s", nodePath, npmCLI, npxCLI) return nodeRuntime{nodePath: nodePath, npmCLI: npmCLI, npxCLI: npxCLI}, nil } // findNodeCLI 在 Node 安装目录中定位 npm/npx 的 JavaScript 入口。 func findNodeCLI(nodePath string, scriptName string) string { for _, dir := range []string{ filepath.Join(filepath.Dir(nodePath), "node_modules", "npm", "bin"), filepath.Join(filepath.Dir(nodePath), "..", "lib", "node_modules", "npm", "bin"), } { candidate := filepath.Join(dir, scriptName) if info, err := os.Stat(candidate); err == nil && !info.IsDir() { return candidate } } return "" } // inspectNPMRegistries 分别探测 npm 与 npx 的 registry。 // npx 与 npm 共享配置;部分版本不支持 npx config,此时保留错误日志并回退到 npm 结果。 func inspectNPMRegistries(nodeEnv nodeRuntime, dir string) (string, string, error) { const timeout = 30 * time.Second npmOutput, err := runWithTimeout(nodeEnv.nodePath, []string{nodeEnv.npmCLI, "config", "get", "registry"}, dir, timeout) if err != nil { return "", "", commandError("读取 npm 镜像源失败", npmOutput, err) } npxOutput, npxErr := runWithTimeout(nodeEnv.nodePath, []string{nodeEnv.npxCLI, "config", "get", "registry"}, dir, timeout) npmRegistry := strings.TrimSpace(npmOutput) if npxErr != nil { logger.Infof("npx registry probe fallback to shared npm config err=%v output=%s", npxErr, commandOutputDetail(npxOutput)) return npmRegistry, npmRegistry, nil } return npmRegistry, strings.TrimSpace(npxOutput), nil } // useOfficialNPMRegistry 仅对当前进程及其子进程临时覆盖 registry。 // 不读写全局 .npmrc;返回的恢复函数会还原环境变量,成功和失败路径均可调用。 func useOfficialNPMRegistry() func() { keys := []string{"NPM_CONFIG_REGISTRY", "npm_config_registry"} restore := overrideEnvironment(keys, officialNPMRegistry) logger.Infof("npm registry temporarily overridden official=%s", officialNPMRegistry) return func() { restore() logger.Info("npm registry environment restored") } } // overrideEnvironment 备份并临时设置环境变量,返回恢复原值的函数。 func overrideEnvironment(keys []string, value string) func() { type envBackup struct { value string exists bool } backup := make(map[string]envBackup, len(keys)) for _, key := range keys { current, exists := os.LookupEnv(key) backup[key] = envBackup{value: current, exists: exists} _ = os.Setenv(key, value) } return func() { for _, key := range keys { item := backup[key] if item.exists { _ = os.Setenv(key, item.value) } else { _ = os.Unsetenv(key) } } } } // commandError 将子进程 stderr/stdout 附加到用户可见错误,避免只剩 exit status。 func commandError(action string, output string, err error) error { if detail := commandOutputDetail(output); detail != "" { return fmt.Errorf("%s: %w\n%s", action, err, detail) } return fmt.Errorf("%s: %w", action, err) } // commandOutputDetail 限制错误摘要长度,同时保持 UTF-8 字符完整。 func commandOutputDetail(output string) string { const maxRunes = 4000 detail := strings.TrimSpace(output) if detail == "" { return "" } runes := []rune(detail) if len(runes) > maxRunes { detail = "…" + string(runes[len(runes)-maxRunes:]) } return detail } // runWithTimeout 带超时地执行命令,记录完整调用上下文并返回合并后的输出。 func runWithTimeout(name string, args []string, dir string, timeout time.Duration) (string, error) { logger.Infof("command started name=%s args=%q dir=%s", name, args, dir) ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() cmd := winexec.Hide(exec.CommandContext(ctx, name, args...)) var buf bytes.Buffer cmd.Stdout = &buf cmd.Stderr = &buf cmd.Dir = dir err := cmd.Run() output := buf.String() if ctx.Err() == context.DeadlineExceeded { logger.Errorf("command timeout name=%s timeout=%s output=%s", name, timeout, commandOutputDetail(output)) return output, fmt.Errorf("执行超时(%s)", timeout) } if err != nil { logger.Errorf("command failed name=%s err=%v output=%s", name, err, commandOutputDetail(output)) } else { logger.Infof("command completed name=%s output=%s", name, commandOutputDetail(output)) } return output, err }