From 1f98ce4c1ed8149d71c3d3f66f65ec79fa59c369 Mon Sep 17 00:00:00 2001 From: hanafish <1106510024@qq.com> Date: Fri, 7 Aug 2026 22:35:53 +0800 Subject: [PATCH] fix(cursor): centralize platform-aware Cursor storage path resolution --- .../crates/agent-cli/src/cursor/plugins.rs | 44 +- src-tauri/crates/app-paths/src/cursor.rs | 409 ++++++++++++++++++ src-tauri/crates/app-paths/src/lib.rs | 6 +- .../src/sources/cursor_ide/io.rs | 41 +- 4 files changed, 442 insertions(+), 58 deletions(-) create mode 100644 src-tauri/crates/app-paths/src/cursor.rs diff --git a/src-tauri/crates/agent-cli/src/cursor/plugins.rs b/src-tauri/crates/agent-cli/src/cursor/plugins.rs index 9828135d88..0595abf54e 100644 --- a/src-tauri/crates/agent-cli/src/cursor/plugins.rs +++ b/src-tauri/crates/agent-cli/src/cursor/plugins.rs @@ -101,28 +101,6 @@ pub struct CursorPluginInfo { pub logo_path: Option, } -// ── Path helpers ── - -fn real_user_db() -> PathBuf { - let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/_unknown".to_string()); - PathBuf::from(home) - .join("Library") - .join("Application Support") - .join("Cursor") - .join("User") - .join("globalStorage") - .join("state.vscdb") -} - -fn plugins_cache_dir() -> PathBuf { - let home = std::env::var("HOME").unwrap_or_else(|_| "/Users/_unknown".to_string()); - PathBuf::from(home) - .join(".cursor") - .join("plugins") - .join("cache") - .join("cursor-public") -} - // ── SQLite reader ── /// Read all installed plugin numeric IDs from `state.vscdb`. Returns a set of @@ -341,7 +319,16 @@ fn read_hooks(plugin_dir: &Path) -> Vec { /// Never returns an error for the "not installed" state — that is a valid /// condition for users who don't have Cursor. pub fn list_installed_plugins() -> Result, String> { - let db_path = real_user_db(); + // Storage paths come from the canonical platform-aware resolver. A typed + // unavailability (no resolvable home/config root) degrades to the same + // "Cursor not installed" empty state — never a fabricated path. + let db_path = match app_paths::cursor::state_db_path() { + Ok(path) => path, + Err(err) => { + debug!(error = %err, "Cursor storage paths unavailable — treating as not installed"); + return Ok(Vec::new()); + } + }; if !db_path.exists() { debug!("Cursor state.vscdb not found — Cursor not installed"); return Ok(Vec::new()); @@ -358,7 +345,16 @@ pub fn list_installed_plugins() -> Result, String> { "installed plugin IDs read from state.vscdb" ); - let cache_dir = plugins_cache_dir(); + let cache_dir = match app_paths::cursor::plugins_cache_dir() { + Ok(dir) => dir, + Err(err) => { + warn!( + error = %err, + "Cursor plugin cache dir unavailable — cannot resolve installed plugin metadata" + ); + return Ok(Vec::new()); + } + }; let cached = discover_cached_plugins(&cache_dir); // installed_ids are numeric marketplace IDs (e.g. "657", "6392"). diff --git a/src-tauri/crates/app-paths/src/cursor.rs b/src-tauri/crates/app-paths/src/cursor.rs new file mode 100644 index 0000000000..d23ba8853c --- /dev/null +++ b/src-tauri/crates/app-paths/src/cursor.rs @@ -0,0 +1,409 @@ +//! Canonical resolver for the Cursor app's own on-disk storage locations. +//! +//! Single source of truth for where the Cursor IDE / CLI keep their data +//! (ORGII only ever reads these locations): +//! +//! - **IDE user data** hangs off the platform config root — macOS +//! `~/Library/Application Support/Cursor/`, Linux `$XDG_CONFIG_HOME/Cursor/` +//! (default `~/.config/Cursor/`), Windows `%APPDATA%\Cursor\`. +//! - **CLI / plugin data** lives in the home-anchored dotdir `~/.cursor/` on +//! every platform. +//! +//! Not to be confused with the ORGII-managed Cursor CLI profile helpers in the +//! crate root (`cursor_config_dir`, `cursor_cli_profile_dir`, ...), which +//! resolve ORGII-owned directories under `~/.orgii/`. +//! +//! ## Identity isolation +//! +//! Every resolver honors the `ORGII_EXTERNAL_HISTORY_HOME` override exactly +//! like the crate root's `external_history_*` family: when the override is +//! set, paths resolve deterministically beneath the override home and the real +//! user's `$HOME` / `$XDG_CONFIG_HOME` / `%APPDATA%` environment is never +//! consulted, so a secondary dev profile cannot discover the primary user's +//! Cursor state. +//! +//! ## Unavailability +//! +//! When neither the override nor a usable home/config root exists, resolvers +//! return [`CursorPathsUnavailable`] instead of fabricating a path. Callers +//! decide how to degrade — for read-only discovery this is equivalent to +//! "Cursor is not installed". + +use std::path::{Path, PathBuf}; + +/// No home / platform-config root exists to anchor Cursor storage paths. +/// +/// Practically: `ORGII_EXTERNAL_HISTORY_HOME` is unset, `dirs::home_dir()` +/// failed, and (on Linux/Windows) `$XDG_CONFIG_HOME` / `%APPDATA%` are unset +/// or unusable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CursorPathsUnavailable; + +impl std::fmt::Display for CursorPathsUnavailable { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str( + "no home or platform config directory is available to resolve Cursor storage paths", + ) + } +} + +impl std::error::Error for CursorPathsUnavailable {} + +// ── Public API (process environment + current platform) ── + +/// Cursor IDE's `User/globalStorage` directory for the current platform. +/// +/// Does not check existence — callers join a filename and test that. +pub fn global_storage_dir() -> Result { + CursorEnv::from_process().global_storage_dir(current_platform()) +} + +/// Cursor's global key-value store: `/state.vscdb`. +/// +/// Does not check existence. +pub fn state_db_path() -> Result { + CursorEnv::from_process().state_db_path(current_platform()) +} + +/// Cursor's conversation index (newer builds), stored next to `state.vscdb`: +/// `/conversation-search.db`. +/// +/// Does not check existence — older Cursor builds predate this file. +pub fn conversation_index_db_path() -> Result { + CursorEnv::from_process().conversation_index_db_path(current_platform()) +} + +/// Cursor's marketplace plugin cache: `~/.cursor/plugins/cache/cursor-public/`. +/// +/// Layout: one `{slug}/{hash}/` directory per downloaded plugin. Does not +/// check existence. +pub fn plugins_cache_dir() -> Result { + CursorEnv::from_process().plugins_cache_dir() +} + +// ── Pure resolver core ── + +/// OS flavor, split from `cfg` blocks so the full platform matrix stays +/// unit-testable on any host. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Platform { + MacOs, + /// Linux plus any other XDG-style unix. + Linux, + Windows, +} + +fn current_platform() -> Platform { + if cfg!(target_os = "macos") { + Platform::MacOs + } else if cfg!(windows) { + Platform::Windows + } else { + Platform::Linux + } +} + +/// Environment inputs that determine Cursor storage roots. +/// +/// Production snapshots the process environment once per resolution +/// ([`CursorEnv::from_process`]); tests construct values directly to cover the +/// whole platform matrix. Fields hold already-validated values — env-string +/// filtering lives in [`parse_env_path`]. +#[derive(Debug, Clone, Default)] +struct CursorEnv { + /// `ORGII_EXTERNAL_HISTORY_HOME` identity-isolation override. + external_history_home: Option, + /// Real user home directory (`dirs::home_dir()`). + home: Option, + /// `$XDG_CONFIG_HOME` (absolute values only). Consulted on Linux. + xdg_config_home: Option, + /// `%APPDATA%` (absolute values only). Consulted on Windows. + appdata: Option, +} + +impl CursorEnv { + fn from_process() -> Self { + Self { + external_history_home: crate::external_history_home_override(), + home: dirs::home_dir(), + xdg_config_home: env_path("XDG_CONFIG_HOME"), + appdata: env_path("APPDATA"), + } + } + + /// Root that Cursor's Electron shell resolves its `userData` dir against. + fn config_root(&self, platform: Platform) -> Result { + // Isolation override: deterministic per-platform layout beneath the + // override home; never read the real user's XDG/APPDATA environment + // (same contract as `external_history_config_dir`). + if let Some(isolated_home) = &self.external_history_home { + return Ok(default_config_root_under(platform, isolated_home)); + } + match platform { + Platform::Linux => { + if let Some(xdg_config_home) = &self.xdg_config_home { + return Ok(xdg_config_home.clone()); + } + } + Platform::Windows => { + if let Some(appdata) = &self.appdata { + return Ok(appdata.clone()); + } + } + Platform::MacOs => {} + } + let home = self.home.as_deref().ok_or(CursorPathsUnavailable)?; + Ok(default_config_root_under(platform, home)) + } + + /// Home root anchoring the `~/.cursor` dotdir family. + fn home_root(&self) -> Result { + if let Some(isolated_home) = &self.external_history_home { + return Ok(isolated_home.clone()); + } + self.home.clone().ok_or(CursorPathsUnavailable) + } + + fn global_storage_dir(&self, platform: Platform) -> Result { + Ok(self + .config_root(platform)? + .join("Cursor") + .join("User") + .join("globalStorage")) + } + + fn state_db_path(&self, platform: Platform) -> Result { + Ok(self.global_storage_dir(platform)?.join("state.vscdb")) + } + + fn conversation_index_db_path( + &self, + platform: Platform, + ) -> Result { + Ok(self + .global_storage_dir(platform)? + .join("conversation-search.db")) + } + + fn plugins_cache_dir(&self) -> Result { + Ok(self + .home_root()? + .join(".cursor") + .join("plugins") + .join("cache") + .join("cursor-public")) + } +} + +/// Default per-platform config root beneath a given home directory — the +/// matrix previously duplicated across `orgtrack_core` and `agent_cli`. +fn default_config_root_under(platform: Platform, home: &Path) -> PathBuf { + match platform { + Platform::MacOs => home.join("Library").join("Application Support"), + Platform::Linux => home.join(".config"), + Platform::Windows => home.join("AppData").join("Roaming"), + } +} + +fn env_path(var: &str) -> Option { + parse_env_path(&std::env::var(var).ok()?) +} + +/// Filter for env-provided directory values: trimmed, non-empty, absolute. +/// (The XDG base-dir spec requires relative `XDG_*` values to be ignored; +/// the same guard keeps a malformed `%APPDATA%` from producing a relative +/// storage root.) +fn parse_env_path(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() { + return None; + } + let path = PathBuf::from(trimmed); + path.is_absolute().then_some(path) +} + +#[cfg(test)] +mod tests { + use super::*; + + // Fixture paths use `/` separators (host-native on the unix CI/dev hosts + // this suite runs on) even for the Windows rows: `Path` equality compares + // components, and the resolver core never inspects separators itself. + + const ALL_PLATFORMS: [Platform; 3] = [Platform::MacOs, Platform::Linux, Platform::Windows]; + + fn env_with_home(home: &str) -> CursorEnv { + CursorEnv { + home: Some(PathBuf::from(home)), + ..CursorEnv::default() + } + } + + // ── Platform matrix (no override) ── + + #[test] + fn macos_global_storage_under_application_support() { + let env = env_with_home("/Users/dev"); + assert_eq!( + env.global_storage_dir(Platform::MacOs).unwrap(), + PathBuf::from("/Users/dev/Library/Application Support/Cursor/User/globalStorage"), + ); + } + + #[test] + fn linux_global_storage_defaults_to_dot_config() { + let env = env_with_home("/home/dev"); + assert_eq!( + env.global_storage_dir(Platform::Linux).unwrap(), + PathBuf::from("/home/dev/.config/Cursor/User/globalStorage"), + ); + } + + #[test] + fn linux_global_storage_respects_xdg_config_home() { + let env = CursorEnv { + home: Some(PathBuf::from("/home/dev")), + xdg_config_home: Some(PathBuf::from("/mnt/config")), + ..CursorEnv::default() + }; + assert_eq!( + env.global_storage_dir(Platform::Linux).unwrap(), + PathBuf::from("/mnt/config/Cursor/User/globalStorage"), + ); + // XDG is a Linux-only concept here: macOS keeps the home-based layout. + assert_eq!( + env.global_storage_dir(Platform::MacOs).unwrap(), + PathBuf::from("/home/dev/Library/Application Support/Cursor/User/globalStorage"), + ); + } + + #[test] + fn windows_global_storage_prefers_appdata() { + let env = CursorEnv { + home: Some(PathBuf::from("C:/Users/dev")), + appdata: Some(PathBuf::from("D:/Roaming")), + ..CursorEnv::default() + }; + assert_eq!( + env.global_storage_dir(Platform::Windows).unwrap(), + PathBuf::from("D:/Roaming/Cursor/User/globalStorage"), + ); + } + + #[test] + fn windows_global_storage_falls_back_to_home_appdata_roaming() { + let env = env_with_home("C:/Users/dev"); + assert_eq!( + env.global_storage_dir(Platform::Windows).unwrap(), + PathBuf::from("C:/Users/dev/AppData/Roaming/Cursor/User/globalStorage"), + ); + } + + // ── Identity-isolation override ── + + #[test] + fn override_wins_and_real_environment_is_never_consulted() { + let env = CursorEnv { + external_history_home: Some(PathBuf::from("/tmp/orgii-instance2")), + home: Some(PathBuf::from("/Users/real")), + xdg_config_home: Some(PathBuf::from("/real/xdg")), + appdata: Some(PathBuf::from("C:/real/appdata")), + }; + assert_eq!( + env.global_storage_dir(Platform::MacOs).unwrap(), + PathBuf::from( + "/tmp/orgii-instance2/Library/Application Support/Cursor/User/globalStorage" + ), + ); + assert_eq!( + env.global_storage_dir(Platform::Linux).unwrap(), + PathBuf::from("/tmp/orgii-instance2/.config/Cursor/User/globalStorage"), + ); + assert_eq!( + env.global_storage_dir(Platform::Windows).unwrap(), + PathBuf::from("/tmp/orgii-instance2/AppData/Roaming/Cursor/User/globalStorage"), + ); + assert_eq!( + env.plugins_cache_dir().unwrap(), + PathBuf::from("/tmp/orgii-instance2/.cursor/plugins/cache/cursor-public"), + ); + } + + #[test] + fn override_resolves_even_without_a_home_dir() { + let env = CursorEnv { + external_history_home: Some(PathBuf::from("/tmp/orgii-instance2")), + ..CursorEnv::default() + }; + for platform in ALL_PLATFORMS { + assert!(env.global_storage_dir(platform).is_ok()); + } + assert!(env.plugins_cache_dir().is_ok()); + } + + // ── Typed unavailability ── + + #[test] + fn missing_home_is_typed_unavailable_not_a_fake_path() { + let env = CursorEnv::default(); + for platform in ALL_PLATFORMS { + assert_eq!(env.global_storage_dir(platform), Err(CursorPathsUnavailable)); + assert_eq!(env.state_db_path(platform), Err(CursorPathsUnavailable)); + } + assert_eq!(env.plugins_cache_dir(), Err(CursorPathsUnavailable)); + } + + #[test] + fn windows_appdata_alone_resolves_global_storage_but_not_home_dotdir() { + let env = CursorEnv { + appdata: Some(PathBuf::from("C:/Roaming")), + ..CursorEnv::default() + }; + assert_eq!( + env.global_storage_dir(Platform::Windows).unwrap(), + PathBuf::from("C:/Roaming/Cursor/User/globalStorage"), + ); + assert_eq!(env.plugins_cache_dir(), Err(CursorPathsUnavailable)); + } + + // ── File names & dotdir family ── + + #[test] + fn database_file_names_join_global_storage() { + let env = env_with_home("/Users/dev"); + assert_eq!( + env.state_db_path(Platform::MacOs).unwrap(), + PathBuf::from( + "/Users/dev/Library/Application Support/Cursor/User/globalStorage/state.vscdb" + ), + ); + assert_eq!( + env.conversation_index_db_path(Platform::MacOs).unwrap(), + PathBuf::from( + "/Users/dev/Library/Application Support/Cursor/User/globalStorage/conversation-search.db" + ), + ); + } + + #[test] + fn plugins_cache_is_home_anchored() { + let env = env_with_home("/home/dev"); + assert_eq!( + env.plugins_cache_dir().unwrap(), + PathBuf::from("/home/dev/.cursor/plugins/cache/cursor-public"), + ); + } + + // ── Env-value filtering ── + + #[test] + fn parse_env_path_filters_blank_and_relative_values() { + assert_eq!(parse_env_path(""), None); + assert_eq!(parse_env_path(" "), None); + assert_eq!(parse_env_path("relative/config"), None); + assert_eq!( + parse_env_path(" /abs/config "), + Some(PathBuf::from("/abs/config")), + ); + } +} diff --git a/src-tauri/crates/app-paths/src/lib.rs b/src-tauri/crates/app-paths/src/lib.rs index 49b7ed8bdf..458c4ba771 100644 --- a/src-tauri/crates/app-paths/src/lib.rs +++ b/src-tauri/crates/app-paths/src/lib.rs @@ -9,6 +9,10 @@ //! to do filesystem and process work (e.g. `set_sensitive_file_permissions`) //! but takes no domain dependencies. Every other crate may depend on it. +/// Canonical resolver for the Cursor app's own storage locations (IDE +/// `globalStorage` databases, `~/.cursor` plugin cache). +pub mod cursor; + use std::collections::HashSet; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -33,7 +37,7 @@ pub fn external_history_home_dir() -> PathBuf { external_history_home_override().unwrap_or_else(home_dir) } -fn external_history_home_override() -> Option { +pub(crate) fn external_history_home_override() -> Option { std::env::var_os("ORGII_EXTERNAL_HISTORY_HOME") .filter(|value| !value.is_empty()) .map(PathBuf::from) diff --git a/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/io.rs b/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/io.rs index 578ae9abda..9a6844620e 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/io.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/cursor_ide/io.rs @@ -28,39 +28,14 @@ pub(super) fn open_cursor_db() -> Option { .ok() } -/// Cursor's `User/globalStorage` directory for the current platform. Does not -/// check existence — callers join a filename and test that. -fn cursor_global_storage_dir() -> Option { - let home = app_paths::external_history_home_dir(); - - #[cfg(target_os = "macos")] - let dir = home - .join("Library") - .join("Application Support") - .join("Cursor") - .join("User") - .join("globalStorage"); - - #[cfg(target_os = "linux")] - let dir = home - .join(".config") - .join("Cursor") - .join("User") - .join("globalStorage"); - - #[cfg(target_os = "windows")] - let dir = home - .join("AppData") - .join("Roaming") - .join("Cursor") - .join("User") - .join("globalStorage"); - - Some(dir) -} - +/// Cursor's global `state.vscdb` path, resolved by the canonical +/// platform-aware resolver in [`app_paths::cursor`] (which also owns the +/// `ORGII_EXTERNAL_HISTORY_HOME` isolation-override semantics). +/// +/// `None` when the storage root is unavailable (no resolvable home dir) or +/// the file does not exist yet — both mean "no Cursor history to import". pub(super) fn cursor_db_path() -> Option { - let path = cursor_global_storage_dir()?.join("state.vscdb"); + let path = app_paths::cursor::state_db_path().ok()?; path.exists().then_some(path) } @@ -69,7 +44,7 @@ pub(super) fn cursor_db_path() -> Option { /// next to `state.vscdb`. Lets discovery avoid scanning the multi-GB `state.vscdb`. /// `None` on older Cursor builds that predate it. pub(super) fn cursor_conversation_index_path() -> Option { - let path = cursor_global_storage_dir()?.join("conversation-search.db"); + let path = app_paths::cursor::conversation_index_db_path().ok()?; path.exists().then_some(path) }