feat(migration): import provider copy files into cc-switch config on first run\n\n- Scan Claude settings-*.json and Codex auth-*.json/config-*.toml\n- Merge into ~/.cc-switch/config.json, de-dupe by provider name (override with copies)\n- Backup old config.json and archive all scanned copy files\n- Add migration marker to avoid re-importing\n- Stop writing provider copy files in ProviderManager::add_provider

This commit is contained in:
Jason
2025-09-04 16:16:51 +08:00
parent 79370dd8a1
commit 5176134c28
3 changed files with 220 additions and 46 deletions

View File

@@ -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);
}
// 保存配置

209
src-tauri/src/migration.rs Normal file
View File

@@ -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<String>, 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::<Value>(&p) {
items.push((name.to_string(), p, val));
}
}
}
items
}
fn scan_codex_copies() -> Vec<(String, Option<PathBuf>, Option<PathBuf>, Value)> {
let mut by_name: HashMap<String, (Option<PathBuf>, Option<PathBuf>)> = 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::<Value>(&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::<toml::Table>(&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<bool, String> {
// 如果已迁移过则跳过
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<String> = 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<String> = 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)
}

View File

@@ -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(())
}