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
8pub struct AppState {
10 pub db: Arc<Database>,
11 pub config: RwLock<MultiAppConfig>,
12 pub proxy_service: ProxyService,
13}
14
15impl AppState {
16 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 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 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 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 for repo in &index.repos {
71 db.save_skill_repo(repo)?;
72 }
73 for skill in index.skills.values() {
75 db.save_skill(skill)?;
76 }
77 archive_legacy_file(&skills_path, "migrated")?;
78 }
79
80 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 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 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 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 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 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 let snippet = db.get_config_snippet(app_key)?;
233 config.common_config_snippets.set(&app, snippet);
234 }
235
236 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 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 for (id, _) in existing.iter() {
293 if !desired.contains(id) {
294 db.delete_provider(app_key, id)?;
295 }
296 }
297
298 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 db.set_config_snippet(app_key, config.common_config_snippets.get(&app).cloned())?;
319 }
320
321 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 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
410fn 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 if let Some(obj) = provider.settings_config.as_object_mut() {
436 obj.insert("config".to_string(), serde_json::Value::String(migrated));
437 }
438 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}