Skip to content
Open
Show file tree
Hide file tree
Changes from 8 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 53 additions & 2 deletions src-tauri/src/services/skill.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ use serde::{Deserialize, Serialize};
use std::collections::{HashMap, HashSet};
use std::fs;
use std::path::{Component, Path, PathBuf};
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Arc;
use tokio::time::timeout;

Expand All @@ -19,6 +20,10 @@ use crate::config::get_app_config_dir;
use crate::database::Database;
use crate::error::format_skill_error;

/// Set to true while migrate_storage is moving files so that get_all_installed
/// does not prune DB records that are temporarily absent from the old SSOT path.
static MIGRATION_IN_PROGRESS: AtomicBool = AtomicBool::new(false);

// ========== 数据结构 ==========

/// Skill 同步方式
Expand Down Expand Up @@ -556,9 +561,45 @@ impl SkillService {
// ========== 统一管理方法 ==========

/// 获取所有已安装的 Skills
///
/// 同时核对磁盘:若 SSOT 目录下的 skill 文件夹已被手动删除,自动清理 DB 记录。
pub fn get_all_installed(db: &Arc<Database>) -> Result<Vec<InstalledSkill>> {
let skills = db.get_all_installed_skills()?;
Ok(skills.into_values().collect())
let ssot_dir = Self::get_ssot_dir().ok();
let mut result = Vec::new();
for (id, skill) in skills {
let missing = ssot_dir
.as_ref()
.and_then(|d| d.join(&skill.directory).try_exists().ok())
.map(|exists| !exists)
.unwrap_or(false);
if missing && !MIGRATION_IN_PROGRESS.load(Ordering::Acquire) {
// Delete the DB record first. If that fails (locked/read-only DB),
// leave the skill visible and skip filesystem cleanup so the DB and
// app directories stay in sync.
match db.delete_skill(&id) {
Comment thread
TuYv marked this conversation as resolved.
Outdated
Comment thread
TuYv marked this conversation as resolved.
Outdated
Ok(_) => {
for app in skill.apps.enabled_apps() {
let _ = Self::remove_from_app(&skill.directory, &app);
}
log::info!(
"skill '{}' directory missing from SSOT, removed from database",
skill.name
);
}
Err(e) => {
log::warn!(
"skill '{}' SSOT directory missing but DB deletion failed, keeping record: {}",
skill.name, e
);
result.push(skill);
}
}
} else {
result.push(skill);
}
}
Ok(result)
}

/// 安装 Skill
Expand Down Expand Up @@ -1171,6 +1212,16 @@ impl SkillService {
fs::create_dir_all(&new_dir)?;

// 2. 逐个移动 skill 目录
// Guard against get_all_installed pruning records while files are in flight.
// The guard resets the flag on drop, so any early-return also clears it.
struct MigrationGuard;
impl Drop for MigrationGuard {
fn drop(&mut self) {
MIGRATION_IN_PROGRESS.store(false, Ordering::Release);
}
}
MIGRATION_IN_PROGRESS.store(true, Ordering::Release);
let _migration_guard = MigrationGuard;
let skills = db.get_all_installed_skills()?;
let mut result = MigrationResult {
migrated_count: 0,
Expand Down Expand Up @@ -1206,7 +1257,7 @@ impl SkillService {
}
}

// 3. 文件移动完成后才持久化设置
// 3. 文件移动完成后才持久化设置(_migration_guard 在此作用域末尾自动清 flag)
crate::settings::set_skill_storage_location(target)?;

// 4. 刷新所有应用目录的 symlink(指向新 SSOT)
Expand Down
99 changes: 99 additions & 0 deletions src-tauri/src/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -478,7 +478,28 @@ fn settings_store() -> &'static RwLock<AppSettings> {
SETTINGS_STORE.get_or_init(|| RwLock::new(AppSettings::load_from_file()))
}

fn expand_env_vars(s: &str) -> String {
let mut result = s.to_string();
let mut pos = 0;
while let Some(rel_start) = result[pos..].find("${") {
let start = pos + rel_start;
let Some(rel_end) = result[start + 2..].find('}') else {
break;
};
let end = start + 2 + rel_end;
let var_name = result[start + 2..end].to_string();
if let Ok(val) = std::env::var(&var_name) {
result.replace_range(start..=end, &val);
pos = start + val.len();
} else {
pos = end + 1;
}
}
result
}

fn resolve_override_path(raw: &str) -> PathBuf {
let raw = &expand_env_vars(raw);
if raw == "~" {
if let Some(home) = dirs::home_dir() {
return home;
Expand Down Expand Up @@ -768,3 +789,81 @@ pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppErro
}
})
}

#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::sync::{Mutex, OnceLock};

fn env_lock() -> &'static Mutex<()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(()))
}

struct EnvGuard {
key: &'static str,
original: Option<String>,
}

impl EnvGuard {
fn set(key: &'static str, value: &str) -> Self {
let original = std::env::var(key).ok();
std::env::set_var(key, value);
Self { key, original }
}
}

impl Drop for EnvGuard {
fn drop(&mut self) {
match &self.original {
Some(v) => std::env::set_var(self.key, v),
None => std::env::remove_var(self.key),
}
}
}

#[test]
#[serial]
fn expand_env_vars_replaces_home() {
let _guard = env_lock().lock().unwrap();
let _home = EnvGuard::set("HOME", "/Users/test");
assert_eq!(expand_env_vars("${HOME}/.claude"), "/Users/test/.claude");
assert_eq!(expand_env_vars("${HOME}"), "/Users/test");
}

#[test]
#[serial]
fn expand_env_vars_multiple_vars() {
let _guard = env_lock().lock().unwrap();
let _foo = EnvGuard::set("FOO", "foo");
let _bar = EnvGuard::set("BAR", "bar");
Comment thread
TuYv marked this conversation as resolved.
assert_eq!(expand_env_vars("${FOO}/${BAR}"), "foo/bar");
}

#[test]
fn expand_env_vars_unknown_var_left_as_is() {
let s = "${DEFINITELY_NOT_SET_XYZ}";
assert_eq!(expand_env_vars(s), s);
}

#[test]
fn expand_env_vars_no_vars_unchanged() {
assert_eq!(expand_env_vars("/absolute/path"), "/absolute/path");
assert_eq!(expand_env_vars("~/path"), "~/path");
}

#[test]
#[serial]
fn resolve_override_path_expands_home_var() {
let _guard = env_lock().lock().unwrap();
if let Some(home) = dirs::home_dir() {
let home_str = home.to_string_lossy().to_string();
let _home = EnvGuard::set("HOME", &home_str);
assert_eq!(
resolve_override_path("${HOME}/.claude"),
home.join(".claude")
);
}
}
}
Loading