Files
Local-Cursor/internal/cursor/localize.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

390 lines
12 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.
// 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
}