From ebb75f4d6813ee389cd8b2318faae0eab84901d7 Mon Sep 17 00:00:00 2001 From: jiawei666 Date: Fri, 12 Jun 2026 17:05:59 +0800 Subject: [PATCH] feat: auto-seed common config snippets from live files on first setup (Claude + Codex + Gemini) Mirrors the desktop cc-switch initialize_common_config_snippets flow: - On first startup with empty common config, extract shared settings from live config files (permissions, statusLine, hooks, non-auth env, Codex shared TOML tables, Gemini proxy env) into common_config_ snippets. - Non-destructive: default provider retains full original settings. - Gate: skipped if snippet already exists or was explicitly cleared. - OpenCode/Hermes/OpenClaw excluded: their common config merge mechanism is a no-op, and extractors don't match the shape of read_live_settings for additive-mode apps. Co-Authored-By: Claude Opus 4.8 --- src-tauri/src/database/dao/settings.rs | 34 +++ src-tauri/src/database/tests.rs | 40 ++++ src-tauri/src/services/provider/mod.rs | 14 +- src-tauri/src/store.rs | 278 +++++++++++++++++++++++++ 4 files changed, 365 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index ccf305b4..a1b12821 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -74,6 +74,40 @@ impl Database { } } + /// 通用配置片段“已被用户显式清空”标记的键名 + fn config_snippet_cleared_key(app_type: &str) -> String { + format!("common_config_{app_type}_cleared") + } + + /// 通用配置片段是否被用户显式清空 + pub fn is_config_snippet_cleared(&self, app_type: &str) -> Result { + Ok(self + .get_setting(&Self::config_snippet_cleared_key(app_type))? + .as_deref() + == Some("true")) + } + + /// 设置/清除“通用配置片段已被显式清空”标记 + pub fn set_config_snippet_cleared( + &self, + app_type: &str, + cleared: bool, + ) -> Result<(), AppError> { + let key = Self::config_snippet_cleared_key(app_type); + if cleared { + self.set_setting(&key, "true") + } else { + self.delete_setting(&key) + } + } + + /// 当前是否允许从 live 配置自动播种通用配置片段: + /// 片段为空且未被用户显式清空时返回 true + pub fn should_auto_extract_config_snippet(&self, app_type: &str) -> Result { + Ok(self.get_config_snippet(app_type)?.is_none() + && !self.is_config_snippet_cleared(app_type)?) + } + // --- 全局出站代理 --- /// 全局代理 URL 的存储键名 diff --git a/src-tauri/src/database/tests.rs b/src-tauri/src/database/tests.rs index 30667e7c..c124ad73 100644 --- a/src-tauri/src/database/tests.rs +++ b/src-tauri/src/database/tests.rs @@ -1824,3 +1824,43 @@ fn model_pricing_upsert_rejects_invalid_values() { assert!(ModelPricingUpdate::new("bad-negative", "Bad Negative", "-1", "1", "0", "0").is_err()); assert!(ModelPricingUpdate::new("", "Blank Model", "1", "1", "0", "0").is_err()); } + +#[test] +fn should_auto_extract_config_snippet_respects_snippet_and_cleared_flag() { + let db = Database::memory().expect("create memory db"); + + // 全新状态:无片段、未清空 → 允许自动播种 + assert!(db + .should_auto_extract_config_snippet("claude") + .expect("gate")); + assert!(!db.is_config_snippet_cleared("claude").expect("cleared")); + + // 有片段 → 不再自动播种 + db.set_config_snippet("claude", Some("{\"a\":1}".to_string())) + .expect("set snippet"); + assert!(!db + .should_auto_extract_config_snippet("claude") + .expect("gate after set")); + + // 删除片段并标记为已清空 → 仍不自动播种 + db.set_config_snippet("claude", None) + .expect("clear snippet"); + db.set_config_snippet_cleared("claude", true) + .expect("set cleared"); + assert!(db + .is_config_snippet_cleared("claude") + .expect("cleared true")); + assert!(!db + .should_auto_extract_config_snippet("claude") + .expect("gate after cleared")); + + // 取消清空标记 → 重新允许自动播种 + db.set_config_snippet_cleared("claude", false) + .expect("unset cleared"); + assert!(!db + .is_config_snippet_cleared("claude") + .expect("cleared false")); + assert!(db + .should_auto_extract_config_snippet("claude") + .expect("gate after unset")); +} diff --git a/src-tauri/src/services/provider/mod.rs b/src-tauri/src/services/provider/mod.rs index c7248582..4765a3d2 100644 --- a/src-tauri/src/services/provider/mod.rs +++ b/src-tauri/src/services/provider/mod.rs @@ -1532,6 +1532,10 @@ impl ProviderService { }); Self::validate_common_config_snippet(&app_type, normalized_snippet.as_deref())?; + // 清空(None)→ 记录 cleared=true,避免下次启动自动重新播种; + // 设置非空片段 → cleared=false。 + let snippet_cleared = normalized_snippet.is_none(); + let app_type_clone = app_type.clone(); let (effective_current_provider, db_providers) = if app_type.is_additive_mode() { (None, None) @@ -1648,7 +1652,15 @@ impl ProviderService { )?; Ok(((), action)) }, - ) + )?; + + // 与启动期 best-effort 播种不同,这里传播错误:若该标记写入失败, + // 下次启动会误判为"未清空"并把刚清空的片段重新播种回来。 + state + .db + .set_config_snippet_cleared(app_type.as_str(), snippet_cleared)?; + + Ok(()) } pub fn clear_common_config_snippet( diff --git a/src-tauri/src/store.rs b/src-tauri/src/store.rs index 0288ae98..f62734bb 100644 --- a/src-tauri/src/store.rs +++ b/src-tauri/src/store.rs @@ -102,6 +102,8 @@ impl AppState { state.import_live_provider_configs_on_startup()?; + state.initialize_common_config_snippets(); + let owned_managed_session_active = state .proxy_service .should_skip_startup_recovery_for_active_managed_session_blocking() @@ -212,6 +214,89 @@ impl AppState { self.refresh_config_from_db() } + /// 首次配置时从干净的 live 文件自动播种通用配置片段(Claude + Codex + Gemini)。 + /// + /// 仅当某 app 的通用配置为空且未被用户显式清空时执行;读取 live 文件失败或 + /// 无可提取内容时静默跳过。必须在代理接管恢复之前调用,避免读到占位符配置。 + /// + /// OpenCode/Hermes/OpenClaw 不在此列:这三个 app 的通用配置合并机制 + /// (`apply_common_config_to_settings` / `remove_common_config_from_settings`) + /// 是 no-op,自动播种对它们没有实际效果,反而可能把整份 live 配置(含密钥) + /// 复制进 `common_config_`。 + fn initialize_common_config_snippets(&self) { + use crate::app_config::AppType; + use crate::services::provider::ProviderService; + + let mut seeded = false; + + for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] { + match self + .db + .should_auto_extract_config_snippet(app_type.as_str()) + { + Ok(true) => {} + Ok(false) => continue, + Err(error) => { + log::warn!( + "✗ Failed to check auto-extract gate for {}: {error}", + app_type.as_str() + ); + continue; + } + } + + let settings = match ProviderService::read_live_settings(app_type.clone()) { + Ok(settings) => settings, + Err(_) => continue, + }; + + let snippet = match ProviderService::extract_common_config_snippet_from_settings( + app_type.clone(), + &settings, + ) { + Ok(snippet) => snippet, + Err(error) => { + log::warn!( + "✗ Failed to extract common config snippet for {}: {error}", + app_type.as_str() + ); + continue; + } + }; + + if snippet.is_empty() || snippet == "{}" { + log::debug!( + "○ Live config for {} has no extractable common fields", + app_type.as_str() + ); + continue; + } + + match self.db.set_config_snippet(app_type.as_str(), Some(snippet)) { + Ok(()) => { + let _ = self.db.set_config_snippet_cleared(app_type.as_str(), false); + seeded = true; + log::info!( + "✓ Auto-extracted common config snippet for {}", + app_type.as_str() + ); + } + Err(error) => log::warn!( + "✗ Failed to save common config snippet for {}: {error}", + app_type.as_str() + ), + } + } + + if seeded { + if let Err(error) = self.refresh_config_from_db() { + log::warn!( + "✗ Failed to refresh config after seeding common config snippets: {error}" + ); + } + } + } + fn import_live_current_provider_configs_on_startup(&self) -> Result<(), AppError> { for app_type in crate::app_config::AppType::all().filter(|app| !app.is_additive_mode()) { if self @@ -784,4 +869,197 @@ requires_openai_auth = true Some("default") ); } + + #[test] + #[serial(home_settings)] + fn startup_seeds_claude_common_config_snippet_from_live() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = TestEnvGuard::isolated(temp_home.path()); + + write_json( + crate::config::get_claude_settings_path(), + json!({ + "env": { "ANTHROPIC_API_KEY": "live-key", "ANTHROPIC_BASE_URL": "https://x" }, + "permissions": { "allow": ["Bash"] }, + "statusLine": { "type": "command", "command": "echo hi" } + }), + ); + + let state = AppState::try_new_with_startup_recovery().expect("create startup state"); + + let snippet = state + .db + .get_config_snippet("claude") + .expect("read snippet") + .expect("claude snippet should be seeded"); + let parsed: serde_json::Value = serde_json::from_str(&snippet).expect("snippet json"); + assert!(parsed.get("permissions").is_some()); + assert!(parsed.get("statusLine").is_some()); + // 鉴权/endpoint 字段不进通用配置 + assert!(parsed + .get("env") + .and_then(|env| env.get("ANTHROPIC_BASE_URL")) + .is_none()); + // 非破坏式:default 供应商仍保留完整 settings + let provider = state + .db + .get_provider_by_id("default", "claude") + .expect("read provider") + .expect("default provider"); + assert_eq!( + provider.settings_config["env"]["ANTHROPIC_API_KEY"], + json!("live-key") + ); + } + + #[test] + #[serial(home_settings)] + fn startup_seeds_codex_common_config_snippet_from_live() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = TestEnvGuard::isolated(temp_home.path()); + + write_json( + crate::codex_config::get_codex_auth_path(), + json!({ "OPENAI_API_KEY": "live-codex-key" }), + ); + write_text( + crate::codex_config::get_codex_config_path(), + "model_provider = \"legacy\"\nmodel = \"gpt-4\"\n\n[tui]\ntheme = \"dark\"\n\n[model_providers.legacy]\nbase_url = \"https://api.example.com/v1\"\nwire_api = \"responses\"\n", + ); + + let state = AppState::try_new_with_startup_recovery().expect("create startup state"); + + let snippet = state + .db + .get_config_snippet("codex") + .expect("read snippet") + .expect("codex snippet should be seeded"); + assert!(snippet.contains("[tui]")); + assert!(snippet.contains("theme")); + // 供应商专属字段被剥离 + assert!(!snippet.contains("model_providers")); + assert!(!snippet.contains("model =")); + } + + #[test] + #[serial(home_settings)] + fn startup_seeds_gemini_common_config_snippet_from_live() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = TestEnvGuard::isolated(temp_home.path()); + + write_text( + crate::gemini_config::get_gemini_env_path(), + "GEMINI_API_KEY=live-key\nGOOGLE_GEMINI_BASE_URL=https://x\nHTTPS_PROXY=http://proxy.local:8080\n", + ); + + let state = AppState::try_new_with_startup_recovery().expect("create startup state"); + + let snippet = state + .db + .get_config_snippet("gemini") + .expect("read snippet") + .expect("gemini snippet should be seeded"); + let parsed: serde_json::Value = serde_json::from_str(&snippet).expect("snippet json"); + assert_eq!( + parsed.get("HTTPS_PROXY"), + Some(&json!("http://proxy.local:8080")) + ); + // 鉴权/endpoint 字段不进通用配置 + assert!(parsed.get("GEMINI_API_KEY").is_none()); + assert!(parsed.get("GOOGLE_GEMINI_BASE_URL").is_none()); + + // 非破坏式:default 供应商仍保留完整 settings + let provider = state + .db + .get_provider_by_id("default", "gemini") + .expect("read provider") + .expect("default provider"); + assert_eq!( + provider.settings_config["env"]["GEMINI_API_KEY"], + json!("live-key") + ); + } + + #[test] + #[serial(home_settings)] + fn startup_does_not_overwrite_existing_common_config_snippet() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = TestEnvGuard::isolated(temp_home.path()); + + write_json( + crate::config::get_claude_settings_path(), + json!({ "permissions": { "allow": ["Bash"] } }), + ); + + // 首次启动播种,然后用户改成自定义值 + { + let state = AppState::try_new_with_startup_recovery().expect("first startup"); + assert!(state.db.get_config_snippet("claude").unwrap().is_some()); + state + .db + .set_config_snippet("claude", Some("{\"custom\":true}".to_string())) + .expect("override snippet"); + } + + // 再次启动不得覆盖已有片段 + let state = AppState::try_new_with_startup_recovery().expect("second startup"); + let snippet = state.db.get_config_snippet("claude").unwrap().unwrap(); + assert!(snippet.contains("custom")); + } + + #[test] + #[serial(home_settings)] + fn startup_skips_seeding_when_no_common_fields() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = TestEnvGuard::isolated(temp_home.path()); + + // 仅含鉴权/endpoint,无可提取的共享字段 + write_json( + crate::config::get_claude_settings_path(), + json!({ "env": { "ANTHROPIC_API_KEY": "k", "ANTHROPIC_BASE_URL": "u" } }), + ); + + let state = AppState::try_new_with_startup_recovery().expect("startup"); + assert!(state + .db + .get_config_snippet("claude") + .expect("read") + .is_none()); + assert!(!state + .db + .is_config_snippet_cleared("claude") + .expect("cleared flag")); + } + + #[test] + #[serial(home_settings)] + fn startup_does_not_reseed_after_user_clears_snippet() { + let temp_home = TempDir::new().expect("create temp home"); + let _env = TestEnvGuard::isolated(temp_home.path()); + + write_json( + crate::config::get_claude_settings_path(), + json!({ "permissions": { "allow": ["Bash"] } }), + ); + + // 首次启动播种,随后用户清空 + { + let state = AppState::try_new_with_startup_recovery().expect("first startup"); + assert!(state.db.get_config_snippet("claude").unwrap().is_some()); + + crate::services::provider::ProviderService::clear_common_config_snippet( + &state, + crate::app_config::AppType::Claude, + ) + .expect("clear snippet"); + + assert!(state.db.is_config_snippet_cleared("claude").unwrap()); + assert!(state.db.get_config_snippet("claude").unwrap().is_none()); + } + + // 再次启动不得重新播种(用户已显式清空) + let state = AppState::try_new_with_startup_recovery().expect("second startup"); + assert!(state.db.get_config_snippet("claude").unwrap().is_none()); + assert!(state.db.is_config_snippet_cleared("claude").unwrap()); + } }