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 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 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 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 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 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 let snippet = db.get_config_snippet(app_key)?;
244 config.common_config_snippets.set(&app, snippet);
245 }
246
247 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 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 for (id, _) in existing.iter() {
304 if !desired.contains(id) {
305 db.delete_provider(app_key, id)?;
306 }
307 }
308
309 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 db.set_config_snippet(app_key, config.common_config_snippets.get(&app).cloned())?;
330 }
331
332 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 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
421fn 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 if let Some(obj) = provider.settings_config.as_object_mut() {
447 obj.insert("config".to_string(), serde_json::Value::String(migrated));
448 }
449 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}