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