use serde::{Deserialize, Serialize}; use std::fs; use std::io::Write; use std::path::PathBuf; use std::sync::{OnceLock, RwLock}; use crate::app_config::AppType; use crate::error::AppError; use crate::services::skill::{SkillStorageLocation, SyncMethod}; /// 自定义端点配置(历史兼容,实际存储在 provider.meta.custom_endpoints) #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct CustomEndpoint { pub url: String, pub added_at: i64, #[serde(skip_serializing_if = "Option::is_none")] pub last_used: Option, } fn default_true() -> bool { true } /// 主页面显示的应用配置 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct VisibleApps { #[serde(default = "default_true")] pub claude: bool, #[serde(default = "default_true")] pub codex: bool, #[serde(default = "default_true")] pub gemini: bool, #[serde(default = "default_true")] pub opencode: bool, #[serde(default = "default_true")] pub openclaw: bool, } impl Default for VisibleApps { fn default() -> Self { Self { claude: true, codex: true, gemini: true, opencode: true, openclaw: true, } } } impl VisibleApps { /// Check if the specified app is visible pub fn is_visible(&self, app: &AppType) -> bool { match app { AppType::Claude => self.claude, AppType::Codex => self.codex, AppType::Gemini => self.gemini, AppType::OpenCode => self.opencode, AppType::OpenClaw => self.openclaw, } } } /// WebDAV 同步状态(持久化同步进度信息) #[derive(Debug, Clone, Serialize, Deserialize, Default)] #[serde(rename_all = "camelCase")] pub struct WebDavSyncStatus { #[serde(default, skip_serializing_if = "Option::is_none")] pub last_sync_at: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_error: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_error_source: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_remote_etag: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_local_manifest_hash: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub last_remote_manifest_hash: Option, } fn default_remote_root() -> String { "cc-switch-sync".to_string() } fn default_profile() -> String { "default".to_string() } /// WebDAV 同步设置 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct WebDavSyncSettings { #[serde(default)] pub enabled: bool, #[serde(default)] pub auto_sync: bool, #[serde(default)] pub base_url: String, #[serde(default)] pub username: String, #[serde(default)] pub password: String, #[serde(default = "default_remote_root")] pub remote_root: String, #[serde(default = "default_profile")] pub profile: String, #[serde(default)] pub status: WebDavSyncStatus, } impl Default for WebDavSyncSettings { fn default() -> Self { Self { enabled: false, auto_sync: false, base_url: String::new(), username: String::new(), password: String::new(), remote_root: default_remote_root(), profile: default_profile(), status: WebDavSyncStatus::default(), } } } impl WebDavSyncSettings { pub fn validate(&self) -> Result<(), crate::error::AppError> { if self.base_url.trim().is_empty() { return Err(crate::error::AppError::localized( "webdav.base_url.required", "WebDAV 地址不能为空", "WebDAV URL is required.", )); } if self.username.trim().is_empty() { return Err(crate::error::AppError::localized( "webdav.username.required", "WebDAV 用户名不能为空", "WebDAV username is required.", )); } Ok(()) } pub fn normalize(&mut self) { self.base_url = self.base_url.trim().to_string(); self.username = self.username.trim().to_string(); self.remote_root = self.remote_root.trim().to_string(); self.profile = self.profile.trim().to_string(); if self.remote_root.is_empty() { self.remote_root = default_remote_root(); } if self.profile.is_empty() { self.profile = default_profile(); } } /// Returns true if all credential fields are blank (no config to persist). fn is_empty(&self) -> bool { self.base_url.is_empty() && self.username.is_empty() && self.password.is_empty() } } /// 应用设置结构 /// /// 存储设备级别设置,保存在本地 `~/.cc-switch/settings.json`,不随数据库同步。 /// 这确保了云同步场景下多设备可以独立运作。 #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct AppSettings { // ===== 设备级 UI 设置 ===== #[serde(default = "default_show_in_tray")] pub show_in_tray: bool, #[serde(default = "default_minimize_to_tray_on_close")] pub minimize_to_tray_on_close: bool, /// 是否启用 Claude 插件联动 #[serde(default)] pub enable_claude_plugin_integration: bool, /// 是否跳过 Claude Code 初次安装确认 #[serde(default)] pub skip_claude_onboarding: bool, /// 是否开机自启 #[serde(default)] pub launch_on_startup: bool, /// 静默启动(程序启动时不显示主窗口,仅托盘运行) #[serde(default)] pub silent_startup: bool, /// 是否在主页面启用本地代理功能(默认关闭) #[serde(default)] pub enable_local_proxy: bool, /// User has confirmed the local proxy first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] pub proxy_confirmed: Option, /// User has confirmed the usage query first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] pub usage_confirmed: Option, /// User has confirmed the stream check first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] pub stream_check_confirmed: Option, /// Whether to show the failover toggle independently on the main page #[serde(default)] pub enable_failover_toggle: bool, /// User has confirmed the failover toggle first-run notice #[serde(default, skip_serializing_if = "Option::is_none")] pub failover_confirmed: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub language: Option, // ===== 主页面显示的应用 ===== #[serde(default, skip_serializing_if = "Option::is_none")] pub visible_apps: Option, // ===== 设备级目录覆盖 ===== #[serde(default, skip_serializing_if = "Option::is_none")] pub claude_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub codex_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub gemini_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub opencode_config_dir: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub openclaw_config_dir: Option, // ===== 当前供应商 ID(设备级)===== /// 当前 Claude 供应商 ID(本地存储,优先于数据库 is_current) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_claude: Option, /// 当前 Codex 供应商 ID(本地存储,优先于数据库 is_current) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_codex: Option, /// 当前 Gemini 供应商 ID(本地存储,优先于数据库 is_current) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_gemini: Option, /// 当前 OpenCode 供应商 ID(本地存储,对 OpenCode 可能无意义,但保持结构一致) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_opencode: Option, /// 当前 OpenClaw 供应商 ID(本地存储,对 OpenClaw 可能无意义,但保持结构一致) #[serde(default, skip_serializing_if = "Option::is_none")] pub current_provider_openclaw: Option, // ===== Skill 同步设置 ===== /// Skill 同步方式:auto(默认,优先 symlink)、symlink、copy #[serde(default)] pub skill_sync_method: SyncMethod, /// Skill 存储位置:cc_switch(默认)或 unified(~/.agents/skills/) #[serde(default)] pub skill_storage_location: SkillStorageLocation, // ===== WebDAV 同步设置 ===== #[serde(default, skip_serializing_if = "Option::is_none")] pub webdav_sync: Option, // ===== WebDAV 备份设置(旧版,保留向后兼容)===== #[serde(default, skip_serializing_if = "Option::is_none")] pub webdav_backup: Option, // ===== 备份策略设置 ===== /// Auto-backup interval in hours (default 24, 0 = disabled) #[serde(default, skip_serializing_if = "Option::is_none")] pub backup_interval_hours: Option, /// Maximum number of backup files to retain (default 10) #[serde(default, skip_serializing_if = "Option::is_none")] pub backup_retain_count: Option, // ===== 终端设置 ===== /// 首选终端应用(可选,默认使用系统默认终端) /// - macOS: "terminal" | "iterm2" | "warp" | "alacritty" | "kitty" | "ghostty" /// - Windows: "cmd" | "powershell" | "wt" (Windows Terminal) /// - Linux: "gnome-terminal" | "konsole" | "xfce4-terminal" | "alacritty" | "kitty" | "ghostty" #[serde(default, skip_serializing_if = "Option::is_none")] pub preferred_terminal: Option, } fn default_show_in_tray() -> bool { true } fn default_minimize_to_tray_on_close() -> bool { true } impl Default for AppSettings { fn default() -> Self { Self { show_in_tray: true, minimize_to_tray_on_close: true, enable_claude_plugin_integration: false, skip_claude_onboarding: false, launch_on_startup: false, silent_startup: false, enable_local_proxy: false, proxy_confirmed: None, usage_confirmed: None, stream_check_confirmed: None, enable_failover_toggle: false, failover_confirmed: None, language: None, visible_apps: None, claude_config_dir: None, codex_config_dir: None, gemini_config_dir: None, opencode_config_dir: None, openclaw_config_dir: None, current_provider_claude: None, current_provider_codex: None, current_provider_gemini: None, current_provider_opencode: None, current_provider_openclaw: None, skill_sync_method: SyncMethod::default(), skill_storage_location: SkillStorageLocation::default(), webdav_sync: None, webdav_backup: None, backup_interval_hours: None, backup_retain_count: None, preferred_terminal: None, } } } impl AppSettings { fn settings_path() -> Option { // settings.json 保留用于旧版本迁移和无数据库场景 Some( crate::config::get_home_dir() .join(".cc-switch") .join("settings.json"), ) } fn normalize_paths(&mut self) { self.claude_config_dir = self .claude_config_dir .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()); self.codex_config_dir = self .codex_config_dir .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()); self.gemini_config_dir = self .gemini_config_dir .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()); self.opencode_config_dir = self .opencode_config_dir .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()); self.openclaw_config_dir = self .openclaw_config_dir .as_ref() .map(|s| s.trim()) .filter(|s| !s.is_empty()) .map(|s| s.to_string()); self.language = self .language .as_ref() .map(|s| s.trim()) .filter(|s| matches!(*s, "en" | "zh" | "ja")) .map(|s| s.to_string()); if let Some(sync) = &mut self.webdav_sync { sync.normalize(); if sync.is_empty() { self.webdav_sync = None; } } } fn load_from_file() -> Self { let Some(path) = Self::settings_path() else { return Self::default(); }; if let Ok(content) = fs::read_to_string(&path) { match serde_json::from_str::(&content) { Ok(mut settings) => { settings.normalize_paths(); settings } Err(err) => { log::warn!( "解析设置文件失败,将使用默认设置。路径: {}, 错误: {}", path.display(), err ); Self::default() } } } else { Self::default() } } } fn save_settings_file(settings: &AppSettings) -> Result<(), AppError> { let mut normalized = settings.clone(); normalized.normalize_paths(); let Some(path) = AppSettings::settings_path() else { return Err(AppError::Config("无法获取用户主目录".to_string())); }; if let Some(parent) = path.parent() { fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; } let json = serde_json::to_string_pretty(&normalized) .map_err(|e| AppError::JsonSerialize { source: e })?; #[cfg(unix)] { use std::fs::OpenOptions; use std::os::unix::fs::OpenOptionsExt; let mut file = OpenOptions::new() .create(true) .write(true) .truncate(true) .mode(0o600) .open(&path) .map_err(|e| AppError::io(&path, e))?; file.write_all(json.as_bytes()) .map_err(|e| AppError::io(&path, e))?; } #[cfg(not(unix))] { fs::write(&path, json).map_err(|e| AppError::io(&path, e))?; } Ok(()) } static SETTINGS_STORE: OnceLock> = OnceLock::new(); fn settings_store() -> &'static RwLock { SETTINGS_STORE.get_or_init(|| RwLock::new(AppSettings::load_from_file())) } fn resolve_override_path(raw: &str) -> PathBuf { if raw == "~" { if let Some(home) = dirs::home_dir() { return home; } } else if let Some(stripped) = raw.strip_prefix("~/") { if let Some(home) = dirs::home_dir() { return home.join(stripped); } } else if let Some(stripped) = raw.strip_prefix("~\\") { if let Some(home) = dirs::home_dir() { return home.join(stripped); } } PathBuf::from(raw) } pub fn get_settings() -> AppSettings { settings_store() .read() .unwrap_or_else(|e| { log::warn!("设置锁已毒化,使用恢复值: {e}"); e.into_inner() }) .clone() } pub fn get_settings_for_frontend() -> AppSettings { let mut settings = get_settings(); if let Some(sync) = &mut settings.webdav_sync { sync.password.clear(); } settings.webdav_backup = None; settings } pub fn update_settings(mut new_settings: AppSettings) -> Result<(), AppError> { new_settings.normalize_paths(); save_settings_file(&new_settings)?; let mut guard = settings_store().write().unwrap_or_else(|e| { log::warn!("设置锁已毒化,使用恢复值: {e}"); e.into_inner() }); *guard = new_settings; Ok(()) } fn mutate_settings(mutator: F) -> Result<(), AppError> where F: FnOnce(&mut AppSettings), { let mut guard = settings_store().write().unwrap_or_else(|e| { log::warn!("设置锁已毒化,使用恢复值: {e}"); e.into_inner() }); let mut next = guard.clone(); mutator(&mut next); next.normalize_paths(); save_settings_file(&next)?; *guard = next; Ok(()) } /// 从文件重新加载设置到内存缓存 /// 用于导入配置等场景,确保内存缓存与文件同步 pub fn reload_settings() -> Result<(), AppError> { let fresh_settings = AppSettings::load_from_file(); let mut guard = settings_store().write().unwrap_or_else(|e| { log::warn!("设置锁已毒化,使用恢复值: {e}"); e.into_inner() }); *guard = fresh_settings; Ok(()) } pub fn get_claude_override_dir() -> Option { let settings = settings_store().read().ok()?; settings .claude_config_dir .as_ref() .map(|p| resolve_override_path(p)) } pub fn get_codex_override_dir() -> Option { let settings = settings_store().read().ok()?; settings .codex_config_dir .as_ref() .map(|p| resolve_override_path(p)) } pub fn get_gemini_override_dir() -> Option { let settings = settings_store().read().ok()?; settings .gemini_config_dir .as_ref() .map(|p| resolve_override_path(p)) } pub fn get_opencode_override_dir() -> Option { let settings = settings_store().read().ok()?; settings .opencode_config_dir .as_ref() .map(|p| resolve_override_path(p)) } pub fn get_openclaw_override_dir() -> Option { let settings = settings_store().read().ok()?; settings .openclaw_config_dir .as_ref() .map(|p| resolve_override_path(p)) } // ===== 当前供应商管理函数 ===== /// 获取指定应用类型的当前供应商 ID(从本地 settings 读取) /// /// 这是设备级别的设置,不随数据库同步。 /// 如果本地没有设置,调用者应该 fallback 到数据库的 `is_current` 字段。 pub fn get_current_provider(app_type: &AppType) -> Option { let settings = settings_store().read().ok()?; match app_type { AppType::Claude => settings.current_provider_claude.clone(), AppType::Codex => settings.current_provider_codex.clone(), AppType::Gemini => settings.current_provider_gemini.clone(), AppType::OpenCode => settings.current_provider_opencode.clone(), AppType::OpenClaw => settings.current_provider_openclaw.clone(), } } /// 设置指定应用类型的当前供应商 ID(保存到本地 settings) /// /// 这是设备级别的设置,不随数据库同步。 /// 传入 `None` 会清除当前供应商设置。 pub fn set_current_provider(app_type: &AppType, id: Option<&str>) -> Result<(), AppError> { let id_owned = id.map(|s| s.to_string()); mutate_settings(|settings| match app_type { AppType::Claude => settings.current_provider_claude = id_owned.clone(), AppType::Codex => settings.current_provider_codex = id_owned.clone(), AppType::Gemini => settings.current_provider_gemini = id_owned.clone(), AppType::OpenCode => settings.current_provider_opencode = id_owned.clone(), AppType::OpenClaw => settings.current_provider_openclaw = id_owned.clone(), }) } /// 获取有效的当前供应商 ID(验证存在性) /// /// 逻辑: /// 1. 从本地 settings 读取当前供应商 ID /// 2. 验证该 ID 在数据库中存在 /// 3. 如果不存在则清理本地 settings,fallback 到数据库的 is_current /// /// 这确保了返回的 ID 一定是有效的(在数据库中存在)。 /// 多设备云同步场景下,配置导入后本地 ID 可能失效,此函数会自动修复。 pub fn get_effective_current_provider( db: &crate::database::Database, app_type: &AppType, ) -> Result, AppError> { // 1. 从本地 settings 读取 if let Some(local_id) = get_current_provider(app_type) { // 2. 验证该 ID 在数据库中存在 let providers = db.get_all_providers(app_type.as_str())?; if providers.contains_key(&local_id) { // 存在,直接返回 return Ok(Some(local_id)); } // 3. 不存在,清理本地 settings log::warn!( "本地 settings 中的供应商 {} ({}) 在数据库中不存在,将清理并 fallback 到数据库", local_id, app_type.as_str() ); let _ = set_current_provider(app_type, None); } // Fallback 到数据库的 is_current db.get_current_provider(app_type.as_str()) } // ===== Skill 同步方式管理函数 ===== /// 获取 Skill 同步方式配置 pub fn get_skill_sync_method() -> SyncMethod { settings_store() .read() .unwrap_or_else(|e| { log::warn!("设置锁已毒化,使用恢复值: {e}"); e.into_inner() }) .skill_sync_method } // ===== Skill 存储位置管理函数 ===== /// 获取 Skill 存储位置配置 pub fn get_skill_storage_location() -> SkillStorageLocation { settings_store() .read() .unwrap_or_else(|e| { log::warn!("设置锁已毒化,使用恢复值: {e}"); e.into_inner() }) .skill_storage_location } /// 设置 Skill 存储位置 pub fn set_skill_storage_location(location: SkillStorageLocation) -> Result<(), AppError> { mutate_settings(|s| { s.skill_storage_location = location; }) } // ===== 备份策略管理函数 ===== /// Get the effective auto-backup interval in hours (default 24) pub fn effective_backup_interval_hours() -> u32 { settings_store() .read() .unwrap_or_else(|e| { log::warn!("设置锁已毒化,使用恢复值: {e}"); e.into_inner() }) .backup_interval_hours .unwrap_or(24) } /// Get the effective backup retain count (default 10, minimum 1) pub fn effective_backup_retain_count() -> usize { settings_store() .read() .unwrap_or_else(|e| { log::warn!("设置锁已毒化,使用恢复值: {e}"); e.into_inner() }) .backup_retain_count .map(|n| (n as usize).max(1)) .unwrap_or(10) } // ===== 终端设置管理函数 ===== /// 获取首选终端应用 pub fn get_preferred_terminal() -> Option { settings_store() .read() .unwrap_or_else(|e| { log::warn!("设置锁已毒化,使用恢复值: {e}"); e.into_inner() }) .preferred_terminal .clone() } // ===== WebDAV 同步设置管理函数 ===== /// 获取 WebDAV 同步设置 pub fn get_webdav_sync_settings() -> Option { settings_store().read().ok()?.webdav_sync.clone() } /// 保存 WebDAV 同步设置 pub fn set_webdav_sync_settings(settings: Option) -> Result<(), AppError> { mutate_settings(|current| { current.webdav_sync = settings; }) } /// 仅更新 WebDAV 同步状态,避免覆写 credentials/root/profile 等字段 pub fn update_webdav_sync_status(status: WebDavSyncStatus) -> Result<(), AppError> { mutate_settings(|current| { if let Some(sync) = current.webdav_sync.as_mut() { sync.status = status; } }) }