Files

390 lines
12 KiB
Go
Raw Permalink Normal View History

// 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
}