diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 82b7886..077b029 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -4,6 +4,7 @@ mod commands; mod config; mod provider; mod store; +mod migration; use store::AppState; use tauri::Manager; @@ -55,48 +56,16 @@ pub fn run() { // 初始化应用状态(仅创建一次,并在本函数末尾注入 manage) let app_state = AppState::new(); - // 如果没有供应商且存在 Claude Code 配置,自动导入 + // 首次启动迁移:扫描副本文件,合并到 config.json,并归档副本;旧 config.json 先归档 { - let mut config = app_state.config.lock().unwrap(); - - // 检查 Claude 供应商 - let need_import = if let Some(claude_manager) = - config.get_manager(&app_config::AppType::Claude) - { - claude_manager.providers.is_empty() - } else { - // 确保 Claude 应用存在 - config.ensure_app(&app_config::AppType::Claude); - true - }; - - if need_import { - let settings_path = config::get_claude_settings_path(); - if settings_path.exists() { - log::info!("检测到 Claude Code 配置,自动导入为默认供应商"); - - if let Ok(settings_config) = config::import_current_config_as_default() { - if let Some(manager) = - config.get_manager_mut(&app_config::AppType::Claude) - { - let provider = provider::Provider::with_id( - "default".to_string(), - "default".to_string(), - settings_config, - None, - ); - - if manager.add_provider(provider).is_ok() { - manager.current = "default".to_string(); - log::info!("成功导入默认供应商"); - } - } - } - } + let mut config_guard = app_state.config.lock().unwrap(); + let migrated = migration::migrate_copies_into_config(&mut *config_guard)?; + if migrated { + log::info!("已将副本文件导入到 config.json,并完成归档"); } - - // 确保 Codex 应用存在 - config.ensure_app(&app_config::AppType::Codex); + // 确保两个 App 条目存在 + config_guard.ensure_app(&app_config::AppType::Claude); + config_guard.ensure_app(&app_config::AppType::Codex); } // 保存配置 diff --git a/src-tauri/src/migration.rs b/src-tauri/src/migration.rs new file mode 100644 index 0000000..ce851ad --- /dev/null +++ b/src-tauri/src/migration.rs @@ -0,0 +1,209 @@ +use crate::app_config::{AppType, MultiAppConfig}; +use crate::config::{ + archive_file, get_app_config_dir, get_app_config_path, get_claude_config_dir, +}; +use serde_json::Value; +use std::collections::{HashMap, HashSet}; +use std::fs; +use std::path::PathBuf; + +fn now_ts() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn get_marker_path() -> PathBuf { + get_app_config_dir().join("migrated.copies.v1") +} + +fn sanitized_id(base: &str) -> String { + crate::config::sanitize_provider_name(base) +} + +fn next_unique_id(existing: &HashSet, base: &str) -> String { + let base = sanitized_id(base); + if !existing.contains(&base) { + return base; + } + for i in 2..1000 { + let candidate = format!("{}-{}", base, i); + if !existing.contains(&candidate) { + return candidate; + } + } + format!("{}-dup", base) +} + +fn scan_claude_copies() -> Vec<(String, PathBuf, Value)> { + let mut items = Vec::new(); + let dir = get_claude_config_dir(); + if !dir.exists() { + return items; + } + if let Ok(rd) = fs::read_dir(&dir) { + for e in rd.flatten() { + let p = e.path(); + let fname = match p.file_name().and_then(|s| s.to_str()) { + Some(s) => s, + None => continue, + }; + if fname == "settings.json" || fname == "claude.json" { + continue; + } + if !fname.starts_with("settings-") || !fname.ends_with(".json") { + continue; + } + let name = fname.trim_start_matches("settings-").trim_end_matches(".json"); + if let Ok(val) = crate::config::read_json_file::(&p) { + items.push((name.to_string(), p, val)); + } + } + } + items +} + +fn scan_codex_copies() -> Vec<(String, Option, Option, Value)> { + let mut by_name: HashMap, Option)> = HashMap::new(); + let dir = crate::codex_config::get_codex_config_dir(); + if !dir.exists() { + return Vec::new(); + } + if let Ok(rd) = fs::read_dir(&dir) { + for e in rd.flatten() { + let p = e.path(); + let fname = match p.file_name().and_then(|s| s.to_str()) { + Some(s) => s, + None => continue, + }; + if fname.starts_with("auth-") && fname.ends_with(".json") { + let name = fname.trim_start_matches("auth-").trim_end_matches(".json"); + let entry = by_name.entry(name.to_string()).or_default(); + entry.0 = Some(p); + } else if fname.starts_with("config-") && fname.ends_with(".toml") { + let name = fname.trim_start_matches("config-").trim_end_matches(".toml"); + let entry = by_name.entry(name.to_string()).or_default(); + entry.1 = Some(p); + } + } + } + + let mut items = Vec::new(); + for (name, (auth_path, config_path)) in by_name { + if let Some(authp) = auth_path { + if let Ok(auth) = crate::config::read_json_file::(&authp) { + let config_str = if let Some(cfgp) = &config_path { + fs::read_to_string(cfgp).unwrap_or_default() + } else { + String::new() + }; + // 校验 TOML(若非空) + if !config_str.trim().is_empty() { + if let Err(e) = toml::from_str::(&config_str) { + log::warn!("跳过无效 Codex config-{}.toml: {}", name, e); + } + } + let settings = serde_json::json!({ + "auth": auth, + "config": config_str, + }); + items.push((name, Some(authp), config_path, settings)); + } + } + } + items +} + +pub fn migrate_copies_into_config(config: &mut MultiAppConfig) -> Result { + // 如果已迁移过则跳过 + let marker = get_marker_path(); + if marker.exists() { + return Ok(false); + } + + let claude_items = scan_claude_copies(); + let codex_items = scan_codex_copies(); + if claude_items.is_empty() && codex_items.is_empty() { + // 即便没有可迁移项,也写入标记避免每次扫描 + fs::write(&marker, b"no-copies").map_err(|e| format!("写入迁移标记失败: {}", e))?; + return Ok(false); + } + + // 备份旧的 config.json + let ts = now_ts(); + let app_cfg_path = get_app_config_path(); + if app_cfg_path.exists() { + let _ = archive_file(ts, "cc-switch", &app_cfg_path); + } + + // 合并:Claude + config.ensure_app(&AppType::Claude); + let manager = config.get_manager_mut(&AppType::Claude).unwrap(); + let mut ids: HashSet = manager.providers.keys().cloned().collect(); + for (name, path, value) in claude_items.iter() { + if let Some((id, prov)) = manager + .providers + .iter_mut() + .find(|(_, p)| p.name == *name) + { + // 重名:覆盖为副本内容 + log::info!("覆盖 Claude 供应商 '{}' 来自 {}", name, path.display()); + prov.settings_config = value.clone(); + } else { + // 新增 + let id = next_unique_id(&ids, name); + ids.insert(id.clone()); + let provider = crate::provider::Provider::with_id( + id, + name.clone(), + value.clone(), + None, + ); + manager.providers.insert(provider.id.clone(), provider); + } + } + + // 合并:Codex + config.ensure_app(&AppType::Codex); + let manager = config.get_manager_mut(&AppType::Codex).unwrap(); + let mut ids: HashSet = manager.providers.keys().cloned().collect(); + for (name, authp, cfgp, value) in codex_items.iter() { + if let Some((_id, prov)) = manager + .providers + .iter_mut() + .find(|(_, p)| p.name == *name) + { + log::info!("覆盖 Codex 供应商 '{}' 来自 {:?} / {:?}", name, authp, cfgp); + prov.settings_config = value.clone(); + } else { + let id = next_unique_id(&ids, name); + ids.insert(id.clone()); + let provider = crate::provider::Provider::with_id( + id, + name.clone(), + value.clone(), + None, + ); + manager.providers.insert(provider.id.clone(), provider); + } + } + + // 归档副本文件 + for (_, p, _) in claude_items.into_iter() { + let _ = archive_file(ts, "claude", &p); + } + for (_, ap, cp, _) in codex_items.into_iter() { + if let Some(ap) = ap { + let _ = archive_file(ts, "codex", &ap); + } + if let Some(cp) = cp { + let _ = archive_file(ts, "codex", &cp); + } + } + + // 标记完成 + fs::write(&marker, b"done").map_err(|e| format!("写入迁移标记失败: {}", e))?; + Ok(true) +} + diff --git a/src-tauri/src/provider.rs b/src-tauri/src/provider.rs index a972339..76b3ba8 100644 --- a/src-tauri/src/provider.rs +++ b/src-tauri/src/provider.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; -use crate::config::{get_provider_config_path, write_json_file}; +// SSOT 模式:不再写供应商副本文件 /// 供应商结构体 #[derive(Debug, Clone, Serialize, Deserialize)] @@ -52,11 +52,7 @@ impl Default for ProviderManager { impl ProviderManager { /// 添加供应商 pub fn add_provider(&mut self, provider: Provider) -> Result<(), String> { - // 保存供应商配置到独立文件 - let config_path = get_provider_config_path(&provider.id, Some(&provider.name)); - write_json_file(&config_path, &provider.settings_config)?; - - // 添加到管理器 + // 仅添加到管理器(SSOT:统一由 cc-switch/config.json 持久化) self.providers.insert(provider.id.clone(), provider); Ok(()) }