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