Migrate the Claude common config snippet storage from browser localStorage to the persistent config.json file for better cross-device sync and backup support. **Backend Changes:** - Add `claude_common_config_snippet` field to `MultiAppConfig` struct - Add `get_claude_common_config_snippet` and `set_claude_common_config_snippet` Tauri commands - Include JSON validation in the setter command **Frontend Changes:** - Create new `lib/api/config.ts` API module - Refactor `useCommonConfigSnippet` hook to use config.json instead of localStorage - Add automatic one-time migration from localStorage to config.json - Add loading state during initialization **Benefits:** - Cross-device synchronization via backup/restore - More reliable persistence than browser storage - Centralized configuration management - Seamless migration for existing users
503 lines
16 KiB
Rust
503 lines
16 KiB
Rust
use serde::{Deserialize, Serialize};
|
||
use std::collections::HashMap;
|
||
use std::str::FromStr;
|
||
|
||
/// MCP 配置:单客户端维度(claude 或 codex 下的一组服务器)
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
pub struct McpConfig {
|
||
/// 以 id 为键的服务器定义(宽松 JSON 对象,包含 enabled/source 等 UI 辅助字段)
|
||
#[serde(default)]
|
||
pub servers: HashMap<String, serde_json::Value>,
|
||
}
|
||
|
||
/// MCP 根:按客户端分开维护(无历史兼容压力,直接以 v2 结构落地)
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
pub struct McpRoot {
|
||
#[serde(default)]
|
||
pub claude: McpConfig,
|
||
#[serde(default)]
|
||
pub codex: McpConfig,
|
||
#[serde(default)]
|
||
pub gemini: McpConfig, // Gemini MCP 配置(预留)
|
||
}
|
||
|
||
/// Prompt 配置:单客户端维度
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
pub struct PromptConfig {
|
||
#[serde(default)]
|
||
pub prompts: HashMap<String, crate::prompt::Prompt>,
|
||
}
|
||
|
||
/// Prompt 根:按客户端分开维护
|
||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||
pub struct PromptRoot {
|
||
#[serde(default)]
|
||
pub claude: PromptConfig,
|
||
#[serde(default)]
|
||
pub codex: PromptConfig,
|
||
#[serde(default)]
|
||
pub gemini: PromptConfig,
|
||
}
|
||
|
||
use crate::config::{copy_file, get_app_config_dir, get_app_config_path, write_json_file};
|
||
use crate::error::AppError;
|
||
use crate::prompt_files::prompt_file_path;
|
||
use crate::provider::ProviderManager;
|
||
|
||
/// 应用类型
|
||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||
#[serde(rename_all = "lowercase")]
|
||
pub enum AppType {
|
||
Claude,
|
||
Codex,
|
||
Gemini, // 新增
|
||
}
|
||
|
||
impl AppType {
|
||
pub fn as_str(&self) -> &str {
|
||
match self {
|
||
AppType::Claude => "claude",
|
||
AppType::Codex => "codex",
|
||
AppType::Gemini => "gemini", // 新增
|
||
}
|
||
}
|
||
}
|
||
|
||
impl FromStr for AppType {
|
||
type Err = AppError;
|
||
|
||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||
let normalized = s.trim().to_lowercase();
|
||
match normalized.as_str() {
|
||
"claude" => Ok(AppType::Claude),
|
||
"codex" => Ok(AppType::Codex),
|
||
"gemini" => Ok(AppType::Gemini), // 新增
|
||
other => Err(AppError::localized(
|
||
"unsupported_app",
|
||
format!("不支持的应用标识: '{other}'。可选值: claude, codex, gemini。"),
|
||
format!("Unsupported app id: '{other}'. Allowed: claude, codex, gemini."),
|
||
)),
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 多应用配置结构(向后兼容)
|
||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||
pub struct MultiAppConfig {
|
||
#[serde(default = "default_version")]
|
||
pub version: u32,
|
||
/// 应用管理器(claude/codex)
|
||
#[serde(flatten)]
|
||
pub apps: HashMap<String, ProviderManager>,
|
||
/// MCP 配置(按客户端分治)
|
||
#[serde(default)]
|
||
pub mcp: McpRoot,
|
||
/// Prompt 配置(按客户端分治)
|
||
#[serde(default)]
|
||
pub prompts: PromptRoot,
|
||
/// Claude 通用配置片段(JSON 字符串,用于跨供应商共享配置)
|
||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||
pub claude_common_config_snippet: Option<String>,
|
||
}
|
||
|
||
fn default_version() -> u32 {
|
||
2
|
||
}
|
||
|
||
impl Default for MultiAppConfig {
|
||
fn default() -> Self {
|
||
let mut apps = HashMap::new();
|
||
apps.insert("claude".to_string(), ProviderManager::default());
|
||
apps.insert("codex".to_string(), ProviderManager::default());
|
||
apps.insert("gemini".to_string(), ProviderManager::default()); // 新增
|
||
|
||
Self {
|
||
version: 2,
|
||
apps,
|
||
mcp: McpRoot::default(),
|
||
prompts: PromptRoot::default(),
|
||
claude_common_config_snippet: None,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl MultiAppConfig {
|
||
/// 从文件加载配置(仅支持 v2 结构)
|
||
pub fn load() -> Result<Self, AppError> {
|
||
let config_path = get_app_config_path();
|
||
|
||
if !config_path.exists() {
|
||
log::info!("配置文件不存在,创建新的多应用配置并自动导入提示词");
|
||
// 使用新的方法,支持自动导入提示词
|
||
let config = Self::default_with_auto_import()?;
|
||
// 立即保存到磁盘
|
||
config.save()?;
|
||
return Ok(config);
|
||
}
|
||
|
||
// 尝试读取文件
|
||
let content =
|
||
std::fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?;
|
||
|
||
// 先解析为 Value,以便严格判定是否为 v1 结构;
|
||
// 满足:顶层同时包含 providers(object) + current(string),且不包含 version/apps/mcp 关键键,即视为 v1
|
||
let value: serde_json::Value =
|
||
serde_json::from_str(&content).map_err(|e| AppError::json(&config_path, e))?;
|
||
let is_v1 = value.as_object().is_some_and(|map| {
|
||
let has_providers = map.get("providers").map(|v| v.is_object()).unwrap_or(false);
|
||
let has_current = map.get("current").map(|v| v.is_string()).unwrap_or(false);
|
||
// v1 的充分必要条件:有 providers 和 current,且 apps 不存在(version/mcp 可能存在但不作为 v2 判据)
|
||
let has_apps = map.contains_key("apps");
|
||
has_providers && has_current && !has_apps
|
||
});
|
||
if is_v1 {
|
||
return Err(AppError::localized(
|
||
"config.unsupported_v1",
|
||
"检测到旧版 v1 配置格式。当前版本已不再支持运行时自动迁移。\n\n解决方案:\n1. 安装 v3.2.x 版本进行一次性自动迁移\n2. 或手动编辑 ~/.cc-switch/config.json,将顶层结构调整为:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
|
||
"Detected legacy v1 config. Runtime auto-migration is no longer supported.\n\nSolutions:\n1. Install v3.2.x for one-time auto-migration\n2. Or manually edit ~/.cc-switch/config.json to adjust the top-level structure:\n {\"version\": 2, \"claude\": {...}, \"codex\": {...}, \"mcp\": {...}}\n\n",
|
||
));
|
||
}
|
||
|
||
// 解析 v2 结构
|
||
let mut config: Self =
|
||
serde_json::from_value(value).map_err(|e| AppError::json(&config_path, e))?;
|
||
|
||
// 确保 gemini 应用存在(兼容旧配置文件)
|
||
if !config.apps.contains_key("gemini") {
|
||
config
|
||
.apps
|
||
.insert("gemini".to_string(), ProviderManager::default());
|
||
}
|
||
|
||
Ok(config)
|
||
}
|
||
|
||
/// 保存配置到文件
|
||
pub fn save(&self) -> Result<(), AppError> {
|
||
let config_path = get_app_config_path();
|
||
// 先备份旧版(若存在)到 ~/.cc-switch/config.json.bak,再写入新内容
|
||
if config_path.exists() {
|
||
let backup_path = get_app_config_dir().join("config.json.bak");
|
||
if let Err(e) = copy_file(&config_path, &backup_path) {
|
||
log::warn!("备份 config.json 到 .bak 失败: {e}");
|
||
}
|
||
}
|
||
|
||
write_json_file(&config_path, self)?;
|
||
Ok(())
|
||
}
|
||
|
||
/// 获取指定应用的管理器
|
||
pub fn get_manager(&self, app: &AppType) -> Option<&ProviderManager> {
|
||
self.apps.get(app.as_str())
|
||
}
|
||
|
||
/// 获取指定应用的管理器(可变引用)
|
||
pub fn get_manager_mut(&mut self, app: &AppType) -> Option<&mut ProviderManager> {
|
||
self.apps.get_mut(app.as_str())
|
||
}
|
||
|
||
/// 确保应用存在
|
||
pub fn ensure_app(&mut self, app: &AppType) {
|
||
if !self.apps.contains_key(app.as_str()) {
|
||
self.apps
|
||
.insert(app.as_str().to_string(), ProviderManager::default());
|
||
}
|
||
}
|
||
|
||
/// 获取指定客户端的 MCP 配置(不可变引用)
|
||
pub fn mcp_for(&self, app: &AppType) -> &McpConfig {
|
||
match app {
|
||
AppType::Claude => &self.mcp.claude,
|
||
AppType::Codex => &self.mcp.codex,
|
||
AppType::Gemini => &self.mcp.gemini,
|
||
}
|
||
}
|
||
|
||
/// 获取指定客户端的 MCP 配置(可变引用)
|
||
pub fn mcp_for_mut(&mut self, app: &AppType) -> &mut McpConfig {
|
||
match app {
|
||
AppType::Claude => &mut self.mcp.claude,
|
||
AppType::Codex => &mut self.mcp.codex,
|
||
AppType::Gemini => &mut self.mcp.gemini,
|
||
}
|
||
}
|
||
|
||
/// 创建默认配置并自动导入已存在的提示词文件
|
||
fn default_with_auto_import() -> Result<Self, AppError> {
|
||
log::info!("首次启动,创建默认配置并检测提示词文件");
|
||
|
||
let mut config = Self::default();
|
||
|
||
// 为每个应用尝试自动导入提示词
|
||
Self::auto_import_prompt_if_exists(&mut config, AppType::Claude)?;
|
||
Self::auto_import_prompt_if_exists(&mut config, AppType::Codex)?;
|
||
Self::auto_import_prompt_if_exists(&mut config, AppType::Gemini)?;
|
||
|
||
Ok(config)
|
||
}
|
||
|
||
/// 检查并自动导入单个应用的提示词文件
|
||
fn auto_import_prompt_if_exists(config: &mut Self, app: AppType) -> Result<(), AppError> {
|
||
let file_path = prompt_file_path(&app)?;
|
||
|
||
// 检查文件是否存在
|
||
if !file_path.exists() {
|
||
log::debug!("提示词文件不存在,跳过自动导入: {file_path:?}");
|
||
return Ok(());
|
||
}
|
||
|
||
// 读取文件内容
|
||
let content = match std::fs::read_to_string(&file_path) {
|
||
Ok(c) => c,
|
||
Err(e) => {
|
||
log::warn!("读取提示词文件失败: {file_path:?}, 错误: {e}");
|
||
return Ok(()); // 失败时不中断,继续处理其他应用
|
||
}
|
||
};
|
||
|
||
// 检查内容是否为空
|
||
if content.trim().is_empty() {
|
||
log::debug!("提示词文件内容为空,跳过导入: {file_path:?}");
|
||
return Ok(());
|
||
}
|
||
|
||
log::info!("发现提示词文件,自动导入: {file_path:?}");
|
||
|
||
// 创建提示词对象
|
||
let timestamp = std::time::SystemTime::now()
|
||
.duration_since(std::time::UNIX_EPOCH)
|
||
.unwrap()
|
||
.as_secs() as i64;
|
||
|
||
let id = format!("auto-imported-{timestamp}");
|
||
let prompt = crate::prompt::Prompt {
|
||
id: id.clone(),
|
||
name: format!(
|
||
"Auto-imported Prompt {}",
|
||
chrono::Local::now().format("%Y-%m-%d %H:%M")
|
||
),
|
||
content,
|
||
description: Some("Automatically imported on first launch".to_string()),
|
||
enabled: true, // 自动启用
|
||
created_at: Some(timestamp),
|
||
updated_at: Some(timestamp),
|
||
};
|
||
|
||
// 插入到对应的应用配置中
|
||
let prompts = match app {
|
||
AppType::Claude => &mut config.prompts.claude.prompts,
|
||
AppType::Codex => &mut config.prompts.codex.prompts,
|
||
AppType::Gemini => &mut config.prompts.gemini.prompts,
|
||
};
|
||
|
||
prompts.insert(id, prompt);
|
||
|
||
log::info!("自动导入完成: {}", app.as_str());
|
||
Ok(())
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use super::*;
|
||
use serial_test::serial;
|
||
use std::env;
|
||
use std::fs;
|
||
use tempfile::TempDir;
|
||
|
||
struct TempHome {
|
||
#[allow(dead_code)] // 字段通过 Drop trait 管理临时目录生命周期
|
||
dir: TempDir,
|
||
original_home: Option<String>,
|
||
original_userprofile: Option<String>,
|
||
}
|
||
|
||
impl TempHome {
|
||
fn new() -> Self {
|
||
let dir = TempDir::new().expect("failed to create temp home");
|
||
let original_home = env::var("HOME").ok();
|
||
let original_userprofile = env::var("USERPROFILE").ok();
|
||
|
||
env::set_var("HOME", dir.path());
|
||
env::set_var("USERPROFILE", dir.path());
|
||
|
||
Self {
|
||
dir,
|
||
original_home,
|
||
original_userprofile,
|
||
}
|
||
}
|
||
}
|
||
|
||
impl Drop for TempHome {
|
||
fn drop(&mut self) {
|
||
match &self.original_home {
|
||
Some(value) => env::set_var("HOME", value),
|
||
None => env::remove_var("HOME"),
|
||
}
|
||
|
||
match &self.original_userprofile {
|
||
Some(value) => env::set_var("USERPROFILE", value),
|
||
None => env::remove_var("USERPROFILE"),
|
||
}
|
||
}
|
||
}
|
||
|
||
fn write_prompt_file(app: AppType, content: &str) {
|
||
let path = crate::prompt_files::prompt_file_path(&app).expect("prompt path");
|
||
if let Some(parent) = path.parent() {
|
||
fs::create_dir_all(parent).expect("create parent dir");
|
||
}
|
||
fs::write(path, content).expect("write prompt");
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn auto_imports_existing_prompt_when_config_missing() {
|
||
let _home = TempHome::new();
|
||
write_prompt_file(AppType::Claude, "# hello");
|
||
|
||
let config = MultiAppConfig::load().expect("load config");
|
||
|
||
assert_eq!(config.prompts.claude.prompts.len(), 1);
|
||
let prompt = config
|
||
.prompts
|
||
.claude
|
||
.prompts
|
||
.values()
|
||
.next()
|
||
.expect("prompt exists");
|
||
assert!(prompt.enabled);
|
||
assert_eq!(prompt.content, "# hello");
|
||
|
||
let config_path = crate::config::get_app_config_path();
|
||
assert!(
|
||
config_path.exists(),
|
||
"auto import should persist config to disk"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn skips_empty_prompt_files_during_import() {
|
||
let _home = TempHome::new();
|
||
write_prompt_file(AppType::Claude, " \n ");
|
||
|
||
let config = MultiAppConfig::load().expect("load config");
|
||
assert!(
|
||
config.prompts.claude.prompts.is_empty(),
|
||
"empty files must be ignored"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn auto_import_happens_only_once() {
|
||
let _home = TempHome::new();
|
||
write_prompt_file(AppType::Claude, "first version");
|
||
|
||
let first = MultiAppConfig::load().expect("load config");
|
||
assert_eq!(first.prompts.claude.prompts.len(), 1);
|
||
let claude_prompt = first
|
||
.prompts
|
||
.claude
|
||
.prompts
|
||
.values()
|
||
.next()
|
||
.expect("prompt exists")
|
||
.content
|
||
.clone();
|
||
assert_eq!(claude_prompt, "first version");
|
||
|
||
// 覆盖文件内容,但保留 config.json
|
||
write_prompt_file(AppType::Claude, "second version");
|
||
let second = MultiAppConfig::load().expect("load config again");
|
||
|
||
assert_eq!(second.prompts.claude.prompts.len(), 1);
|
||
let prompt = second
|
||
.prompts
|
||
.claude
|
||
.prompts
|
||
.values()
|
||
.next()
|
||
.expect("prompt exists");
|
||
assert_eq!(
|
||
prompt.content, "first version",
|
||
"should not re-import when config already exists"
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn auto_imports_gemini_prompt_on_first_launch() {
|
||
let _home = TempHome::new();
|
||
write_prompt_file(AppType::Gemini, "# Gemini Prompt\n\nTest content");
|
||
|
||
let config = MultiAppConfig::load().expect("load config");
|
||
|
||
assert_eq!(config.prompts.gemini.prompts.len(), 1);
|
||
let prompt = config
|
||
.prompts
|
||
.gemini
|
||
.prompts
|
||
.values()
|
||
.next()
|
||
.expect("gemini prompt exists");
|
||
assert!(prompt.enabled, "gemini prompt should be enabled");
|
||
assert_eq!(prompt.content, "# Gemini Prompt\n\nTest content");
|
||
assert_eq!(
|
||
prompt.description,
|
||
Some("Automatically imported on first launch".to_string())
|
||
);
|
||
}
|
||
|
||
#[test]
|
||
#[serial]
|
||
fn auto_imports_all_three_apps_prompts() {
|
||
let _home = TempHome::new();
|
||
write_prompt_file(AppType::Claude, "# Claude prompt");
|
||
write_prompt_file(AppType::Codex, "# Codex prompt");
|
||
write_prompt_file(AppType::Gemini, "# Gemini prompt");
|
||
|
||
let config = MultiAppConfig::load().expect("load config");
|
||
|
||
// 验证所有三个应用的提示词都被导入
|
||
assert_eq!(config.prompts.claude.prompts.len(), 1);
|
||
assert_eq!(config.prompts.codex.prompts.len(), 1);
|
||
assert_eq!(config.prompts.gemini.prompts.len(), 1);
|
||
|
||
// 验证所有提示词都被启用
|
||
assert!(
|
||
config
|
||
.prompts
|
||
.claude
|
||
.prompts
|
||
.values()
|
||
.next()
|
||
.unwrap()
|
||
.enabled
|
||
);
|
||
assert!(
|
||
config
|
||
.prompts
|
||
.codex
|
||
.prompts
|
||
.values()
|
||
.next()
|
||
.unwrap()
|
||
.enabled
|
||
);
|
||
assert!(
|
||
config
|
||
.prompts
|
||
.gemini
|
||
.prompts
|
||
.values()
|
||
.next()
|
||
.unwrap()
|
||
.enabled
|
||
);
|
||
}
|
||
}
|