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
This commit is contained in:
FuDie0915
2026-11-10 01:24:28 +08:00
commit db73cf5a39
337 changed files with 147502 additions and 0 deletions

417
internal/cursor/cli.go Normal file
View File

@@ -0,0 +1,417 @@
// cli.go 管理 CLI 版 cursor-agent 的代理设置注入。
// 通过在数据目录生成 wrapper 脚本和 preload 脚本,
// 将 CLI 的 cursor.sh 连接重定向到后端 HTTPS+HTTP/2 监听器。
package cursor
import (
"encoding/json"
"fmt"
"net"
"os"
"path/filepath"
"runtime"
"strings"
"cursor/internal/appdata"
"cursor/internal/logger"
localruntime "cursor/internal/runtime"
)
// CLIWrapperDir 返回 wrapper 脚本存放目录。
func CLIWrapperDir() string {
return filepath.Join(appdata.RootDir(), "cli")
}
// CLIWrapperPath 返回当前平台的 wrapper 脚本完整路径。
func CLIWrapperPath() string {
if runtime.GOOS == "windows" {
return filepath.Join(CLIWrapperDir(), "agent.cmd")
}
return filepath.Join(CLIWrapperDir(), "agent")
}
// DetectCLIInstallPath 尝试检测 CLI 版 cursor-agent 的安装路径。
func DetectCLIInstallPath() string {
homeDir, err := os.UserHomeDir()
if err != nil {
return ""
}
candidates := make([]string, 0, 4)
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, "cursor-agent", "agent.cmd"),
)
case "darwin":
candidates = append(candidates,
filepath.Join(homeDir, ".cursor-agent", "agent"),
filepath.Join(homeDir, ".local", "bin", "cursor-agent"),
)
case "linux":
candidates = append(candidates,
filepath.Join(homeDir, ".cursor-agent", "agent"),
filepath.Join(homeDir, ".local", "bin", "cursor-agent"),
"/usr/local/bin/cursor-agent",
)
}
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && !info.IsDir() {
return c
}
}
return ""
}
// cliPreloadFilename 是 preload 脚本的文件名。
const cliPreloadFilename = "tls-bypass.cjs"
// cliPreloadScript 是注入到 Node.js 的 preload 脚本。
//
// 工作原理:
// 1. patch net.connect — 将 cursor.sh 域名的 TCP 连接重定向到本地后端 HTTPS 端口
// 2. patch tls.connect — 强制 rejectUnauthorized=false,使后端签发的 CA 证书被接受
//
// CLI 的 connect-node 使用 HTTP/2 (http2.connect),goproxy 无法 MITM HTTP/2 帧。
// 因此跳过 MITM 代理,直接在 TCP 层重定向到后端的 HTTPS+HTTP/2 监听器。
// 后端 Go http.Server.ServeTLS 原生支持 HTTP/2 ALPN。
const cliPreloadScript = `// Generated by Local-Curosr: redirect cursor.sh TCP to local HTTPS backend
'use strict';
const tls = require('tls');
const net = require('net');
const fs = require('fs');
const path = require('path');
const os = require('os');
const logPath = path.join(os.homedir(), '.cursor-local-assistant-v2', 'logs', 'tls-bypass.log');
function dbg(msg) {
try { fs.appendFileSync(logPath, new Date().toISOString() + ' ' + msg + '\n'); } catch(e) {}
}
var REDIRECT_PORT = parseInt(process.env.CLI_HTTPS_PORT || '39092', 10);
var REDIRECT_HOST = '127.0.0.1';
var REDIRECT_DOMAINS = ['api2.cursor.sh', 'api3.cursor.sh'];
function shouldRedirect(host) {
if (!host) return false;
host = String(host).toLowerCase().trim();
for (var i = 0; i < REDIRECT_DOMAINS.length; i++) {
var d = REDIRECT_DOMAINS[i];
if (host === d || host.endsWith('.' + d)) return true;
}
return false;
}
// --- patch net.connect: TCP 层重定向 ---
var origNetConnect = net.connect;
function patchedNetConnect() {
if (typeof arguments[0] === 'object' && arguments[0] !== null) {
var opts = arguments[0];
if (shouldRedirect(opts.host)) {
dbg('net.connect redirect: ' + opts.host + ':' + opts.port + ' -> ' + REDIRECT_HOST + ':' + REDIRECT_PORT);
opts.host = REDIRECT_HOST;
opts.port = REDIRECT_PORT;
}
} else if (typeof arguments[0] === 'number' && typeof arguments[1] === 'string') {
if (shouldRedirect(arguments[1])) {
dbg('net.connect redirect (positional): ' + arguments[1] + ':' + arguments[0] + ' -> ' + REDIRECT_HOST + ':' + REDIRECT_PORT);
arguments[1] = REDIRECT_HOST;
arguments[0] = REDIRECT_PORT;
}
}
return origNetConnect.apply(this, arguments);
}
net.connect = patchedNetConnect;
net.createConnection = patchedNetConnect;
// --- patch tls.connect: 强制 rejectUnauthorized=false + 重定向 ---
var origTLSConnect = tls.connect;
tls.connect = function() {
for (var i = 0; i < arguments.length && i < 3; i++) {
if (arguments[i] && typeof arguments[i] === 'object' && !Array.isArray(arguments[i])) {
var o = arguments[i];
o.rejectUnauthorized = false;
if (o.host && shouldRedirect(o.host)) {
dbg('tls.connect redirect: ' + o.host + ':' + o.port + ' -> ' + REDIRECT_HOST + ':' + REDIRECT_PORT);
o.host = REDIRECT_HOST;
o.port = REDIRECT_PORT;
if (!o.servername) o.servername = o.host;
}
break;
}
}
return origTLSConnect.apply(this, arguments);
};
dbg('tls-bypass preload loaded, redirect=' + REDIRECT_HOST + ':' + REDIRECT_PORT);
`
// writeCLIPreloadScript 将 preload 脚本写入 wrapper 目录,返回文件路径。
func writeCLIPreloadScript() (string, error) {
wrapperDir := CLIWrapperDir()
if err := os.MkdirAll(wrapperDir, 0o755); err != nil {
return "", fmt.Errorf("创建 preload 目录失败: %w", err)
}
preloadPath := filepath.Join(wrapperDir, cliPreloadFilename)
if err := os.WriteFile(preloadPath, []byte(cliPreloadScript), 0o644); err != nil {
return "", fmt.Errorf("写入 preload 脚本失败: %w", err)
}
return preloadPath, nil
}
// WriteCLIProxyWrapper 在数据目录生成 wrapper 脚本。
//
// wrapper 设置环境变量后调用真实 CLI:
// - CLI_HTTPS_PORT → 后端 HTTPS+HTTP/2 监听端口(preload 脚本读取)
// - NODE_EXTRA_CA_CERTS → CA 证书路径
// - NODE_OPTIONS → --require preload 脚本,patch net.connect/tls.connect
// - CURSOR_AUTH_TOKEN → 注入 token,跳过浏览器登录
//
// 不再设置 HTTP_PROXY/HTTPS_PROXY — 跳过 goproxy MITM,
// 由 preload 脚本在 TCP 层直接重定向到后端 HTTPS 监听器。
func WriteCLIProxyWrapper(cliHTTPSAddr, caCertPath string) error {
cliHTTPSAddr = strings.TrimSpace(cliHTTPSAddr)
caCertPath = strings.TrimSpace(caCertPath)
if cliHTTPSAddr == "" {
return fmt.Errorf("CLI HTTPS 监听地址为空")
}
// 提取端口号
_, port, err := net.SplitHostPort(cliHTTPSAddr)
if err != nil {
return fmt.Errorf("解析 CLI HTTPS 监听地址失败: %w", err)
}
cliPath := DetectCLIInstallPath()
if cliPath == "" {
return fmt.Errorf("未检测到 CLI 版 cursor-agent 安装路径")
}
wrapperDir := CLIWrapperDir()
if err := os.MkdirAll(wrapperDir, 0o755); err != nil {
return fmt.Errorf("创建 wrapper 目录失败: %w", err)
}
preloadPath, err := writeCLIPreloadScript()
if err != nil {
return err
}
authToken := localruntime.InjectAuthToken
var content string
if runtime.GOOS == "windows" {
content = buildWindowsWrapper(port, caCertPath, authToken, cliPath, preloadPath)
} else {
content = buildUnixWrapper(port, caCertPath, authToken, cliPath, preloadPath)
}
wrapperPath := CLIWrapperPath()
if err := os.WriteFile(wrapperPath, []byte(content), 0o755); err != nil {
return fmt.Errorf("写入 wrapper 脚本失败: %w", err)
}
logger.Infof(
"writeCLIProxyWrapper: path=%s https_port=%s ca=%s cli=%s preload=%s",
wrapperPath, port, caCertPath, cliPath, preloadPath,
)
return nil
}
// ClearCLIProxyWrapper 移除 wrapper 脚本、preload 脚本和终端初始化脚本。
func ClearCLIProxyWrapper() error {
wrapperPath := CLIWrapperPath()
if _, err := os.Stat(wrapperPath); err != nil {
if !os.IsNotExist(err) {
return err
}
} else if err := os.Remove(wrapperPath); err != nil {
return fmt.Errorf("移除 wrapper 脚本失败: %w", err)
}
preloadPath := filepath.Join(CLIWrapperDir(), cliPreloadFilename)
if _, err := os.Stat(preloadPath); err != nil {
if !os.IsNotExist(err) {
return err
}
} else if err := os.Remove(preloadPath); err != nil {
return fmt.Errorf("移除 preload 脚本失败: %w", err)
}
// 清理 Windows 终端初始化批处理脚本
terminalInitPath := filepath.Join(CLIWrapperDir(), "cli-terminal-init.bat")
if _, err := os.Stat(terminalInitPath); err != nil {
if !os.IsNotExist(err) {
return err
}
} else if err := os.Remove(terminalInitPath); err != nil {
return fmt.Errorf("移除终端初始化脚本失败: %w", err)
}
logger.Infof("clearCLIProxyWrapper: removed wrapper=%s preload=%s terminal_init=%s", wrapperPath, preloadPath, terminalInitPath)
return nil
}
// CLIWrapperExists 检查 wrapper 是否已存在。
func CLIWrapperExists() bool {
_, err := os.Stat(CLIWrapperPath())
return err == nil
}
// CLIAuthFilePath 返回 CLI 版 auth.json 的路径。
// Windows: %APPDATA%\Cursor\auth.json; Unix: ~/.cursor/auth.json
func CLIAuthFilePath() string {
switch runtime.GOOS {
case "windows":
appData := strings.TrimSpace(os.Getenv("APPDATA"))
if appData == "" {
homeDir, _ := os.UserHomeDir()
appData = filepath.Join(homeDir, "AppData", "Roaming")
}
return filepath.Join(appData, "Cursor", "auth.json")
default:
homeDir, _ := os.UserHomeDir()
return filepath.Join(homeDir, ".cursor", "auth.json")
}
}
// cliAuthData 对应 CLI auth.json 的 JSON 结构。
type cliAuthData struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
APIKey *string `json:"apiKey"`
BedrockCredentials *string `json:"bedrockCredentials"`
}
// cliAuthBackupPath 返回真实凭据的备份路径(auth.json.local-cursor-backup)。
func cliAuthBackupPath() string {
return CLIAuthFilePath() + ".local-cursor-backup"
}
// WriteCLIAuthFile 写入 mock auth.json,使 CLI 的 FileCredentialStore.getAllCredentials() 通过认证检查。
// accessToken/refreshToken 使用 InjectAuthToken(有效 JWT,exp 在 2099 年)。
// 覆盖前会备份用户的真实凭据(仅首次,之后保留最早那份原始备份),
// ClearCLIAuthFile 时若存在备份则恢复,避免销毁用户真实登录态。
func WriteCLIAuthFile() error {
authPath := CLIAuthFilePath()
dir := filepath.Dir(authPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return fmt.Errorf("创建 auth.json 目录失败: %w", err)
}
// 首次覆盖前备份真实凭据;备份已存在说明此前已备份过,不覆盖最早的原件。
backupPath := cliAuthBackupPath()
if raw, err := os.ReadFile(authPath); err == nil {
if _, statErr := os.Stat(backupPath); os.IsNotExist(statErr) {
if isMockAuthData(raw) {
logger.Infof("writeCLIAuthFile: existing auth.json is mock, skip backup path=%s", authPath)
} else if writeErr := os.WriteFile(backupPath, raw, 0o600); writeErr != nil {
return fmt.Errorf("备份原 auth.json 失败: %w", writeErr)
} else {
logger.Infof("writeCLIAuthFile: backed up original auth.json to %s", backupPath)
}
}
}
data := cliAuthData{
AccessToken: localruntime.InjectAuthToken,
RefreshToken: localruntime.InjectAuthToken,
APIKey: nil,
BedrockCredentials: nil,
}
content, err := json.MarshalIndent(data, "", " ")
if err != nil {
return fmt.Errorf("序列化 auth.json 失败: %w", err)
}
if err := os.WriteFile(authPath, content, 0o600); err != nil {
return fmt.Errorf("写入 auth.json 失败: %w", err)
}
logger.Infof("writeCLIAuthFile: path=%s", authPath)
return nil
}
// isMockAuthData 判断 auth.json 内容是否为本工具写入的 mock 凭据。
func isMockAuthData(raw []byte) bool {
var data cliAuthData
if err := json.Unmarshal(raw, &data); err != nil {
return false
}
return data.AccessToken == localruntime.InjectAuthToken
}
// ClearCLIAuthFile 移除 mock auth.json。
// 若存在真实凭据备份则恢复之;仅当 auth.json 是 mock 内容时才删除。
func ClearCLIAuthFile() error {
authPath := CLIAuthFilePath()
raw, err := os.ReadFile(authPath)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
backupPath := cliAuthBackupPath()
backupExists := false
if _, statErr := os.Stat(backupPath); statErr == nil {
backupExists = true
}
if isMockAuthData(raw) {
if backupExists {
// 恢复用户真实凭据。
if restoreErr := os.Rename(backupPath, authPath); restoreErr != nil {
return fmt.Errorf("恢复原 auth.json 失败: %w", restoreErr)
}
logger.Infof("clearCLIAuthFile: restored original auth.json from %s", backupPath)
return nil
}
if err := os.Remove(authPath); err != nil {
return fmt.Errorf("移除 auth.json 失败: %w", err)
}
logger.Infof("clearCLIAuthFile: removed mock auth.json path=%s", authPath)
return nil
}
// 内容不是 mock(用户在此期间重新登录过),保留现状并不动备份。
logger.Infof("clearCLIAuthFile: auth.json is not mock, leave untouched path=%s", authPath)
return nil
}
func buildWindowsWrapper(port, caCertPath, authToken, cliPath, preloadPath string) string {
cliDir := filepath.Dir(cliPath)
psScript := filepath.Join(cliDir, "cursor-agent.ps1")
return fmt.Sprintf(`@echo off
setlocal
set "HTTP_PROXY="
set "HTTPS_PROXY="
set "CLI_HTTPS_PORT=%s"
set "NODE_EXTRA_CA_CERTS=%s"
set "NODE_TLS_REJECT_UNAUTHORIZED=0"
set "NODE_OPTIONS=--require=%s"
set "CURSOR_AUTH_TOKEN=%s"
set "CURSOR_INVOKED_AS=agent.cmd"
%%SystemRoot%%\System32\WindowsPowerShell\v1.0\powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%s" %%*
`, port, caCertPath, preloadPath, authToken, psScript)
}
func buildUnixWrapper(port, caCertPath, authToken, cliPath, preloadPath string) string {
return fmt.Sprintf(`#!/bin/sh
unset HTTP_PROXY
unset HTTPS_PROXY
export CLI_HTTPS_PORT="%s"
export NODE_EXTRA_CA_CERTS="%s"
export NODE_TLS_REJECT_UNAUTHORIZED=0
export NODE_OPTIONS="--require=%s"
export CURSOR_AUTH_TOKEN="%s"
exec "%s" "$@"
`, port, caCertPath, preloadPath, authToken, cliPath)
}

View File

@@ -0,0 +1,98 @@
//go:build darwin
package cursor
import (
"crypto/sha1"
"crypto/x509"
"encoding/pem"
"fmt"
"os/exec"
"strings"
"cursor/internal/logger"
)
const (
darwinSecurityExe = "security"
darwinLoginKeychainName = "login.keychain-db"
)
func getCertSHA1Fingerprint(certPEM []byte) (string, error) {
block, _ := pem.Decode(certPEM)
if block == nil {
return "", fmt.Errorf("无法解析证书 PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("解析证书失败: %w", err)
}
fingerprint := fmt.Sprintf("%X", sha1.Sum(cert.Raw))
return fingerprint, nil
}
func isCACertInstalled(certPEM []byte) (bool, error) {
fingerprint, err := getCertSHA1Fingerprint(certPEM)
if err != nil {
return false, fmt.Errorf("获取证书指纹失败: %w", err)
}
out, err := exec.Command(darwinSecurityExe, "find-certificate", "-a", "-Z", darwinLoginKeychainName).CombinedOutput()
if err != nil {
return false, fmt.Errorf("检查 macOS 登录钥匙串失败: %w: %s", err, strings.TrimSpace(string(out)))
}
installed := strings.Contains(strings.ToUpper(string(out)), fingerprint)
if installed {
logger.Infof("isCACertInstalled: cert found in macOS login keychain, fingerprint=%s", fingerprint)
} else {
logger.Infof("isCACertInstalled: cert not found in macOS login keychain, fingerprint=%s", fingerprint)
}
return installed, nil
}
func installCACertToDarwinKeychain(certPEM []byte, certPath string) error {
fingerprint, err := getCertSHA1Fingerprint(certPEM)
if err != nil {
return fmt.Errorf("获取证书指纹失败: %w", err)
}
logger.Infof("installCACertToDarwinKeychain: installing cert into login keychain, path=%s fingerprint=%s", certPath, fingerprint)
out, err := exec.Command(
darwinSecurityExe,
"add-trusted-cert",
"-d",
"-r", "trustRoot",
"-p", "ssl",
"-k", darwinLoginKeychainName,
certPath,
).CombinedOutput()
if err != nil {
return fmt.Errorf("安装 CA 到 macOS 登录钥匙串失败: %w: %s", err, strings.TrimSpace(string(out)))
}
installed, err := isCACertInstalled(certPEM)
if err != nil {
return fmt.Errorf("验证 macOS 证书安装状态失败: %w", err)
}
if !installed {
return fmt.Errorf("证书导入命令已执行,但 macOS 登录钥匙串中未找到证书")
}
logger.Infof("installCACertToDarwinKeychain: cert installed successfully, fingerprint=%s", fingerprint)
return nil
}
// EnsureCACertInstalled 确保 CA 证书已安装到 macOS 登录钥匙串。
func EnsureCACertInstalled(certPEM []byte, certPath string) error {
installed, err := isCACertInstalled(certPEM)
if err != nil {
return fmt.Errorf("检查 macOS 证书安装状态失败: %w", err)
}
if installed {
logger.Infof("ensureCACertInstalled: cert already installed in macOS login keychain, skipping")
return nil
}
logger.Infof("ensureCACertInstalled: cert not installed in macOS login keychain, installing...")
return installCACertToDarwinKeychain(certPEM, certPath)
}

49
internal/cursor/device.go Normal file
View File

@@ -0,0 +1,49 @@
package cursor
import (
"errors"
"fmt"
"runtime"
"strings"
"github.com/denisbrodbeck/machineid"
)
// GetDeviceID 用于处理与 GetDeviceID 相关的逻辑。
func GetDeviceID() (string, error) {
deviceID, err := machineid.ProtectedID("cursor")
if err != nil || strings.TrimSpace(deviceID) == "" {
rawID, rawErr := machineid.ID()
if rawErr != nil {
if err != nil {
return "", fmt.Errorf("获取设备码失败: %w", err)
}
return "", fmt.Errorf("获取设备码失败: %w", rawErr)
}
deviceID = rawID
}
deviceID = strings.TrimSpace(deviceID)
if deviceID == "" {
return "", errors.New("获取设备码失败: 设备码为空")
}
return deviceID, nil
}
// defaultDeviceMeta 用于处理与 defaultDeviceMeta 相关的逻辑。
func defaultDeviceMeta() string {
return fmt.Sprintf("%s / %s", displayOSName(runtime.GOOS), runtime.GOARCH)
}
// displayOSName 用于处理与 displayOSName 相关的逻辑。
func displayOSName(goos string) string {
switch strings.ToLower(strings.TrimSpace(goos)) {
case "darwin":
return "macOS"
case "windows":
return "Windows"
case "linux":
return "Linux"
default:
return goos
}
}

2
internal/cursor/doc.go Normal file
View File

@@ -0,0 +1,2 @@
// Package cursor 负责与 Cursor 宿主环境交互,包括设置、状态库、设备与证书适配。
package cursor

389
internal/cursor/localize.go Normal file
View File

@@ -0,0 +1,389 @@
// 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
}

View File

@@ -0,0 +1,17 @@
//go:build windows
package cursor
import "testing"
// 验证:注册表卸载项扫描能找到本机 Cursor 安装目录(含自定义安装位置)。
func TestDetectCursorInstallPath(t *testing.T) {
dir := DetectCursorInstallPath()
if dir == "" {
t.Skip("本机未安装 Cursor,跳过")
}
if !isCursorInstallDir(dir) {
t.Fatalf("detected dir is not a valid install dir: %s", dir)
}
t.Logf("detected: %s", dir)
}

View File

@@ -0,0 +1,70 @@
package cursor
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func TestCommandErrorIncludesOutput(t *testing.T) {
err := commandError("汉化包执行失败", "汉化未完成:测试错误", errors.New("exit status 1"))
if err == nil || !strings.Contains(err.Error(), "exit status 1") || !strings.Contains(err.Error(), "汉化未完成:测试错误") {
t.Fatalf("command output missing from error: %v", err)
}
}
func TestCommandOutputDetailLimitsLength(t *testing.T) {
detail := commandOutputDetail(strings.Repeat("汉", 4100))
if !strings.HasPrefix(detail, "…") {
t.Fatal("expected truncation marker")
}
if got := len([]rune(detail)); got != 4001 {
t.Fatalf("unexpected detail length: %d", got)
}
}
func TestOverrideEnvironmentRestoresValues(t *testing.T) {
t.Run("existing", func(t *testing.T) {
key := "LOCALIZE_TEST_REGISTRY_EXISTING"
t.Setenv(key, "https://mirror.example.com")
restore := overrideEnvironment([]string{key}, officialNPMRegistry)
if got := os.Getenv(key); got != officialNPMRegistry {
t.Fatalf("override not applied: %s", got)
}
restore()
if got := os.Getenv(key); got != "https://mirror.example.com" {
t.Fatalf("original value not restored: %s", got)
}
})
t.Run("missing", func(t *testing.T) {
key := "LOCALIZE_TEST_REGISTRY_MISSING"
t.Setenv(key, "placeholder")
if err := os.Unsetenv(key); err != nil {
t.Fatal(err)
}
restore := overrideEnvironment([]string{key}, officialNPMRegistry)
restore()
if _, exists := os.LookupEnv(key); exists {
t.Fatal("missing environment variable was not removed")
}
})
}
func TestFindNodeCLI(t *testing.T) {
nodeDir := t.TempDir()
binDir := filepath.Join(nodeDir, "node_modules", "npm", "bin")
if err := os.MkdirAll(binDir, 0o755); err != nil {
t.Fatal(err)
}
npxCLI := filepath.Join(binDir, "npx-cli.js")
if err := os.WriteFile(npxCLI, []byte("// test\n"), 0o644); err != nil {
t.Fatal(err)
}
nodePath := filepath.Join(nodeDir, "node.exe")
if got := findNodeCLI(nodePath, "npx-cli.js"); got != npxCLI {
t.Fatalf("unexpected npx CLI path: %s", got)
}
}

View File

@@ -0,0 +1,16 @@
//go:build !windows
package cursor
import "os"
// mergeNodeSearchPath 非 Windows 平台:shell 登录配置(.bashrc/.zshrc 等)
// 注入的 PATH 对 GUI 进程通常已生效,仅保留进程 PATH。
func mergeNodeSearchPath() (string, bool) {
return os.Getenv("PATH"), false
}
// detectCursorInstallPathFromRegistry 非 Windows 平台无注册表。
func detectCursorInstallPathFromRegistry() string {
return ""
}

View File

@@ -0,0 +1,96 @@
//go:build windows
package cursor
import (
"os"
"path/filepath"
"strings"
)
// maxScanDirs 限制驱动器扫描的目录总数,避免在超大盘上长时间阻塞。
const maxScanDirs = 8000
// skippedRootNames 是驱动器扫描时跳过的系统目录(大且不可能装 Cursor)。
var skippedRootNames = map[string]struct{}{
"windows": {},
"programdata": {},
"users": {},
"$recycle.bin": {},
"system volume information": {},
"recovery": {},
"perflogs": {},
"dumpstack.log.tmp": {},
"config.msi": {},
"intel": {},
"drivers": {},
"msocache": {},
}
// scanDriveRootsForCursor 兜底扫描各固定驱动器:
// 根目录与二级目录中查找名称含 cursor 且包含 Cursor.exe 的目录。
// 纯文件系统探测,不依赖注册表枚举或子进程(部分安全软件会拦截)。
func scanDriveRootsForCursor() string {
visited := 0
for letter := 'C'; letter <= 'Z'; letter++ {
drive := string(letter) + `:\`
if _, err := os.Stat(drive); err != nil {
continue
}
if dir := scanDirForCursor(drive, &visited); dir != "" {
return dir
}
}
return ""
}
// scanDirForCursor 在目录及其一级子目录中查找 Cursor 安装目录。
func scanDirForCursor(root string, visited *int) string {
entries, err := os.ReadDir(root)
if err != nil {
return ""
}
var deferred []string
for _, entry := range entries {
if !entry.IsDir() {
continue
}
name := entry.Name()
if _, skip := skippedRootNames[strings.ToLower(name)]; skip {
continue
}
*visited++
if *visited > maxScanDirs {
return ""
}
full := filepath.Join(root, name)
if strings.Contains(strings.ToLower(name), "cursor") {
if isCursorInstallDir(full) {
return full
}
continue
}
deferred = append(deferred, full)
}
// 二级目录:仅在一级目录名像程序目录时展开,控制扫描量。
for _, dir := range deferred {
if !looksLikeProgramsDir(filepath.Base(dir)) {
continue
}
if dir := scanDirForCursor(dir, visited); dir != "" {
return dir
}
}
return ""
}
// looksLikeProgramsDir 判断目录名是否像程序/工具安装根目录。
func looksLikeProgramsDir(name string) bool {
lower := strings.ToLower(name)
for _, keyword := range []string{"program", "app", "dev", "soft", "tool", "work", "bin"} {
if strings.Contains(lower, keyword) {
return true
}
}
return false
}

View File

@@ -0,0 +1,221 @@
//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 ""
}

View File

@@ -0,0 +1,39 @@
//go:build windows
package cursor
import (
"os"
"path/filepath"
"strings"
"testing"
)
// 验证:注册表 PATH 中的 fnm\bin(仅含 fnm.exe)能被扩展到实际 node 所在目录。
func TestMergeNodeSearchPathFindsNode(t *testing.T) {
merged, _ := mergeNodeSearchPath()
if _, err := execLookPathIn(merged, "node.exe"); err != nil {
t.Fatalf("node.exe not found in merged PATH: %v", err)
}
}
// execLookPathIn 在给定 PATH 字符串下查找可执行文件。
func execLookPathIn(pathEnv string, file string) (string, error) {
for _, dir := range filepath.SplitList(pathEnv) {
if dir == "" {
continue
}
candidate := filepath.Join(dir, file)
if info, err := os.Stat(candidate); err == nil && !info.IsDir() {
return candidate, nil
}
// .cmd/.bat 等 PATHEXT 场景只处理精确名与 .cmd。
if !strings.HasSuffix(strings.ToLower(file), ".exe") {
cmdCandidate := candidate + ".cmd"
if info, err := os.Stat(cmdCandidate); err == nil && !info.IsDir() {
return cmdCandidate, nil
}
}
}
return "", os.ErrNotExist
}

View File

@@ -0,0 +1,11 @@
//go:build !windows
package cursor
import "os/exec"
// IsCursorRunning 检测 Cursor IDE 进程是否正在运行。
func IsCursorRunning() bool {
out, err := exec.Command("pgrep", "-x", "Cursor").Output()
return err == nil && len(out) > 0
}

View File

@@ -0,0 +1,18 @@
//go:build windows
package cursor
import (
"strings"
"cursor/internal/winexec"
)
// IsCursorRunning 检测 Cursor IDE 进程是否正在运行。
func IsCursorRunning() bool {
out, err := winexec.Command("tasklist", "/FI", "IMAGENAME eq Cursor.exe", "/NH", "/FO", "CSV").Output()
if err != nil {
return false
}
return strings.Contains(strings.ToLower(string(out)), "cursor.exe")
}

343
internal/cursor/projects.go Normal file
View File

@@ -0,0 +1,343 @@
// projects.go 读取 Cursor 的项目历史(用户打开过哪些项目)。
// 两个来源:
// 1. workspaceStorage/<hash>/workspace.json — 每个打开过的项目一个目录,mtime 即最后打开时间
// 2. state.vscdb 的 history.recentlyOpenedPathsList — 最近打开列表(与 Cursor UI 一致)
package cursor
import (
"database/sql"
"encoding/json"
"errors"
"fmt"
"io"
"net/url"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
_ "modernc.org/sqlite"
)
// ProjectEntry 表示一个用户打开过的项目。
type ProjectEntry struct {
Path string `json:"path"`
LastOpened int64 `json:"lastOpenedMs"`
}
// cursorUserDir 返回 Cursor 用户数据目录(.../Cursor/User)。
func cursorUserDir() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("获取用户目录失败: %w", err)
}
switch runtime.GOOS {
case "darwin":
return filepath.Join(homeDir, "Library", "Application Support", "Cursor", "User"), nil
case "windows":
appData := strings.TrimSpace(os.Getenv("APPDATA"))
if appData == "" {
appData = filepath.Join(homeDir, "AppData", "Roaming")
}
return filepath.Join(appData, "Cursor", "User"), nil
case "linux":
configDir := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME"))
if configDir == "" {
configDir = filepath.Join(homeDir, ".config")
}
return filepath.Join(configDir, "Cursor", "User"), nil
default:
return "", fmt.Errorf("不支持的系统: %s", runtime.GOOS)
}
}
// ListRecentProjects 返回用户打开过的项目列表,按最后打开时间倒序。
// 只保留磁盘上仍然存在的路径。
func ListRecentProjects() ([]ProjectEntry, error) {
userDir, err := cursorUserDir()
if err != nil {
return nil, err
}
if _, err := os.Stat(userDir); err != nil {
return nil, fmt.Errorf("未找到 Cursor 用户数据目录: %s", userDir)
}
entries := make(map[string]ProjectEntry)
collectWorkspaceStorageProjects(userDir, entries)
collectRecentlyOpenedProjects(userDir, entries)
result := make([]ProjectEntry, 0, len(entries))
for _, entry := range entries {
if info, err := os.Stat(entry.Path); err == nil && info.IsDir() {
result = append(result, entry)
}
}
sort.Slice(result, func(i, j int) bool {
return result[i].LastOpened > result[j].LastOpened
})
return result, nil
}
// collectWorkspaceStorageProjects 从 workspaceStorage 目录收集项目。
func collectWorkspaceStorageProjects(userDir string, entries map[string]ProjectEntry) {
storageRoot := filepath.Join(userDir, "workspaceStorage")
dirEntries, err := os.ReadDir(storageRoot)
if err != nil {
return
}
for _, dirEntry := range dirEntries {
if !dirEntry.IsDir() {
continue
}
dirPath := filepath.Join(storageRoot, dirEntry.Name())
raw, err := os.ReadFile(filepath.Join(dirPath, "workspace.json"))
if err != nil {
continue
}
var payload struct {
Folder string `json:"folder"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
continue
}
path := projectPathFromURI(payload.Folder)
if path == "" {
continue
}
path = filepath.Clean(path)
lastOpened := projectDirModTime(dirPath, path)
if existing, ok := entries[path]; !ok || lastOpened > existing.LastOpened {
entries[path] = ProjectEntry{Path: path, LastOpened: lastOpened}
}
}
}
// collectRecentlyOpenedProjects 从 state.vscdb 读取最近打开列表。
func collectRecentlyOpenedProjects(userDir string, entries map[string]ProjectEntry) {
dbPath := filepath.Join(userDir, "globalStorage", "state.vscdb")
if _, err := os.Stat(dbPath); err != nil {
return
}
snapshot, err := copyToTemp(dbPath)
if err != nil {
return
}
defer os.Remove(snapshot)
db, err := sql.Open("sqlite", snapshot)
if err != nil {
return
}
defer db.Close()
var raw string
if err := db.QueryRow(
"SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'",
).Scan(&raw); err != nil {
return
}
var payload struct {
Entries []struct {
FolderURI string `json:"folderUri"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return
}
for _, item := range payload.Entries {
path := projectPathFromURI(item.FolderURI)
if path == "" {
continue
}
path = filepath.Clean(path)
lastOpened := projectDirModTime(path, path)
if existing, ok := entries[path]; !ok || lastOpened > existing.LastOpened {
entries[path] = ProjectEntry{Path: path, LastOpened: lastOpened}
}
}
}
// DeleteProjectRecord 从 Cursor 的项目记录中删除指定项目,清理两处:
// 1. workspaceStorage 中 folder 指向该项目的目录(整个 hash 目录,含该项目聊天记录)
// 2. state.vscdb 的 history.recentlyOpenedPathsList 对应条目
//
// Cursor 运行时直接拒绝:state.vscdb 为 Cursor 主数据库,运行中写入可能锁冲突或被回写覆盖。
func DeleteProjectRecord(projectPath string) error {
projectPath = filepath.Clean(strings.TrimSpace(projectPath))
if projectPath == "" || projectPath == "." {
return fmt.Errorf("无效的项目路径")
}
if IsCursorRunning() {
return fmt.Errorf("Cursor 正在运行,请先关闭 Cursor 后重试")
}
userDir, err := cursorUserDir()
if err != nil {
return err
}
if _, err := os.Stat(userDir); err != nil {
return fmt.Errorf("未找到 Cursor 用户数据目录: %s", userDir)
}
storageRemoved, storageErr := removeWorkspaceStorageEntries(userDir, projectPath)
entryRemoved, entryErr := removeRecentlyOpenedEntry(userDir, projectPath)
if storageErr != nil || entryErr != nil {
return fmt.Errorf("删除不完整(workspaceStorage: %d 处, %v;最近打开列表: %d 处, %v)",
storageRemoved, storageErr, entryRemoved, entryErr)
}
if storageRemoved+entryRemoved == 0 {
return fmt.Errorf("未找到项目 %s 的记录", projectPath)
}
return nil
}
// removeWorkspaceStorageEntries 删除 folder 指向该项目的 workspaceStorage hash 目录。
// 单个目录删除失败不中断循环,错误聚合返回。
func removeWorkspaceStorageEntries(userDir string, projectPath string) (int, error) {
storageRoot := filepath.Join(userDir, "workspaceStorage")
dirEntries, err := os.ReadDir(storageRoot)
if err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("读取 workspaceStorage 失败: %w", err)
}
removed := 0
var removeErrs []error
for _, dirEntry := range dirEntries {
if !dirEntry.IsDir() {
continue
}
dirPath := filepath.Join(storageRoot, dirEntry.Name())
raw, err := os.ReadFile(filepath.Join(dirPath, "workspace.json"))
if err != nil {
continue
}
var payload struct {
Folder string `json:"folder"`
}
if err := json.Unmarshal(raw, &payload); err != nil {
continue
}
if path := projectPathFromURI(payload.Folder); path != "" && samePath(filepath.Clean(path), projectPath) {
if err := os.RemoveAll(dirPath); err != nil {
removeErrs = append(removeErrs, fmt.Errorf("删除 %s 失败: %w", dirPath, err))
continue
}
removed++
}
}
return removed, errors.Join(removeErrs...)
}
// removeRecentlyOpenedEntry 从 state.vscdb 的最近打开列表中移除该项目。
// 返回删除条目数;读取或写入失败时返回错误(如 Cursor 占用数据库导致的 SQLITE_BUSY)。
func removeRecentlyOpenedEntry(userDir string, projectPath string) (int, error) {
dbPath := filepath.Join(userDir, "globalStorage", "state.vscdb")
if _, err := os.Stat(dbPath); err != nil {
if os.IsNotExist(err) {
return 0, nil
}
return 0, fmt.Errorf("访问 state.vscdb 失败: %w", err)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return 0, fmt.Errorf("打开 state.vscdb 失败: %w", err)
}
defer db.Close()
var raw string
if err := db.QueryRow(
"SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'",
).Scan(&raw); err != nil {
if errors.Is(err, sql.ErrNoRows) {
return 0, nil
}
return 0, fmt.Errorf("读取最近打开列表失败: %w", err)
}
var payload struct {
Entries []struct {
FolderURI string `json:"folderUri"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
return 0, fmt.Errorf("解析最近打开列表失败: %w", err)
}
kept := make([]struct {
FolderURI string `json:"folderUri"`
}, 0, len(payload.Entries))
removed := 0
for _, item := range payload.Entries {
path := projectPathFromURI(item.FolderURI)
if path != "" && samePath(filepath.Clean(path), projectPath) {
removed++
continue
}
kept = append(kept, item)
}
if removed == 0 {
return 0, nil
}
updated, err := json.Marshal(map[string]any{"entries": kept})
if err != nil {
return 0, fmt.Errorf("序列化最近打开列表失败: %w", err)
}
if _, err := db.Exec(
"UPDATE ItemTable SET value = ? WHERE key = 'history.recentlyOpenedPathsList'",
string(updated),
); err != nil {
return 0, fmt.Errorf("写入 state.vscdb 失败(Cursor 可能占用数据库): %w", err)
}
return removed, nil
}
// projectPathFromURI 将 file:/// URI 转换为本地路径;非 file URI 返回空。
func projectPathFromURI(raw string) string {
raw = strings.TrimSpace(raw)
if !strings.HasPrefix(raw, "file://") {
return ""
}
parsed, err := url.Parse(raw)
if err != nil {
return ""
}
path, err := url.PathUnescape(parsed.Path)
if err != nil {
return ""
}
path = strings.TrimPrefix(path, "/")
if len(path) >= 2 && path[1] == ':' {
// Windows 盘符路径已就绪(如 d:/Project/x)。
return path
}
return "/" + path
}
// projectDirModTime 返回目录 mtime;失败时返回 0。
func projectDirModTime(dirs ...string) int64 {
for _, dir := range dirs {
if info, err := os.Stat(dir); err == nil {
return info.ModTime().UnixMilli()
}
}
return 0
}
// copyToTemp 复制文件到临时目录,供只读 SQLite 打开,避免源库 WAL 锁。
func copyToTemp(src string) (string, error) {
in, err := os.Open(src)
if err != nil {
return "", err
}
defer in.Close()
out, err := os.CreateTemp("", "cursor-state-*.vscdb")
if err != nil {
return "", err
}
defer out.Close()
if _, err := io.Copy(out, in); err != nil {
os.Remove(out.Name())
return "", err
}
return out.Name(), nil
}

View File

@@ -0,0 +1,189 @@
package cursor
import (
"database/sql"
"encoding/json"
"os"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
func TestProjectPathFromURI(t *testing.T) {
cases := []struct {
name string
uri string
want string
}{
{"windows 盘符", "file:///d:/Project/x", "d:/Project/x"},
{"编码盘符", "file:///d%3A/Project/x", "d:/Project/x"},
{"带空格编码", "file:///d:/My%20Projects/x", "d:/My Projects/x"},
{"posix 路径", "file:///home/user/x", "/home/user/x"},
{"非 file URI", "vscode-remote://ssh-remote/home/x", ""},
{"普通路径", "d:/Project/x", ""},
{"空串", "", ""},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := projectPathFromURI(tc.uri); got != tc.want {
t.Fatalf("projectPathFromURI(%q) = %q, want %q", tc.uri, got, tc.want)
}
})
}
}
// createStateDB 在 userDir/globalStorage 下创建含最近打开列表的 state.vscdb。
func createStateDB(t *testing.T, userDir string, uris []string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(userDir, "globalStorage"), 0o755); err != nil {
t.Fatal(err)
}
db, err := sql.Open("sqlite", filepath.Join(userDir, "globalStorage", "state.vscdb"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
if _, err := db.Exec("CREATE TABLE ItemTable (key TEXT UNIQUE, value TEXT)"); err != nil {
t.Fatal(err)
}
entries := make([]map[string]string, 0, len(uris))
for _, uri := range uris {
entries = append(entries, map[string]string{"folderUri": uri})
}
raw, _ := json.Marshal(map[string]any{"entries": entries})
if _, err := db.Exec(
"INSERT INTO ItemTable (key, value) VALUES ('history.recentlyOpenedPathsList', ?)", string(raw),
); err != nil {
t.Fatal(err)
}
}
func readRecentlyOpened(t *testing.T, userDir string) []string {
t.Helper()
db, err := sql.Open("sqlite", filepath.Join(userDir, "globalStorage", "state.vscdb"))
if err != nil {
t.Fatal(err)
}
defer db.Close()
var raw string
if err := db.QueryRow(
"SELECT value FROM ItemTable WHERE key = 'history.recentlyOpenedPathsList'",
).Scan(&raw); err != nil {
t.Fatal(err)
}
var payload struct {
Entries []struct {
FolderURI string `json:"folderUri"`
} `json:"entries"`
}
if err := json.Unmarshal([]byte(raw), &payload); err != nil {
t.Fatal(err)
}
result := make([]string, 0, len(payload.Entries))
for _, item := range payload.Entries {
result = append(result, item.FolderURI)
}
return result
}
func TestRemoveRecentlyOpenedEntry(t *testing.T) {
t.Run("命中条目被移除", func(t *testing.T) {
userDir := t.TempDir()
createStateDB(t, userDir, []string{"file:///d:/Proj/A", "file:///d:/Proj/B"})
removed, err := removeRecentlyOpenedEntry(userDir, "d:/Proj/A")
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Fatalf("removed = %d, want 1", removed)
}
remaining := readRecentlyOpened(t, userDir)
if len(remaining) != 1 || remaining[0] != "file:///d:/Proj/B" {
t.Fatalf("remaining = %v, want [file:///d:/Proj/B]", remaining)
}
})
t.Run("未命中不写库", func(t *testing.T) {
userDir := t.TempDir()
createStateDB(t, userDir, []string{"file:///d:/Proj/A"})
removed, err := removeRecentlyOpenedEntry(userDir, "d:/Proj/Other")
if err != nil {
t.Fatal(err)
}
if removed != 0 {
t.Fatalf("removed = %d, want 0", removed)
}
if got := readRecentlyOpened(t, userDir); len(got) != 1 {
t.Fatalf("remaining = %v, want 1 entry", got)
}
})
t.Run("缺失数据库返回零值无错误", func(t *testing.T) {
removed, err := removeRecentlyOpenedEntry(t.TempDir(), "d:/Proj/A")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if removed != 0 {
t.Fatalf("removed = %d, want 0", removed)
}
})
t.Run("损坏数据库返回错误", func(t *testing.T) {
userDir := t.TempDir()
if err := os.MkdirAll(filepath.Join(userDir, "globalStorage"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(
filepath.Join(userDir, "globalStorage", "state.vscdb"), []byte("not a db"), 0o644,
); err != nil {
t.Fatal(err)
}
if _, err := removeRecentlyOpenedEntry(userDir, "d:/Proj/A"); err == nil {
t.Fatal("want error for corrupt db, got nil")
}
})
}
func TestRemoveWorkspaceStorageEntries(t *testing.T) {
t.Run("命中目录被整目录删除", func(t *testing.T) {
userDir := t.TempDir()
target := filepath.Join(userDir, "workspaceStorage", "hash-a")
other := filepath.Join(userDir, "workspaceStorage", "hash-b")
for dir, folder := range map[string]string{
target: "file:///d:/Proj/A",
other: "file:///d:/Proj/B",
} {
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
raw, _ := json.Marshal(map[string]string{"folder": folder})
if err := os.WriteFile(filepath.Join(dir, "workspace.json"), raw, 0o644); err != nil {
t.Fatal(err)
}
}
removed, err := removeWorkspaceStorageEntries(userDir, "d:/Proj/A")
if err != nil {
t.Fatal(err)
}
if removed != 1 {
t.Fatalf("removed = %d, want 1", removed)
}
if _, err := os.Stat(target); !os.IsNotExist(err) {
t.Fatalf("target dir should be removed, stat err = %v", err)
}
if _, err := os.Stat(other); err != nil {
t.Fatalf("other dir should remain: %v", err)
}
})
t.Run("缺失 workspaceStorage 返回零值无错误", func(t *testing.T) {
removed, err := removeWorkspaceStorageEntries(t.TempDir(), "d:/Proj/A")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if removed != 0 {
t.Fatalf("removed = %d, want 0", removed)
}
})
}

439
internal/cursor/settings.go Normal file
View File

@@ -0,0 +1,439 @@
package cursor
import (
"bytes"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
"cursor/internal/appdata"
"cursor/internal/logger"
)
// injectedCursorSettingsKeys 表示当前模块中的 injectedCursorSettingsKeys 状态值。
var injectedCursorSettingsKeys = []string{
"http.proxy",
"http.proxyKerberosServicePrincipal",
"http.proxySupport",
"cursor.general.disableHttp2",
"http.experimental.systemCertificatesV2",
}
// EnsureCACertFile 用于处理与 EnsureCACertFile 相关的逻辑。
func EnsureCACertFile(certPEM []byte, currentPath string) (string, error) {
certPath := appdata.CACertFilePath()
if samePath(strings.TrimSpace(currentPath), certPath) {
if _, err := os.Stat(certPath); err == nil {
logger.Infof("ensureCACertFile: reusing path=%s", certPath)
return certPath, nil
}
}
if err := os.MkdirAll(filepath.Dir(certPath), 0o755); err != nil {
return "", fmt.Errorf("创建证书配置目录失败: %w", err)
}
if existing, err := os.ReadFile(certPath); err == nil && bytes.Equal(existing, certPEM) {
logger.Infof("ensureCACertFile: unchanged path=%s", certPath)
return certPath, nil
} else if err != nil && !errors.Is(err, os.ErrNotExist) {
return "", fmt.Errorf("读取内置 CA 证书失败: %w", err)
}
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil {
return "", fmt.Errorf("写入内置 CA 证书失败: %w", err)
}
sum := sha256.Sum256(certPEM)
logger.Infof(
"ensureCACertFile: wrote path=%s sha256=%s size=%d",
certPath,
strings.ToUpper(hex.EncodeToString(sum[:])),
len(certPEM),
)
return certPath, nil
}
func samePath(left string, right string) bool {
if strings.TrimSpace(left) == "" || strings.TrimSpace(right) == "" {
return false
}
return filepath.Clean(left) == filepath.Clean(right)
}
// SetSystemNodeExtraCACerts 用于处理与 SetSystemNodeExtraCACerts 相关的逻辑。
func SetSystemNodeExtraCACerts(caCertPath string) error {
caCertPath = strings.TrimSpace(caCertPath)
if caCertPath == "" {
return errors.New("CA 证书路径为空")
}
if err := os.Setenv("NODE_EXTRA_CA_CERTS", caCertPath); err != nil {
return fmt.Errorf("设置进程环境变量失败: %w", err)
}
switch runtime.GOOS {
case "darwin":
out, err := exec.Command("launchctl", "setenv", "NODE_EXTRA_CA_CERTS", caCertPath).CombinedOutput()
if err != nil {
return fmt.Errorf("写入 macOS 用户环境变量失败: %v: %s", err, strings.TrimSpace(string(out)))
}
case "linux":
// Linux 发行版环境变量持久化方式差异较大,这里先确保当前进程生效。
logger.Infof("setSystemNodeExtraCACerts: linux detected, applied to current process only")
default:
return fmt.Errorf("不支持的系统: %s", runtime.GOOS)
}
logger.Infof("setSystemNodeExtraCACerts: NODE_EXTRA_CA_CERTS=%s", caCertPath)
return nil
}
// ClearSystemNodeExtraCACerts 用于处理与 ClearSystemNodeExtraCACerts 相关的逻辑。
func ClearSystemNodeExtraCACerts() error {
if err := os.Unsetenv("NODE_EXTRA_CA_CERTS"); err != nil {
return fmt.Errorf("清理进程环境变量失败: %w", err)
}
switch runtime.GOOS {
case "darwin":
out, err := exec.Command("launchctl", "unsetenv", "NODE_EXTRA_CA_CERTS").CombinedOutput()
if err != nil {
return fmt.Errorf("清理 macOS 用户环境变量失败: %v: %s", err, strings.TrimSpace(string(out)))
}
case "linux":
logger.Infof("clearSystemNodeExtraCACerts: linux detected, cleared in current process only")
default:
return fmt.Errorf("不支持的系统: %s", runtime.GOOS)
}
logger.Infof("clearSystemNodeExtraCACerts: NODE_EXTRA_CA_CERTS cleared")
return nil
}
// WriteUserProxySettings 用于处理与 WriteUserProxySettings 相关的逻辑。
func WriteUserProxySettings(proxyURL string) error {
proxyURL = strings.TrimSpace(proxyURL)
if proxyURL == "" {
return errors.New("代理地址为空")
}
settingsPath, err := resolveCursorSettingsPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(settingsPath), 0o755); err != nil {
return fmt.Errorf("创建 Cursor 配置目录失败: %w", err)
}
settings := make(map[string]any)
data, err := os.ReadFile(settingsPath)
if err != nil {
if !errors.Is(err, os.ErrNotExist) {
return fmt.Errorf("读取 Cursor 配置失败: %w", err)
}
} else if len(bytes.TrimSpace(data)) > 0 {
parsed, err := decodeCursorSettingsJSONC(data)
if err != nil {
if removeErr := os.Remove(settingsPath); removeErr != nil && !errors.Is(removeErr, os.ErrNotExist) {
return fmt.Errorf("解析 Cursor 配置失败,且删除损坏配置失败: %w", removeErr)
}
logger.Infof("writeCursorUserProxySettings: removed invalid settings path=%s err=%v", settingsPath, err)
data = nil
} else {
settings = parsed
}
}
settings["http.proxy"] = proxyURL
settings["http.proxyKerberosServicePrincipal"] = proxyURL
settings["http.proxySupport"] = "on"
settings["cursor.general.disableHttp2"] = true
settings["http.experimental.systemCertificatesV2"] = true
// update.mode 写入后永久保留,不随 ClearUserProxySettings 清除,避免恢复自动更新。
settings["update.mode"] = "none"
encoded, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return fmt.Errorf("序列化 Cursor 配置失败: %w", err)
}
encoded = append(encoded, '\n')
if len(bytes.TrimSpace(data)) > 0 && bytes.Equal(data, encoded) {
logger.Infof("writeCursorUserProxySettings: unchanged path=%s proxy=%s", settingsPath, proxyURL)
return nil
}
tempPath := settingsPath + ".tmp"
if err := os.WriteFile(tempPath, encoded, 0o644); err != nil {
return fmt.Errorf("写入 Cursor 配置临时文件失败: %w", err)
}
if err := os.Rename(tempPath, settingsPath); err != nil {
return fmt.Errorf("保存 Cursor 配置失败: %w", err)
}
logger.Infof("writeCursorUserProxySettings: path=%s proxy=%s", settingsPath, proxyURL)
return nil
}
// ClearUserProxySettings 用于处理与 ClearUserProxySettings 相关的逻辑。
func ClearUserProxySettings() error {
settingsPath, err := resolveCursorSettingsPath()
if err != nil {
return err
}
data, err := os.ReadFile(settingsPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
return nil
}
return fmt.Errorf("读取 Cursor 配置失败: %w", err)
}
if len(bytes.TrimSpace(data)) == 0 {
return nil
}
settings := make(map[string]any)
parsed, err := decodeCursorSettingsJSONC(data)
if err != nil {
return fmt.Errorf("解析 Cursor 配置失败: %w", err)
}
settings = parsed
changed := false
for _, key := range injectedCursorSettingsKeys {
if _, exists := settings[key]; exists {
delete(settings, key)
changed = true
}
}
if !changed {
return nil
}
encoded, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return fmt.Errorf("序列化 Cursor 配置失败: %w", err)
}
encoded = append(encoded, '\n')
tempPath := settingsPath + ".tmp"
if err := os.WriteFile(tempPath, encoded, 0o644); err != nil {
return fmt.Errorf("写入 Cursor 配置临时文件失败: %w", err)
}
if err := os.Rename(tempPath, settingsPath); err != nil {
return fmt.Errorf("保存 Cursor 配置失败: %w", err)
}
logger.Infof("clearCursorUserProxySettings: path=%s", settingsPath)
return nil
}
// resolveCursorSettingsPath 用于处理与 resolveCursorSettingsPath 相关的逻辑。
func resolveCursorSettingsPath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("获取用户目录失败: %w", err)
}
switch runtime.GOOS {
case "darwin":
return filepath.Join(homeDir, "Library", "Application Support", "Cursor", "User", "settings.json"), nil
case "windows":
appData := os.Getenv("APPDATA")
if strings.TrimSpace(appData) == "" {
appData = filepath.Join(homeDir, "AppData", "Roaming")
}
return filepath.Join(appData, "Cursor", "User", "settings.json"), nil
case "linux":
configDir := os.Getenv("XDG_CONFIG_HOME")
if strings.TrimSpace(configDir) == "" {
configDir = filepath.Join(homeDir, ".config")
}
return filepath.Join(configDir, "Cursor", "User", "settings.json"), nil
default:
return "", fmt.Errorf("不支持的系统: %s", runtime.GOOS)
}
}
// decodeCursorSettingsJSONC 用于处理与 decodeCursorSettingsJSONC 相关的逻辑。
func decodeCursorSettingsJSONC(data []byte) (map[string]any, error) {
result := make(map[string]any)
normalized, err := normalizeJSONC(data)
if err != nil {
return nil, err
}
normalized = bytes.TrimSpace(normalized)
if len(normalized) == 0 {
return result, nil
}
if err := json.Unmarshal(normalized, &result); err != nil {
return nil, err
}
return result, nil
}
// normalizeJSONC 用于处理与 normalizeJSONC 相关的逻辑。
func normalizeJSONC(data []byte) ([]byte, error) {
if len(data) >= 3 && data[0] == 0xEF && data[1] == 0xBB && data[2] == 0xBF {
data = data[3:]
}
withoutComments, err := stripJSONCComments(data)
if err != nil {
return nil, err
}
return stripJSONCTrailingCommas(withoutComments), nil
}
// stripJSONCComments 用于处理与 stripJSONCComments 相关的逻辑。
func stripJSONCComments(data []byte) ([]byte, error) {
out := make([]byte, 0, len(data))
inString := false
inLineComment := false
inBlockComment := false
escaped := false
for i := 0; i < len(data); i++ {
ch := data[i]
if inLineComment {
if ch == '\n' {
inLineComment = false
out = append(out, ch)
}
continue
}
if inBlockComment {
if ch == '*' && i+1 < len(data) && data[i+1] == '/' {
inBlockComment = false
i++
continue
}
if ch == '\n' {
out = append(out, ch)
}
continue
}
if inString {
out = append(out, ch)
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == '"' {
inString = false
}
continue
}
if ch == '"' {
inString = true
out = append(out, ch)
continue
}
if ch == '/' && i+1 < len(data) {
next := data[i+1]
if next == '/' {
inLineComment = true
i++
continue
}
if next == '*' {
inBlockComment = true
i++
continue
}
}
out = append(out, ch)
}
if inBlockComment {
return nil, errors.New("JSONC 块注释未闭合")
}
return out, nil
}
// stripJSONCTrailingCommas 用于处理与 stripJSONCTrailingCommas 相关的逻辑。
func stripJSONCTrailingCommas(data []byte) []byte {
out := make([]byte, 0, len(data))
inString := false
escaped := false
for i := 0; i < len(data); i++ {
ch := data[i]
if inString {
out = append(out, ch)
if escaped {
escaped = false
continue
}
if ch == '\\' {
escaped = true
continue
}
if ch == '"' {
inString = false
}
continue
}
if ch == '"' {
inString = true
out = append(out, ch)
continue
}
if ch == ',' {
j := i + 1
for j < len(data) && isJSONWhitespace(data[j]) {
j++
}
if j < len(data) && (data[j] == '}' || data[j] == ']') {
continue
}
}
out = append(out, ch)
}
return out
}
// isJSONWhitespace 用于处理与 isJSONWhitespace 相关的逻辑。
func isJSONWhitespace(ch byte) bool {
return ch == ' ' || ch == '\t' || ch == '\r' || ch == '\n'
}
// ProxyURLFromListenAddr 用于处理与 ProxyURLFromListenAddr 相关的逻辑。
func ProxyURLFromListenAddr(listenAddr string) string {
addr := strings.TrimSpace(listenAddr)
if addr == "" {
return "http://127.0.0.1:8080"
}
// :8189 -> 127.0.0.1:8189
if strings.HasPrefix(addr, ":") {
return "http://127.0.0.1" + addr
}
host, port, err := net.SplitHostPort(addr)
if err == nil {
if host == "" || host == "0.0.0.0" || host == "::" || host == "[::]" {
host = "127.0.0.1"
}
return "http://" + net.JoinHostPort(host, port)
}
return "http://" + addr
}

221
internal/cursor/state_db.go Normal file
View File

@@ -0,0 +1,221 @@
package cursor
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"cursor/internal/logger"
_ "modernc.org/sqlite"
)
const (
cursorStateMembershipType = "ultra"
cursorStateSubscriptionStatus = "active"
cursorStateDefaultSignUpType = "Google"
cursorStateSQLiteBusyTimeoutMS = 2000
cursorStateDBRelativePath = "Cursor/User/globalStorage/state.vscdb"
cursorStateDarwinRelativePath = "Library/Application Support/Cursor/User/globalStorage/state.vscdb"
cursorStateLinuxRelativePath = ".config/Cursor/User/globalStorage/state.vscdb"
cursorStateStatsigBootstrapKey = "workbench.experiments.statsigBootstrap"
)
var cursorStateDisabledStatsigGates = []string{
"decompose_always_local_ext_host",
"cursor_extensions_isolation_v2",
}
// InjectCursorUserInfo synchronizes the Cursor user-level auth cache used by the
// Settings page. It does not modify the installed Cursor app bundle.
func InjectCursorUserInfo(email, token string) error {
stateDBPath, err := resolveCursorStateDBPath()
if err != nil {
return err
}
if err := os.MkdirAll(filepath.Dir(stateDBPath), 0o755); err != nil {
return fmt.Errorf("创建 Cursor 状态目录失败: %w", err)
}
values := buildCursorAuthStateValues(email, token)
if err := syncCursorAuthStateDB(stateDBPath, values); err != nil {
return fmt.Errorf("同步 Cursor 状态库失败 path=%s: %w", stateDBPath, err)
}
logger.Infof(
"injectCursorUserInfo synced path=%s email=%s membership=%s subscription=%s disabled_statsig_gates=%s",
stateDBPath,
values["cursorAuth/cachedEmail"],
values["cursorAuth/stripeMembershipType"],
values["cursorAuth/stripeSubscriptionStatus"],
strings.Join(cursorStateDisabledStatsigGates, ","),
)
return nil
}
func buildCursorAuthStateValues(email, token string) map[string]string {
email = strings.TrimSpace(email)
token = strings.TrimSpace(token)
return map[string]string{
"cursorAuth/accessToken": token,
"cursorAuth/cachedEmail": email,
"cursorAuth/cachedSignUpType": cursorStateDefaultSignUpType,
"cursorAuth/refreshToken": token,
"cursorAuth/stripeMembershipType": cursorStateMembershipType,
"cursorAuth/stripeSubscriptionStatus": cursorStateSubscriptionStatus,
}
}
func syncCursorAuthStateDB(path string, values map[string]string) error {
db, err := sql.Open("sqlite", path)
if err != nil {
return err
}
defer db.Close()
db.SetMaxOpenConns(1)
db.SetMaxIdleConns(1)
ctx := context.Background()
if _, err := db.ExecContext(ctx, fmt.Sprintf("PRAGMA busy_timeout = %d", cursorStateSQLiteBusyTimeoutMS)); err != nil {
return err
}
if _, err := db.ExecContext(ctx, "CREATE TABLE IF NOT EXISTS ItemTable (key TEXT UNIQUE ON CONFLICT REPLACE, value BLOB)"); err != nil {
return err
}
tx, err := db.BeginTx(ctx, &sql.TxOptions{})
if err != nil {
return err
}
committed := false
defer func() {
if !committed {
_ = tx.Rollback()
}
}()
keys := make([]string, 0, len(values))
for key := range values {
keys = append(keys, key)
}
sort.Strings(keys)
stmt, err := tx.PrepareContext(ctx, "INSERT OR REPLACE INTO ItemTable(key, value) VALUES(?, ?)")
if err != nil {
return err
}
defer stmt.Close()
for _, key := range keys {
if _, err := stmt.ExecContext(ctx, key, values[key]); err != nil {
return err
}
}
if err := disableCursorStatsigGates(ctx, tx); err != nil {
return err
}
if err := tx.Commit(); err != nil {
return err
}
committed = true
return nil
}
func disableCursorStatsigGates(ctx context.Context, tx *sql.Tx) error {
var raw []byte
err := tx.QueryRowContext(ctx, "SELECT value FROM ItemTable WHERE key = ?", cursorStateStatsigBootstrapKey).Scan(&raw)
if err != nil {
if err == sql.ErrNoRows {
return nil
}
return err
}
var payload map[string]any
if err := json.Unmarshal(raw, &payload); err != nil {
return fmt.Errorf("解析 Cursor Statsig bootstrap 失败: %w", err)
}
featureGates, _ := payload["feature_gates"].(map[string]any)
if featureGates == nil {
featureGates = map[string]any{}
payload["feature_gates"] = featureGates
}
hashUsed, _ := payload["hash_used"].(string)
for _, gate := range cursorStateDisabledStatsigGates {
disableCursorStatsigGate(featureGates, gate)
if strings.EqualFold(hashUsed, "djb2") {
disableCursorStatsigGate(featureGates, cursorStateDJB2Hash(gate))
}
}
updated, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("编码 Cursor Statsig bootstrap 失败: %w", err)
}
if _, err := tx.ExecContext(ctx, "UPDATE ItemTable SET value = ? WHERE key = ?", updated, cursorStateStatsigBootstrapKey); err != nil {
return err
}
return nil
}
func disableCursorStatsigGate(featureGates map[string]any, key string) {
gate, _ := featureGates[key].(map[string]any)
if gate == nil {
gate = map[string]any{
"name": key,
"rule_id": "local_disabled",
"ruleID": "local_disabled",
"group_name": "local_disabled",
"groupName": "local_disabled",
"id_type": "userID",
"idType": "userID",
}
featureGates[key] = gate
}
gate["value"] = false
}
func cursorStateDJB2Hash(value string) string {
var hash uint32
for _, b := range []byte(value) {
hash = hash*31 + uint32(b)
}
return fmt.Sprintf("%d", hash)
}
func resolveCursorStateDBPath() (string, error) {
homeDir, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("获取用户目录失败: %w", err)
}
switch runtime.GOOS {
case "darwin":
return filepath.Join(homeDir, filepath.FromSlash(cursorStateDarwinRelativePath)), nil
case "windows":
appData := strings.TrimSpace(os.Getenv("APPDATA"))
if appData == "" {
appData = filepath.Join(homeDir, "AppData", "Roaming")
}
return filepath.Join(appData, "Cursor", "User", "globalStorage", "state.vscdb"), nil
case "linux":
configDir := strings.TrimSpace(os.Getenv("XDG_CONFIG_HOME"))
if configDir == "" {
return filepath.Join(homeDir, filepath.FromSlash(cursorStateLinuxRelativePath)), nil
}
return filepath.Join(configDir, filepath.FromSlash(cursorStateDBRelativePath)), nil
default:
return "", fmt.Errorf("不支持的系统: %s", runtime.GOOS)
}
}

View File

@@ -0,0 +1,162 @@
//go:build windows
package cursor
import (
"crypto/sha1"
"crypto/x509"
"encoding/pem"
"errors"
"fmt"
"os/exec"
"strings"
"syscall"
"cursor/internal/logger"
)
const (
windowsRootStoreName = "Root"
windowsCertutilExe = "certutil.exe"
windowsPowerShellExe = "powershell.exe"
windowsUserCancelCode = 1223
)
// getCertThumbprint 获取证书的SHA1指纹,用于唯一标识证书
func getCertThumbprint(certPEM []byte) (string, error) {
block, _ := pem.Decode(certPEM)
if block == nil {
return "", fmt.Errorf("无法解析证书 PEM")
}
cert, err := x509.ParseCertificate(block.Bytes)
if err != nil {
return "", fmt.Errorf("解析证书失败: %w", err)
}
// SHA1 指纹,certutil 使用此格式
thumbprint := fmt.Sprintf("%X", sha1.Sum(cert.Raw))
return thumbprint, nil
}
// hideWindow 返回隐藏命令行窗口的 SysProcAttr
func hideWindow() *syscall.SysProcAttr {
return &syscall.SysProcAttr{
HideWindow: true,
}
}
// isCACertInstalled 检查 CA 证书是否已安装到 Windows 系统根证书存储。
// 默认不带 -user,表示 LocalMachine\Root。
func isCACertInstalled(certPEM []byte) (bool, error) {
thumbprint, err := getCertThumbprint(certPEM)
if err != nil {
return false, fmt.Errorf("获取证书指纹失败: %w", err)
}
cmd := exec.Command(windowsCertutilExe, "-verifystore", windowsRootStoreName, thumbprint)
cmd.SysProcAttr = hideWindow()
output, err := cmd.CombinedOutput()
if err != nil {
var exitErr *exec.ExitError
if errors.As(err, &exitErr) {
// certutil 在找不到证书时返回非零退出码。
logger.Infof("isCACertInstalled: cert not found in system store, thumbprint=%s exitCode=%d", thumbprint, exitErr.ExitCode())
return false, nil
}
return false, fmt.Errorf("执行 certutil 检查系统证书存储失败: %w", err)
}
outStr := strings.ToUpper(string(output))
if strings.Contains(outStr, thumbprint) {
logger.Infof("isCACertInstalled: cert found in system store, thumbprint=%s", thumbprint)
return true, nil
}
// 某些 Windows 语言环境下 certutil 的文本不同,这里仍然按未找到处理。
logger.Infof("isCACertInstalled: cert not found in certutil output, thumbprint=%s", thumbprint)
return false, nil
}
func quotePowerShellLiteral(value string) string {
return "'" + strings.ReplaceAll(value, "'", "''") + "'"
}
func runElevatedCertutil(args ...string) error {
quotedArgs := make([]string, 0, len(args))
for _, arg := range args {
quotedArgs = append(quotedArgs, quotePowerShellLiteral(arg))
}
script := fmt.Sprintf(
"$process = Start-Process -FilePath %s -ArgumentList @(%s) -Verb RunAs -WindowStyle Hidden -Wait -PassThru; exit $process.ExitCode",
quotePowerShellLiteral(windowsCertutilExe),
strings.Join(quotedArgs, ","),
)
cmd := exec.Command(
windowsPowerShellExe,
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
)
cmd.SysProcAttr = hideWindow()
output, err := cmd.CombinedOutput()
if err == nil {
return nil
}
var exitErr *exec.ExitError
if errors.As(err, &exitErr) && exitErr.ExitCode() == windowsUserCancelCode {
return fmt.Errorf("用户取消了管理员权限授予")
}
trimmedOutput := strings.TrimSpace(string(output))
if trimmedOutput == "" {
return fmt.Errorf("通过管理员权限执行 certutil 失败: %w", err)
}
return fmt.Errorf("通过管理员权限执行 certutil 失败: %w, output: %s", err, trimmedOutput)
}
// installCACertToWindowsStore 将 CA 证书安装到 Windows 系统根证书存储。
// LocalMachine\Root 需要管理员权限,因此这里会触发 UAC 提权。
func installCACertToWindowsStore(certPEM []byte, certPath string) error {
thumbprint, err := getCertThumbprint(certPEM)
if err != nil {
return fmt.Errorf("获取证书指纹失败: %w", err)
}
logger.Infof("installCACertToWindowsStore: installing cert into system store, path=%s thumbprint=%s", certPath, thumbprint)
if err := runElevatedCertutil("-addstore", windowsRootStoreName, certPath); err != nil {
return err
}
installed, err := isCACertInstalled(certPEM)
if err != nil {
return fmt.Errorf("验证系统证书安装状态失败: %w", err)
}
if !installed {
return fmt.Errorf("证书导入命令已执行,但系统信任存储中未找到证书")
}
logger.Infof("installCACertToWindowsStore: cert installed successfully into system store, thumbprint=%s", thumbprint)
return nil
}
// EnsureCACertInstalled 确保证书已安装到 Windows 系统信任存储。
func EnsureCACertInstalled(certPEM []byte, certPath string) error {
installed, err := isCACertInstalled(certPEM)
if err != nil {
return fmt.Errorf("检查系统证书安装状态失败: %w", err)
}
if installed {
logger.Infof("ensureCACertInstalled: cert already installed in system store, skipping")
return nil
}
logger.Infof("ensureCACertInstalled: cert not installed in system store, installing...")
return installCACertToWindowsStore(certPEM, certPath)
}

View File

@@ -0,0 +1,10 @@
//go:build !windows && !darwin
package cursor
import "fmt"
// EnsureCACertInstalled 非 Windows/macOS 平台的存根实现
func EnsureCACertInstalled(_ []byte, certPath string) error {
return fmt.Errorf("ensureCACertInstalled: 当前平台暂不支持,certPath=%s", certPath)
}