Skip to main content

cc_switch_lib/
store.rs

1use crate::app_config::MultiAppConfig;
2use crate::database::Database;
3use crate::error::AppError;
4use crate::services::ProxyService;
5use std::path::{Path, PathBuf};
6use std::sync::{Arc, RwLock};
7
8/// 全局应用状态
9pub struct AppState {
10    pub db: Arc<Database>,
11    pub config: RwLock<MultiAppConfig>,
12    pub proxy_service: ProxyService,
13}
14
15impl AppState {
16    /// 创建新的应用状态
17    pub fn try_new() -> Result<Self, AppError> {
18        let app_config_dir = crate::config::get_app_config_dir();
19        let db_path = app_config_dir.join("cc-switch.db");
20        let config_path = app_config_dir.join("config.json");
21        let skills_path = app_config_dir.join("skills.json");
22
23        if db_path.exists() {
24            let db = Arc::new(Database::init()?);
25            let mut config = export_db_to_multi_app_config(&db)?;
26            migrate_legacy_codex_configs(&db, &mut config);
27            crate::services::provider::ProviderService::migrate_common_config_upstream_semantics_if_needed(
28                &db,
29                &mut config,
30            )?;
31            return Self::from_parts(db, config);
32        }
33
34        // Validate legacy files before creating the database file.
35        let legacy_config = if config_path.exists() {
36            Some(MultiAppConfig::load()?)
37        } else {
38            None
39        };
40
41        let legacy_skills_index = if skills_path.exists() {
42            Some(load_skills_index_for_migration(&skills_path)?)
43        } else {
44            None
45        };
46
47        // Now create the database and migrate.
48        let db = Arc::new(Database::init()?);
49
50        if let Some(config) = legacy_config {
51            db.migrate_from_json(&config)?;
52            archive_legacy_file(&config_path, "migrated")?;
53        }
54
55        if let Some(index) = legacy_skills_index {
56            // Migrate legacy skills index flags into upstream-aligned storage:
57            // - sync method lives in settings.json
58            // - SSOT migration pending lives in DB settings table
59            crate::settings::set_skill_sync_method(index.sync_method)?;
60            db.set_setting(
61                "skills_ssot_migration_pending",
62                if index.ssot_migration_pending {
63                    "true"
64                } else {
65                    "false"
66                },
67            )?;
68
69            // repos
70            for repo in &index.repos {
71                db.save_skill_repo(repo)?;
72            }
73            // installed skills
74            for skill in index.skills.values() {
75                db.save_skill(skill)?;
76            }
77            archive_legacy_file(&skills_path, "migrated")?;
78        }
79
80        // Ensure default repos exist (insert-missing only).
81        let _ = db.init_default_skill_repos();
82
83        let mut config = export_db_to_multi_app_config(&db)?;
84        migrate_legacy_codex_configs(&db, &mut config);
85        crate::services::provider::ProviderService::migrate_common_config_upstream_semantics_if_needed(
86            &db,
87            &mut config,
88        )?;
89        Self::from_parts(db, config)
90    }
91
92    /// 创建新的应用状态,并在真实进程启动路径上执行一次启动恢复。
93    pub fn try_new_with_startup_recovery() -> Result<Self, AppError> {
94        let state = Self::try_new()?;
95
96        state.import_live_provider_configs_on_startup()?;
97
98        if !state
99            .proxy_service
100            .is_running_blocking()
101            .map_err(AppError::Message)?
102        {
103            state
104                .proxy_service
105                .recover_takeovers_on_startup_blocking()
106                .map_err(AppError::Config)?;
107        }
108
109        Ok(state)
110    }
111
112    fn import_live_provider_configs_on_startup(&self) -> Result<(), AppError> {
113        for app_type in crate::app_config::AppType::all().filter(|app| !app.is_additive_mode()) {
114            match crate::services::provider::ProviderService::import_default_config(
115                self,
116                app_type.clone(),
117            ) {
118                Ok(true) => log::info!(
119                    "✓ Imported live config for {} as default provider",
120                    app_type.as_str()
121                ),
122                Ok(false) => log::debug!(
123                    "○ {} already has providers; live import skipped",
124                    app_type.as_str()
125                ),
126                Err(error) => log::debug!(
127                    "○ No live config to import for {}: {error}",
128                    app_type.as_str()
129                ),
130            }
131        }
132
133        match self.db.init_default_official_providers() {
134            Ok(count) if count > 0 => log::info!("✓ Seeded {count} official provider(s)"),
135            Ok(_) => {}
136            Err(error) => log::warn!("✗ Failed to seed official providers: {error}"),
137        }
138
139        match crate::services::provider::ProviderService::import_opencode_providers_from_live(self)
140        {
141            Ok(count) if count > 0 => {
142                log::info!("✓ Imported {count} OpenCode provider(s) from live config");
143            }
144            Ok(_) => log::debug!("○ No new OpenCode providers to import"),
145            Err(error) => log::warn!("✗ Failed to import OpenCode providers: {error}"),
146        }
147
148        match crate::services::provider::ProviderService::import_openclaw_providers_from_live(self)
149        {
150            Ok(count) if count > 0 => {
151                log::info!("✓ Imported {count} OpenClaw provider(s) from live config");
152            }
153            Ok(_) => log::debug!("○ No new OpenClaw providers to import"),
154            Err(error) => log::warn!("✗ Failed to import OpenClaw providers: {error}"),
155        }
156
157        self.refresh_config_from_db()
158    }
159
160    /// 将内存中的 config 快照持久化到 SQLite(SSOT)。
161    pub fn save(&self) -> Result<(), AppError> {
162        let config = self.config.read().map_err(AppError::from)?;
163        persist_multi_app_config_to_db(&self.db, &config)
164    }
165
166    /// 将内存中的 config 快照持久化到 SQLite,但保留指定应用当前供应商的 DB 选择。
167    pub fn save_preserving_current_providers(
168        &self,
169        app_types: &[crate::app_config::AppType],
170    ) -> Result<(), AppError> {
171        let config = self.config.read().map_err(AppError::from)?;
172        persist_multi_app_config_to_db_preserving_current_providers(&self.db, &config, app_types)
173    }
174
175    /// 用数据库中的最新快照重建内存配置,供导入/恢复后的 live 同步流程复用。
176    pub fn refresh_config_from_db(&self) -> Result<(), AppError> {
177        let mut config = export_db_to_multi_app_config(&self.db)?;
178        migrate_legacy_codex_configs(&self.db, &mut config);
179        crate::services::provider::ProviderService::migrate_common_config_upstream_semantics_if_needed(
180            &self.db,
181            &mut config,
182        )?;
183
184        let mut guard = self.config.write().map_err(AppError::from)?;
185        *guard = config;
186        Ok(())
187    }
188
189    fn from_parts(db: Arc<Database>, config: MultiAppConfig) -> Result<Self, AppError> {
190        let proxy_service = ProxyService::new(db.clone());
191
192        Ok(Self {
193            db,
194            config: RwLock::new(config),
195            proxy_service,
196        })
197    }
198}
199
200fn export_db_to_multi_app_config(db: &Database) -> Result<MultiAppConfig, AppError> {
201    use crate::app_config::AppType;
202    use crate::provider::ProviderManager;
203
204    let mut config = MultiAppConfig::default();
205
206    for app in [
207        AppType::Claude,
208        AppType::Codex,
209        AppType::Gemini,
210        AppType::OpenCode,
211        AppType::OpenClaw,
212        AppType::Hermes,
213    ] {
214        let app_key = app.as_str();
215        let providers = db.get_all_providers(app_key)?;
216        let current = db.get_current_provider(app_key)?.unwrap_or_default();
217        let manager = ProviderManager { providers, current };
218        config.apps.insert(app_key.to_string(), manager);
219
220        // prompts
221        let prompts = db.get_prompts(app_key)?;
222        match app {
223            AppType::Claude => config.prompts.claude.prompts = prompts.into_iter().collect(),
224            AppType::Codex => config.prompts.codex.prompts = prompts.into_iter().collect(),
225            AppType::Gemini => config.prompts.gemini.prompts = prompts.into_iter().collect(),
226            AppType::OpenCode => config.prompts.opencode.prompts = prompts.into_iter().collect(),
227            AppType::OpenClaw => config.prompts.openclaw.prompts = prompts.into_iter().collect(),
228            AppType::Hermes => config.prompts.hermes.prompts = prompts.into_iter().collect(),
229        }
230
231        // common snippet
232        let snippet = db.get_config_snippet(app_key)?;
233        config.common_config_snippets.set(&app, snippet);
234    }
235
236    // mcp servers (unified)
237    let servers = db.get_all_mcp_servers()?;
238    config.mcp.servers = Some(servers.into_iter().collect());
239
240    Ok(config)
241}
242
243fn persist_multi_app_config_to_db(db: &Database, config: &MultiAppConfig) -> Result<(), AppError> {
244    persist_multi_app_config_to_db_preserving_current_providers(db, config, &[])
245}
246
247fn persist_multi_app_config_to_db_preserving_current_providers(
248    db: &Database,
249    config: &MultiAppConfig,
250    app_types: &[crate::app_config::AppType],
251) -> Result<(), AppError> {
252    use crate::app_config::AppType;
253
254    let preserved_current_apps = app_types
255        .iter()
256        .map(crate::app_config::AppType::as_str)
257        .collect::<std::collections::HashSet<_>>();
258
259    for app in [
260        AppType::Claude,
261        AppType::Codex,
262        AppType::Gemini,
263        AppType::OpenCode,
264        AppType::OpenClaw,
265        AppType::Hermes,
266    ] {
267        let app_key = app.as_str();
268        let manager = config.get_manager(&app);
269
270        let desired = manager
271            .map(|m| {
272                m.providers
273                    .keys()
274                    .cloned()
275                    .collect::<std::collections::HashSet<_>>()
276            })
277            .unwrap_or_default();
278        let existing = db.get_all_providers(app_key)?;
279
280        // Upsert desired
281        if let Some(m) = manager {
282            for provider in m.providers.values() {
283                db.save_provider(app_key, provider)?;
284            }
285
286            if !preserved_current_apps.contains(app_key) && !m.current.trim().is_empty() {
287                db.set_current_provider(app_key, &m.current)?;
288            }
289        }
290
291        // Delete removed (only within supported apps)
292        for (id, _) in existing.iter() {
293            if !desired.contains(id) {
294                db.delete_provider(app_key, id)?;
295            }
296        }
297
298        // Prompts
299        let desired_prompts = match &app {
300            AppType::Claude => &config.prompts.claude.prompts,
301            AppType::Codex => &config.prompts.codex.prompts,
302            AppType::Gemini => &config.prompts.gemini.prompts,
303            AppType::OpenCode => &config.prompts.opencode.prompts,
304            AppType::OpenClaw => &config.prompts.openclaw.prompts,
305            AppType::Hermes => &config.prompts.hermes.prompts,
306        };
307        let existing_prompts = db.get_prompts(app_key)?;
308        for prompt in desired_prompts.values() {
309            db.save_prompt(app_key, prompt)?;
310        }
311        for (id, _) in existing_prompts.iter() {
312            if !desired_prompts.contains_key(id) {
313                db.delete_prompt(app_key, id)?;
314            }
315        }
316
317        // Common config snippets
318        db.set_config_snippet(app_key, config.common_config_snippets.get(&app).cloned())?;
319    }
320
321    // MCP servers (global, unified)
322    let desired_servers = config.mcp.servers.as_ref().cloned().unwrap_or_default();
323    let existing_servers = db.get_all_mcp_servers()?;
324    for server in desired_servers.values() {
325        db.save_mcp_server(server)?;
326    }
327    for (id, _) in existing_servers.iter() {
328        if !desired_servers.contains_key(id) {
329            db.delete_mcp_server(id)?;
330        }
331    }
332
333    Ok(())
334}
335
336fn load_skills_index_for_migration(
337    path: &Path,
338) -> Result<crate::services::skill::SkillsIndex, AppError> {
339    use crate::services::skill::{InstalledSkill, SkillApps, SkillStore, SkillsIndex, SyncMethod};
340
341    let raw = std::fs::read_to_string(path).map_err(|e| AppError::io(path, e))?;
342    let raw = raw.trim_start_matches('\u{feff}');
343    let value: serde_json::Value =
344        serde_json::from_str(raw).map_err(|e| AppError::json(path, e))?;
345
346    if value.get("version").and_then(|v| v.as_u64()).is_some() {
347        let mut index: SkillsIndex =
348            serde_json::from_value(value).map_err(|e| AppError::json(path, e))?;
349        if index.version == 0 {
350            index.version = 1;
351        }
352        return Ok(index);
353    }
354
355    // Legacy file: SkillStore (Claude-only) -> SkillsIndex
356    let legacy: SkillStore = serde_json::from_value(value).map_err(|e| AppError::json(path, e))?;
357    let mut index = SkillsIndex {
358        version: 1,
359        sync_method: SyncMethod::Auto,
360        repos: legacy.repos,
361        skills: std::collections::HashMap::new(),
362        ssot_migration_pending: true,
363    };
364
365    for (directory, state) in legacy.skills.into_iter() {
366        if !state.installed {
367            continue;
368        }
369        let installed_at = state.installed_at.timestamp();
370        let record = InstalledSkill {
371            id: format!("local:{directory}"),
372            name: directory.clone(),
373            description: None,
374            directory: directory.clone(),
375            readme_url: None,
376            repo_owner: None,
377            repo_name: None,
378            repo_branch: None,
379            apps: SkillApps::only(&crate::app_config::AppType::Claude),
380            installed_at,
381        };
382        index.skills.insert(directory, record);
383    }
384
385    Ok(index)
386}
387
388fn archive_legacy_file(path: &Path, suffix: &str) -> Result<Option<PathBuf>, AppError> {
389    if !path.exists() {
390        return Ok(None);
391    }
392
393    let file_name = path
394        .file_name()
395        .ok_or_else(|| AppError::Config("invalid file name".to_string()))?
396        .to_string_lossy()
397        .to_string();
398
399    let mut candidate = path.with_file_name(format!("{file_name}.{suffix}"));
400    let mut counter: u32 = 1;
401    while candidate.exists() {
402        candidate = path.with_file_name(format!("{file_name}.{suffix}.{counter}"));
403        counter += 1;
404    }
405
406    std::fs::rename(path, &candidate).map_err(|e| AppError::io(path, e))?;
407    Ok(Some(candidate))
408}
409
410/// One-time migration: convert legacy flat Codex configs to the upstream
411/// `model_provider + [model_providers.<key>]` format and persist to DB.
412///
413/// After this runs, all Codex providers in memory and DB use the new format.
414fn migrate_legacy_codex_configs(db: &Database, config: &mut MultiAppConfig) {
415    use crate::app_config::AppType;
416    use crate::services::provider::migrate_legacy_codex_config;
417
418    let manager = match config.get_manager_mut(&AppType::Codex) {
419        Some(m) => m,
420        None => return,
421    };
422
423    for (provider_id, provider) in manager.providers.iter_mut() {
424        let cfg_text = match provider
425            .settings_config
426            .get("config")
427            .and_then(|v| v.as_str())
428        {
429            Some(t) => t,
430            None => continue,
431        };
432
433        if let Some(migrated) = migrate_legacy_codex_config(cfg_text, provider) {
434            // Update in-memory
435            if let Some(obj) = provider.settings_config.as_object_mut() {
436                obj.insert("config".to_string(), serde_json::Value::String(migrated));
437            }
438            // Persist to DB
439            if let Err(e) = db.update_provider_settings_config(
440                AppType::Codex.as_str(),
441                provider_id,
442                &provider.settings_config,
443            ) {
444                log::warn!(
445                    "Failed to persist migrated Codex config for provider '{}': {}",
446                    provider_id,
447                    e
448                );
449            }
450        }
451    }
452}
453
454#[cfg(test)]
455mod tests {
456    use super::AppState;
457    use crate::test_support::{
458        lock_test_home_and_settings, set_test_home_override, TestHomeSettingsLock,
459    };
460    use serde_json::json;
461    use serial_test::serial;
462    use std::ffi::OsString;
463    use std::path::{Path, PathBuf};
464    use tempfile::TempDir;
465
466    struct EnvGuard {
467        _lock: TestHomeSettingsLock,
468        old_home: Option<OsString>,
469        old_userprofile: Option<OsString>,
470        old_config_dir: Option<OsString>,
471    }
472
473    impl EnvGuard {
474        fn set_home(home: &Path) -> Self {
475            let lock = lock_test_home_and_settings();
476            let old_home = std::env::var_os("HOME");
477            let old_userprofile = std::env::var_os("USERPROFILE");
478            let old_config_dir = std::env::var_os("CC_SWITCH_CONFIG_DIR");
479            unsafe {
480                std::env::set_var("HOME", home);
481            }
482            unsafe {
483                std::env::set_var("USERPROFILE", home);
484            }
485            unsafe {
486                std::env::remove_var("CC_SWITCH_CONFIG_DIR");
487            }
488            set_test_home_override(Some(home));
489            crate::settings::reload_test_settings();
490            Self {
491                _lock: lock,
492                old_home,
493                old_userprofile,
494                old_config_dir,
495            }
496        }
497    }
498
499    impl Drop for EnvGuard {
500        fn drop(&mut self) {
501            match &self.old_home {
502                Some(value) => unsafe { std::env::set_var("HOME", value) },
503                None => unsafe { std::env::remove_var("HOME") },
504            }
505            match &self.old_userprofile {
506                Some(value) => unsafe { std::env::set_var("USERPROFILE", value) },
507                None => unsafe { std::env::remove_var("USERPROFILE") },
508            }
509            match &self.old_config_dir {
510                Some(value) => unsafe { std::env::set_var("CC_SWITCH_CONFIG_DIR", value) },
511                None => unsafe { std::env::remove_var("CC_SWITCH_CONFIG_DIR") },
512            }
513            set_test_home_override(self.old_home.as_deref().map(Path::new));
514            crate::settings::reload_test_settings();
515        }
516    }
517
518    fn write_text(path: PathBuf, text: &str) {
519        if let Some(parent) = path.parent() {
520            std::fs::create_dir_all(parent).expect("create parent dir");
521        }
522        std::fs::write(path, text).expect("write text file");
523    }
524
525    fn write_json(path: PathBuf, value: serde_json::Value) {
526        if let Some(parent) = path.parent() {
527            std::fs::create_dir_all(parent).expect("create parent dir");
528        }
529        std::fs::write(
530            path,
531            serde_json::to_string_pretty(&value).expect("serialize json"),
532        )
533        .expect("write json file");
534    }
535
536    #[test]
537    #[serial(home_settings)]
538    fn startup_imports_existing_claude_live_config_as_default_provider() {
539        let temp_home = TempDir::new().expect("create temp home");
540        let _env = EnvGuard::set_home(temp_home.path());
541
542        write_json(
543            crate::config::get_claude_settings_path(),
544            json!({
545                "env": { "ANTHROPIC_API_KEY": "live-key" },
546                "permissions": { "allow": ["Bash"] }
547            }),
548        );
549
550        let state = AppState::try_new_with_startup_recovery().expect("create startup state");
551        let provider = state
552            .db
553            .get_provider_by_id("default", "claude")
554            .expect("read provider")
555            .expect("default provider should be imported");
556
557        assert_eq!(
558            state
559                .db
560                .get_current_provider("claude")
561                .expect("read current provider")
562                .as_deref(),
563            Some("default")
564        );
565        assert_eq!(
566            provider.settings_config["env"]["ANTHROPIC_API_KEY"],
567            json!("live-key")
568        );
569        assert!(provider.settings_config["permissions"]["allow"].is_array());
570        assert!(state
571            .db
572            .get_provider_by_id("claude-official", "claude")
573            .expect("read official provider")
574            .is_some());
575        let config = state.config.read().expect("read refreshed config");
576        let manager = config
577            .get_manager(&crate::app_config::AppType::Claude)
578            .expect("claude manager");
579        assert_eq!(manager.current, "default");
580        assert!(manager.providers.contains_key("default"));
581        assert!(manager.providers.contains_key("claude-official"));
582    }
583
584    #[test]
585    #[serial(home_settings)]
586    fn startup_imports_existing_codex_live_config_as_default_provider() {
587        let temp_home = TempDir::new().expect("create temp home");
588        let _env = EnvGuard::set_home(temp_home.path());
589
590        write_json(
591            crate::codex_config::get_codex_auth_path(),
592            json!({ "OPENAI_API_KEY": "live-codex-key" }),
593        );
594        write_text(
595            crate::codex_config::get_codex_config_path(),
596            r#"model_provider = "legacy"
597model = "gpt-4"
598
599[model_providers.legacy]
600base_url = "https://api.example.com/v1"
601wire_api = "responses"
602"#,
603        );
604
605        let state = AppState::try_new_with_startup_recovery().expect("create startup state");
606        let provider = state
607            .db
608            .get_provider_by_id("default", "codex")
609            .expect("read provider")
610            .expect("default provider should be imported");
611
612        assert_eq!(
613            state
614                .db
615                .get_current_provider("codex")
616                .expect("read current provider")
617                .as_deref(),
618            Some("default")
619        );
620        assert_eq!(
621            provider.settings_config["auth"]["OPENAI_API_KEY"],
622            json!("live-codex-key")
623        );
624        assert!(provider
625            .settings_config
626            .get("config")
627            .and_then(|value| value.as_str())
628            .is_some_and(|text| text.contains("model_provider = \"legacy\"")));
629        assert!(state
630            .db
631            .get_provider_by_id("codex-official", "codex")
632            .expect("read official provider")
633            .is_some());
634        let config = state.config.read().expect("read refreshed config");
635        let manager = config
636            .get_manager(&crate::app_config::AppType::Codex)
637            .expect("codex manager");
638        assert_eq!(manager.current, "default");
639        assert!(manager.providers.contains_key("default"));
640        assert!(manager.providers.contains_key("codex-official"));
641    }
642
643    #[test]
644    #[serial(home_settings)]
645    fn startup_seeds_official_providers_when_live_config_is_absent() {
646        let temp_home = TempDir::new().expect("create temp home");
647        let _env = EnvGuard::set_home(temp_home.path());
648
649        let state = AppState::try_new_with_startup_recovery().expect("create startup state");
650
651        for (app, provider_id, name) in [
652            ("claude", "claude-official", "Claude Official"),
653            ("codex", "codex-official", "OpenAI Official"),
654            ("gemini", "gemini-official", "Google Official"),
655        ] {
656            let provider = state
657                .db
658                .get_provider_by_id(provider_id, app)
659                .expect("read official provider")
660                .unwrap_or_else(|| panic!("{provider_id} should be seeded"));
661            assert_eq!(provider.name, name);
662            assert_eq!(provider.category.as_deref(), Some("official"));
663        }
664    }
665
666    #[test]
667    #[serial(home_settings)]
668    fn import_default_config_runs_when_only_official_seed_exists() {
669        let temp_home = TempDir::new().expect("create temp home");
670        let _env = EnvGuard::set_home(temp_home.path());
671
672        let state = AppState::try_new_with_startup_recovery().expect("create startup state");
673        assert!(state
674            .db
675            .get_provider_by_id("claude-official", "claude")
676            .expect("read official provider")
677            .is_some());
678
679        write_json(
680            crate::config::get_claude_settings_path(),
681            json!({
682                "env": { "ANTHROPIC_API_KEY": "late-live-key" }
683            }),
684        );
685
686        let imported = crate::services::ProviderService::import_default_config(
687            &state,
688            crate::app_config::AppType::Claude,
689        )
690        .expect("import live config");
691
692        assert!(imported);
693        assert_eq!(
694            state
695                .db
696                .get_current_provider("claude")
697                .expect("read current provider")
698                .as_deref(),
699            Some("default")
700        );
701    }
702}