Files
CC-Switch/src-tauri/src/services/provider/live.rs
T
YoVinchen 7ad5a76c7a feat(provider): add common config runtime merge to live config sync
Implement runtime merge of common config snippet with provider's custom
config when writing to live configuration files. This enables shared
configuration templates across providers while preserving provider-specific
overrides.

Key changes:
- Add write_live_snapshot_with_merge() function that performs runtime merge
- Support JSON merge for Claude, TOML merge for Codex, and env merge for Gemini
- Update sync_current_to_live() to use the new merge function
- Update provider switch and update operations to use merge function
- Merge rule: customConfig (provider-specific) overrides commonConfig (shared)

The merge is only performed when common_config_enabled is true for the
provider and the corresponding app type.
2026-01-26 14:07:05 +08:00

705 lines
26 KiB
Rust

//! Live configuration operations
//!
//! Handles reading and writing live configuration files for Claude, Codex, and Gemini.
//!
//! ## Common Config Runtime Merge
//!
//! When writing to live files, this module performs runtime merge of:
//! - `customConfig` (provider's settings_config) - provider-specific settings
//! - `commonConfig` (from database settings table) - shared template settings
//!
//! The merge follows the rule: customConfig overrides commonConfig.
use std::collections::HashMap;
use serde_json::{json, Value};
use crate::app_config::AppType;
use crate::codex_config::{get_codex_auth_path, get_codex_config_path};
use crate::config::{delete_file, get_claude_settings_path, read_json_file, write_json_file};
use crate::config_merge::{compute_final_json_config, compute_final_toml_config_str};
use crate::error::AppError;
use crate::provider::Provider;
use crate::services::mcp::McpService;
use crate::store::AppState;
use super::gemini_auth::{
detect_gemini_auth_type, ensure_google_oauth_security_flag, GeminiAuthType,
};
use super::normalize_claude_models_in_value;
/// Live configuration snapshot for backup/restore
#[derive(Clone)]
#[allow(dead_code)]
pub(crate) enum LiveSnapshot {
Claude {
settings: Option<Value>,
},
Codex {
auth: Option<Value>,
config: Option<String>,
},
Gemini {
env: Option<HashMap<String, String>>,
config: Option<Value>,
},
}
impl LiveSnapshot {
#[allow(dead_code)]
pub(crate) fn restore(&self) -> Result<(), AppError> {
match self {
LiveSnapshot::Claude { settings } => {
let path = get_claude_settings_path();
if let Some(value) = settings {
write_json_file(&path, value)?;
} else if path.exists() {
delete_file(&path)?;
}
}
LiveSnapshot::Codex { auth, config } => {
let auth_path = get_codex_auth_path();
let config_path = get_codex_config_path();
if let Some(value) = auth {
write_json_file(&auth_path, value)?;
} else if auth_path.exists() {
delete_file(&auth_path)?;
}
if let Some(text) = config {
crate::config::write_text_file(&config_path, text)?;
} else if config_path.exists() {
delete_file(&config_path)?;
}
}
LiveSnapshot::Gemini { env, .. } => {
use crate::gemini_config::{
get_gemini_env_path, get_gemini_settings_path, write_gemini_env_atomic,
};
let path = get_gemini_env_path();
if let Some(env_map) = env {
write_gemini_env_atomic(env_map)?;
} else if path.exists() {
delete_file(&path)?;
}
let settings_path = get_gemini_settings_path();
match self {
LiveSnapshot::Gemini {
config: Some(cfg), ..
} => {
write_json_file(&settings_path, cfg)?;
}
LiveSnapshot::Gemini { config: None, .. } if settings_path.exists() => {
delete_file(&settings_path)?;
}
_ => {}
}
}
}
Ok(())
}
}
/// Write live configuration snapshot for a provider (raw, without common config merge)
///
/// This function writes the provider's settings_config directly to the live file.
/// Use `write_live_snapshot_with_merge` for runtime merge with common config.
pub(crate) fn write_live_snapshot(app_type: &AppType, provider: &Provider) -> Result<(), AppError> {
write_live_snapshot_internal(app_type, provider, &provider.settings_config)
}
/// Write live configuration snapshot with common config runtime merge
///
/// This function performs runtime merge of:
/// - Provider's settings_config (custom config)
/// - Common config snippet from database (shared template)
///
/// The merge rule is: customConfig overrides commonConfig.
pub(crate) fn write_live_snapshot_with_merge(
state: &AppState,
app_type: &AppType,
provider: &Provider,
) -> Result<(), AppError> {
// Get common config snippet from database
let common_config_snippet = state.db.get_config_snippet(app_type.as_str())?;
// Check if common config is enabled for this app
let common_config_enabled = provider
.meta
.as_ref()
.and_then(|m| {
m.common_config_enabled_by_app
.as_ref()
.and_then(|by_app| match app_type {
AppType::Claude => by_app.claude,
AppType::Codex => by_app.codex,
AppType::Gemini => by_app.gemini,
AppType::OpenCode => None, // OpenCode doesn't support common config
})
.or(m.common_config_enabled)
})
.unwrap_or(false);
// If common config is not enabled or snippet is empty, write raw config
if !common_config_enabled
|| common_config_snippet
.as_ref()
.is_none_or(|s| s.trim().is_empty())
{
return write_live_snapshot(app_type, provider);
}
let snippet = common_config_snippet.unwrap();
// Perform runtime merge based on app type
let final_config = match app_type {
AppType::Claude => {
// Claude uses JSON format
let common_value: Value = serde_json::from_str(&snippet).unwrap_or(json!({}));
compute_final_json_config(&provider.settings_config, &common_value, true)
}
AppType::Codex => {
// Codex uses TOML for config field, JSON for auth field
// Merge only the config field (TOML), keep auth field unchanged
let mut merged_config = provider.settings_config.clone();
if let Some(obj) = merged_config.as_object_mut() {
if let Some(config_str) = obj.get("config").and_then(|v| v.as_str()) {
let (merged_toml, error) =
compute_final_toml_config_str(config_str, &snippet, true);
if let Some(e) = error {
log::warn!("Codex common config merge warning: {e}");
}
obj.insert("config".to_string(), json!(merged_toml));
}
}
merged_config
}
AppType::Gemini => {
// Gemini uses JSON format for env field
let common_value: Value = serde_json::from_str(&snippet).unwrap_or(json!({}));
let mut merged_config = provider.settings_config.clone();
// Merge only the env field
if let (Some(merged_obj), Some(common_obj)) =
(merged_config.as_object_mut(), common_value.as_object())
{
if let Some(merged_env) = merged_obj.get_mut("env") {
if let (Some(merged_env_obj), Some(common_env)) = (
merged_env.as_object_mut(),
common_obj.get("env").and_then(|v| v.as_object()),
) {
// Common env as base, custom env overrides
let mut final_env = common_env.clone();
for (k, v) in merged_env_obj.iter() {
final_env.insert(k.clone(), v.clone());
}
*merged_env = json!(final_env);
}
}
}
merged_config
}
AppType::OpenCode => {
// OpenCode doesn't support common config merge
provider.settings_config.clone()
}
};
log::debug!(
"Writing live config with common config merge for {:?} provider '{}'",
app_type,
provider.id
);
// Write the merged config to live file
let merged_provider = Provider {
settings_config: final_config,
..provider.clone()
};
write_live_snapshot_internal(app_type, &merged_provider, &merged_provider.settings_config)
}
/// Internal function to write live configuration
fn write_live_snapshot_internal(
app_type: &AppType,
provider: &Provider,
config_to_write: &Value,
) -> Result<(), AppError> {
match app_type {
AppType::Claude => {
let path = get_claude_settings_path();
write_json_file(&path, config_to_write)?;
}
AppType::Codex => {
let obj = config_to_write
.as_object()
.ok_or_else(|| AppError::Config("Codex 供应商配置必须是 JSON 对象".to_string()))?;
let auth = obj
.get("auth")
.ok_or_else(|| AppError::Config("Codex 供应商配置缺少 'auth' 字段".to_string()))?;
let config_str = obj.get("config").and_then(|v| v.as_str()).ok_or_else(|| {
AppError::Config("Codex 供应商配置缺少 'config' 字段或不是字符串".to_string())
})?;
let auth_path = get_codex_auth_path();
write_json_file(&auth_path, auth)?;
let config_path = get_codex_config_path();
std::fs::write(&config_path, config_str).map_err(|e| AppError::io(&config_path, e))?;
}
AppType::Gemini => {
// Delegate to write_gemini_live which handles env file writing correctly
// Create a temporary provider with the merged config
let temp_provider = Provider {
settings_config: config_to_write.clone(),
..provider.clone()
};
write_gemini_live(&temp_provider)?;
}
AppType::OpenCode => {
// OpenCode uses additive mode - write provider to config
use crate::opencode_config;
use crate::provider::OpenCodeProviderConfig;
// Defensive check: if settings_config is a full config structure, extract provider fragment
let config_to_write = if let Some(obj) = config_to_write.as_object() {
// Detect full config structure (has $schema or top-level provider field)
if obj.contains_key("$schema") || obj.contains_key("provider") {
log::warn!(
"OpenCode provider '{}' has full config structure in settings_config, attempting to extract fragment",
provider.id
);
// Try to extract from provider.{id}
obj.get("provider")
.and_then(|p| p.get(&provider.id))
.cloned()
.unwrap_or_else(|| provider.settings_config.clone())
} else {
provider.settings_config.clone()
}
} else {
provider.settings_config.clone()
};
// Convert settings_config to OpenCodeProviderConfig
let opencode_config_result =
serde_json::from_value::<OpenCodeProviderConfig>(config_to_write.clone());
match opencode_config_result {
Ok(config) => {
opencode_config::set_typed_provider(&provider.id, &config)?;
log::info!("OpenCode provider '{}' written to live config", provider.id);
}
Err(e) => {
log::warn!(
"Failed to parse OpenCode provider config for '{}': {}",
provider.id,
e
);
// Only write if config looks like a valid provider fragment
if config_to_write.get("npm").is_some()
|| config_to_write.get("options").is_some()
{
opencode_config::set_provider(&provider.id, config_to_write)?;
log::info!(
"OpenCode provider '{}' written as raw JSON to live config",
provider.id
);
} else {
log::error!(
"OpenCode provider '{}' has invalid config structure, skipping write",
provider.id
);
}
}
}
}
}
Ok(())
}
/// Sync current provider to live configuration
///
/// 使用有效的当前供应商 ID(验证过存在性)。
/// 优先从本地 settings 读取,验证后 fallback 到数据库的 is_current 字段。
/// 这确保了配置导入后无效 ID 会自动 fallback 到数据库。
///
/// This function uses `write_live_snapshot_with_merge` to perform runtime merge
/// with common config when enabled.
pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
// Use validated effective current provider
let current_id =
match crate::settings::get_effective_current_provider(&state.db, &app_type)? {
Some(id) => id,
None => continue,
};
let providers = state.db.get_all_providers(app_type.as_str())?;
if let Some(provider) = providers.get(&current_id) {
// Use write_live_snapshot_with_merge to support common config runtime merge
write_live_snapshot_with_merge(state, &app_type, provider)?;
}
// Note: get_effective_current_provider already validates existence,
// so providers.get() should always succeed here
}
// MCP sync
McpService::sync_all_enabled(state)?;
// Skill sync
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
// Continue syncing other apps, don't abort
}
}
Ok(())
}
/// Read current live settings for an app type
pub fn read_live_settings(app_type: AppType) -> Result<Value, AppError> {
match app_type {
AppType::Codex => {
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Err(AppError::localized(
"codex.auth.missing",
"Codex 配置文件不存在:缺少 auth.json",
"Codex configuration missing: auth.json not found",
));
}
let auth: Value = read_json_file(&auth_path)?;
let cfg_text = crate::codex_config::read_and_validate_codex_config_text()?;
Ok(json!({ "auth": auth, "config": cfg_text }))
}
AppType::Claude => {
let path = get_claude_settings_path();
if !path.exists() {
return Err(AppError::localized(
"claude.live.missing",
"Claude Code 配置文件不存在",
"Claude settings file is missing",
));
}
read_json_file(&path)
}
AppType::Gemini => {
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
// Read .env file (environment variables)
let env_path = get_gemini_env_path();
if !env_path.exists() {
return Err(AppError::localized(
"gemini.env.missing",
"Gemini .env 文件不存在",
"Gemini .env file not found",
));
}
let env_map = read_gemini_env()?;
let env_json = env_to_json(&env_map);
let env_obj = env_json.get("env").cloned().unwrap_or_else(|| json!({}));
// Read settings.json file (MCP config etc.)
let settings_path = get_gemini_settings_path();
let config_obj = if settings_path.exists() {
read_json_file(&settings_path)?
} else {
json!({})
};
// Return complete structure: { "env": {...}, "config": {...} }
Ok(json!({
"env": env_obj,
"config": config_obj
}))
}
AppType::OpenCode => {
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
let config_path = get_opencode_config_path();
if !config_path.exists() {
return Err(AppError::localized(
"opencode.config.missing",
"OpenCode 配置文件不存在",
"OpenCode configuration file not found",
));
}
let config = read_opencode_config()?;
Ok(config)
}
}
}
/// Import default configuration from live files
///
/// Returns `Ok(true)` if a provider was actually imported,
/// `Ok(false)` if skipped (providers already exist for this app).
pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool, AppError> {
{
let providers = state.db.get_all_providers(app_type.as_str())?;
if !providers.is_empty() {
return Ok(false); // 已有供应商,跳过
}
}
let settings_config = match app_type {
AppType::Codex => {
let auth_path = get_codex_auth_path();
if !auth_path.exists() {
return Err(AppError::localized(
"codex.live.missing",
"Codex 配置文件不存在",
"Codex configuration file is missing",
));
}
let auth: Value = read_json_file(&auth_path)?;
let config_str = crate::codex_config::read_and_validate_codex_config_text()?;
json!({ "auth": auth, "config": config_str })
}
AppType::Claude => {
let settings_path = get_claude_settings_path();
if !settings_path.exists() {
return Err(AppError::localized(
"claude.live.missing",
"Claude Code 配置文件不存在",
"Claude settings file is missing",
));
}
let mut v = read_json_file::<Value>(&settings_path)?;
let _ = normalize_claude_models_in_value(&mut v);
v
}
AppType::Gemini => {
use crate::gemini_config::{
env_to_json, get_gemini_env_path, get_gemini_settings_path, read_gemini_env,
};
// Read .env file (environment variables)
let env_path = get_gemini_env_path();
if !env_path.exists() {
return Err(AppError::localized(
"gemini.live.missing",
"Gemini 配置文件不存在",
"Gemini configuration file is missing",
));
}
let env_map = read_gemini_env()?;
let env_json = env_to_json(&env_map);
let env_obj = env_json.get("env").cloned().unwrap_or_else(|| json!({}));
// Read settings.json file (MCP config etc.)
let settings_path = get_gemini_settings_path();
let config_obj = if settings_path.exists() {
read_json_file(&settings_path)?
} else {
json!({})
};
// Return complete structure: { "env": {...}, "config": {...} }
json!({
"env": env_obj,
"config": config_obj
})
}
AppType::OpenCode => {
// OpenCode uses additive mode - import from live is not the same pattern
// For now, return an empty config structure
use crate::opencode_config::{get_opencode_config_path, read_opencode_config};
let config_path = get_opencode_config_path();
if !config_path.exists() {
return Err(AppError::localized(
"opencode.live.missing",
"OpenCode 配置文件不存在",
"OpenCode configuration file is missing",
));
}
// For OpenCode, we return the full config - but note that OpenCode
// uses additive mode, so importing defaults works differently
read_opencode_config()?
}
};
let mut provider = Provider::with_id(
"default".to_string(),
"default".to_string(),
settings_config,
None,
);
provider.category = Some("custom".to_string());
state.db.save_provider(app_type.as_str(), &provider)?;
state
.db
.set_current_provider(app_type.as_str(), &provider.id)?;
Ok(true) // 真正导入了
}
/// Write Gemini live configuration with authentication handling
pub(crate) fn write_gemini_live(provider: &Provider) -> Result<(), AppError> {
use crate::gemini_config::{
get_gemini_settings_path, json_to_env, validate_gemini_settings_strict,
write_gemini_env_atomic,
};
// One-time auth type detection to avoid repeated detection
let auth_type = detect_gemini_auth_type(provider);
let mut env_map = json_to_env(&provider.settings_config)?;
// Prepare config to write to ~/.gemini/settings.json
// Behavior:
// - config is object: use it (merge with existing to preserve mcpServers etc.)
// - config is null or absent: preserve existing file content
let settings_path = get_gemini_settings_path();
let mut config_to_write: Option<Value> = None;
if let Some(config_value) = provider.settings_config.get("config") {
if config_value.is_object() {
// Merge with existing settings to preserve mcpServers and other fields
let mut merged = if settings_path.exists() {
read_json_file::<Value>(&settings_path).unwrap_or_else(|_| json!({}))
} else {
json!({})
};
// Merge provider config into existing settings
if let (Some(merged_obj), Some(config_obj)) =
(merged.as_object_mut(), config_value.as_object())
{
for (k, v) in config_obj {
merged_obj.insert(k.clone(), v.clone());
}
}
config_to_write = Some(merged);
} else if !config_value.is_null() {
return Err(AppError::localized(
"gemini.validation.invalid_config",
"Gemini 配置格式错误: config 必须是对象或 null",
"Gemini config invalid: config must be an object or null",
));
}
// config is null: don't modify existing settings.json (preserve mcpServers etc.)
}
// If no config specified or config is null, preserve existing file
if config_to_write.is_none() && settings_path.exists() {
config_to_write = Some(read_json_file(&settings_path)?);
}
match auth_type {
GeminiAuthType::GoogleOfficial => {
// Google official uses OAuth, clear env
env_map.clear();
write_gemini_env_atomic(&env_map)?;
}
GeminiAuthType::Packycode => {
// PackyCode provider, uses API Key (strict validation on switch)
validate_gemini_settings_strict(&provider.settings_config)?;
write_gemini_env_atomic(&env_map)?;
}
GeminiAuthType::Generic => {
// Generic provider, uses API Key (strict validation on switch)
validate_gemini_settings_strict(&provider.settings_config)?;
write_gemini_env_atomic(&env_map)?;
}
}
if let Some(config_value) = config_to_write {
write_json_file(&settings_path, &config_value)?;
}
// Set security.auth.selectedType based on auth type
// - Google Official: OAuth mode
// - All others: API Key mode
match auth_type {
GeminiAuthType::GoogleOfficial => ensure_google_oauth_security_flag(provider)?,
GeminiAuthType::Packycode | GeminiAuthType::Generic => {
crate::gemini_config::write_packycode_settings()?;
}
}
Ok(())
}
/// Remove an OpenCode provider from the live configuration
///
/// This is specific to OpenCode's additive mode - removing a provider
/// from the opencode.json file.
pub(crate) fn remove_opencode_provider_from_live(provider_id: &str) -> Result<(), AppError> {
use crate::opencode_config;
// Check if OpenCode config directory exists
if !opencode_config::get_opencode_dir().exists() {
log::debug!("OpenCode config directory doesn't exist, skipping removal of '{provider_id}'");
return Ok(());
}
opencode_config::remove_provider(provider_id)?;
log::info!("OpenCode provider '{provider_id}' removed from live config");
Ok(())
}
/// Import all providers from OpenCode live config to database
///
/// This imports existing providers from ~/.config/opencode/opencode.json
/// into the CC Switch database. Each provider found will be added to the
/// database with is_current set to false.
pub fn import_opencode_providers_from_live(state: &AppState) -> Result<usize, AppError> {
use crate::opencode_config;
let providers = opencode_config::get_typed_providers()?;
if providers.is_empty() {
return Ok(0);
}
let mut imported = 0;
let existing = state.db.get_all_providers("opencode")?;
for (id, config) in providers {
// Skip if already exists in database
if existing.contains_key(&id) {
log::debug!("OpenCode provider '{id}' already exists in database, skipping");
continue;
}
// Convert to Value for settings_config
let settings_config = match serde_json::to_value(&config) {
Ok(v) => v,
Err(e) => {
log::warn!("Failed to serialize OpenCode provider '{id}': {e}");
continue;
}
};
// Create provider
let provider = Provider::with_id(
id.clone(),
config.name.clone().unwrap_or_else(|| id.clone()),
settings_config,
None,
);
// Save to database
if let Err(e) = state.db.save_provider("opencode", &provider) {
log::warn!("Failed to import OpenCode provider '{id}': {e}");
continue;
}
imported += 1;
log::info!("Imported OpenCode provider '{id}' from live config");
}
Ok(imported)
}