diff --git a/docs/BACKEND_REFACTOR_PLAN.md b/docs/BACKEND_REFACTOR_PLAN.md index 6e5347a..9c8b75f 100644 --- a/docs/BACKEND_REFACTOR_PLAN.md +++ b/docs/BACKEND_REFACTOR_PLAN.md @@ -90,7 +90,9 @@ - 新增 `services/provider.rs` 并实现 `ProviderService::switch` / `delete`,集中处理供应商切换、回填、MCP 同步等核心业务;命令层改为薄封装并在 `tests/provider_service.rs`、`tests/provider_commands.rs` 中完成成功与失败路径的集成验证。 - 新增 `services/mcp.rs` 提供 `McpService`,封装 MCP 服务器的查询、增删改、启用同步与导入流程;命令层改为参数解析 + 调用服务,`tests/mcp_commands.rs` 直接使用 `McpService` 验证成功与失败路径,阶段三测试继续适配。 - `McpService` 在内部先复制内存快照、释放写锁,再执行文件同步,避免阶段五升级后的 `RwLock` 在 I/O 场景被长时间占用;`upsert/delete/set_enabled/sync_enabled` 均已修正。 - - 仍待拆分的领域服务:配置导入导出、应用设置等命令需进一步抽象,以便统一封装文件 IO 与状态同步后再收尾阶段四。 + - 新增 `services/config.rs` 提供 `ConfigService`,统一处理配置导入导出、备份与 live 同步;命令层迁移至 `commands/import_export.rs`,在落盘操作前释放锁并复用现有集成测试。 + - 新增 `services/speedtest.rs` 并实现 `SpeedtestService::test_endpoints`,将 URL 校验、超时裁剪与网络请求封装在服务层,命令改为薄封装;补充单元测试覆盖空列表与非法 URL 分支。 + - 后续可选:应用设置(Store)命令仍较薄,可按需评估是否抽象;当前阶段四核心服务已基本齐备。 - **阶段 5:锁与阻塞优化 ✅(首轮)** - `AppState` 已由 `Mutex` 切换为 `RwLock`,托盘、命令与测试均按读写语义区分 `read()` / `write()`;`cargo test` 全量通过验证并未破坏现有流程。 - 针对高开销 IO 的配置导入/导出命令提取 `load_config_for_import`,并通过 `tauri::async_runtime::spawn_blocking` 将文件读写与备份迁至阻塞线程,保持命令处理线程轻量。 diff --git a/src-tauri/src/commands/import_export.rs b/src-tauri/src/commands/import_export.rs new file mode 100644 index 0000000..f0070ef --- /dev/null +++ b/src-tauri/src/commands/import_export.rs @@ -0,0 +1,104 @@ +#![allow(non_snake_case)] + +use serde_json::{json, Value}; +use std::path::PathBuf; +use tauri::State; +use tauri_plugin_dialog::DialogExt; + +use crate::error::AppError; +use crate::services::ConfigService; +use crate::store::AppState; + +/// 导出配置文件 +#[tauri::command] +pub async fn export_config_to_file(file_path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + let target_path = PathBuf::from(&file_path); + ConfigService::export_config_to_path(&target_path)?; + Ok::<_, AppError>(json!({ + "success": true, + "message": "Configuration exported successfully", + "filePath": file_path + })) + }) + .await + .map_err(|e| format!("导出配置失败: {}", e))? + .map_err(|e: AppError| e.to_string()) +} + +/// 从文件导入配置 +#[tauri::command] +pub async fn import_config_from_file( + file_path: String, + state: State<'_, AppState>, +) -> Result { + let (new_config, backup_id) = tauri::async_runtime::spawn_blocking(move || { + let path_buf = PathBuf::from(&file_path); + ConfigService::load_config_for_import(&path_buf) + }) + .await + .map_err(|e| format!("导入配置失败: {}", e))? + .map_err(|e: AppError| e.to_string())?; + + { + let mut guard = state + .config + .write() + .map_err(|e| AppError::from(e).to_string())?; + *guard = new_config; + } + + Ok(json!({ + "success": true, + "message": "Configuration imported successfully", + "backupId": backup_id + })) +} + +/// 同步当前供应商配置到对应的 live 文件 +#[tauri::command] +pub async fn sync_current_providers_live(state: State<'_, AppState>) -> Result { + { + let mut config_state = state + .config + .write() + .map_err(|e| AppError::from(e).to_string())?; + ConfigService::sync_current_providers_to_live(&mut config_state) + .map_err(|e| e.to_string())?; + } + + Ok(json!({ + "success": true, + "message": "Live configuration synchronized" + })) +} + +/// 保存文件对话框 +#[tauri::command] +pub async fn save_file_dialog( + app: tauri::AppHandle, + default_name: String, +) -> Result, String> { + let dialog = app.dialog(); + let result = dialog + .file() + .add_filter("JSON", &["json"]) + .set_file_name(&default_name) + .blocking_save_file(); + + Ok(result.map(|p| p.to_string())) +} + +/// 打开文件对话框 +#[tauri::command] +pub async fn open_file_dialog( + app: tauri::AppHandle, +) -> Result, String> { + let dialog = app.dialog(); + let result = dialog + .file() + .add_filter("JSON", &["json"]) + .blocking_pick_file(); + + Ok(result.map(|p| p.to_string())) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 224b88c..170dc7e 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] mod config; +mod import_export; mod mcp; mod misc; mod plugin; @@ -8,6 +9,7 @@ mod provider; mod settings; pub use config::*; +pub use import_export::*; pub use mcp::*; pub use misc::*; pub use plugin::*; diff --git a/src-tauri/src/commands/provider.rs b/src-tauri/src/commands/provider.rs index ae0375a..4de9f54 100644 --- a/src-tauri/src/commands/provider.rs +++ b/src-tauri/src/commands/provider.rs @@ -10,8 +10,7 @@ use crate::codex_config; use crate::config::get_claude_settings_path; use crate::error::AppError; use crate::provider::{Provider, ProviderMeta}; -use crate::services::ProviderService; -use crate::speedtest; +use crate::services::{EndpointLatency, ProviderService, SpeedtestService}; use crate::store::AppState; fn validate_provider_settings(app_type: &AppType, provider: &Provider) -> Result<(), String> { @@ -572,12 +571,8 @@ pub async fn read_live_provider_settings( pub async fn test_api_endpoints( urls: Vec, timeout_secs: Option, -) -> Result, String> { - let filtered: Vec = urls - .into_iter() - .filter(|url| !url.trim().is_empty()) - .collect(); - speedtest::test_endpoints(filtered, timeout_secs) +) -> Result, String> { + SpeedtestService::test_endpoints(urls, timeout_secs) .await .map_err(|e| e.to_string()) } diff --git a/src-tauri/src/import_export.rs b/src-tauri/src/import_export.rs deleted file mode 100644 index 6489032..0000000 --- a/src-tauri/src/import_export.rs +++ /dev/null @@ -1,323 +0,0 @@ -use crate::app_config::{AppType, MultiAppConfig}; -use crate::error::AppError; -use crate::provider::Provider; -use chrono::Utc; -use serde_json::{json, Value}; -use std::fs; -use std::path::{Path, PathBuf}; - -// 默认仅保留最近 10 份备份,避免目录无限膨胀 -const MAX_BACKUPS: usize = 10; - -/// 创建配置文件备份 -pub fn create_backup(config_path: &PathBuf) -> Result { - if !config_path.exists() { - return Ok(String::new()); - } - - let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); - let backup_id = format!("backup_{}", timestamp); - - let backup_dir = config_path - .parent() - .ok_or_else(|| AppError::Config("Invalid config path".into()))? - .join("backups"); - - // 创建备份目录 - fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; - - let backup_path = backup_dir.join(format!("{}.json", backup_id)); - - let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?; - fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?; - - // 备份完成后清理旧的备份文件(仅保留最近 MAX_BACKUPS 份) - cleanup_old_backups(&backup_dir, MAX_BACKUPS)?; - - Ok(backup_id) -} - -fn cleanup_old_backups(backup_dir: &PathBuf, retain: usize) -> Result<(), AppError> { - if retain == 0 { - return Ok(()); - } - - let mut entries: Vec<_> = match fs::read_dir(backup_dir) { - Ok(iter) => iter - .filter_map(|entry| entry.ok()) - .filter(|entry| { - entry - .path() - .extension() - .map(|ext| ext == "json") - .unwrap_or(false) - }) - .collect(), - Err(_) => return Ok(()), - }; - - if entries.len() <= retain { - return Ok(()); - } - - let remove_count = entries.len().saturating_sub(retain); - - entries.sort_by(|a, b| { - let a_time = a.metadata().and_then(|m| m.modified()).ok(); - let b_time = b.metadata().and_then(|m| m.modified()).ok(); - a_time.cmp(&b_time) - }); - - for entry in entries.into_iter().take(remove_count) { - if let Err(err) = fs::remove_file(entry.path()) { - log::warn!( - "Failed to remove old backup {}: {}", - entry.path().display(), - err - ); - } - } - - Ok(()) -} - -pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> { - sync_current_provider_for_app(config, &AppType::Claude)?; - sync_current_provider_for_app(config, &AppType::Codex)?; - Ok(()) -} - -fn sync_current_provider_for_app( - config: &mut MultiAppConfig, - app_type: &AppType, -) -> Result<(), AppError> { - let (current_id, provider) = { - let manager = match config.get_manager(app_type) { - Some(manager) => manager, - None => return Ok(()), - }; - - if manager.current.is_empty() { - return Ok(()); - } - - let current_id = manager.current.clone(); - let provider = match manager.providers.get(¤t_id) { - Some(provider) => provider.clone(), - None => { - log::warn!( - "当前应用 {:?} 的供应商 {} 不存在,跳过 live 同步", - app_type, - current_id - ); - return Ok(()); - } - }; - (current_id, provider) - }; - - match app_type { - AppType::Codex => sync_codex_live(config, ¤t_id, &provider)?, - AppType::Claude => sync_claude_live(config, ¤t_id, &provider)?, - } - - Ok(()) -} - -fn sync_codex_live( - config: &mut MultiAppConfig, - provider_id: &str, - provider: &Provider, -) -> Result<(), AppError> { - use serde_json::Value; - - let settings = provider.settings_config.as_object().ok_or_else(|| { - AppError::Config(format!("供应商 {} 的 Codex 配置必须是对象", provider_id)) - })?; - let auth = settings.get("auth").ok_or_else(|| { - AppError::Config(format!( - "供应商 {} 的 Codex 配置缺少 auth 字段", - provider_id - )) - })?; - if !auth.is_object() { - return Err(AppError::Config(format!( - "供应商 {} 的 Codex auth 配置必须是 JSON 对象", - provider_id - ))); - } - let cfg_text = settings.get("config").and_then(Value::as_str); - - crate::codex_config::write_codex_live_atomic(auth, cfg_text)?; - crate::mcp::sync_enabled_to_codex(config)?; - - let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?; - if let Some(manager) = config.get_manager_mut(&AppType::Codex) { - if let Some(target) = manager.providers.get_mut(provider_id) { - if let Some(obj) = target.settings_config.as_object_mut() { - obj.insert( - "config".to_string(), - serde_json::Value::String(cfg_text_after), - ); - } - } - } - - Ok(()) -} - -fn sync_claude_live( - config: &mut MultiAppConfig, - provider_id: &str, - provider: &Provider, -) -> Result<(), AppError> { - use crate::config::{read_json_file, write_json_file}; - - let settings_path = crate::config::get_claude_settings_path(); - if let Some(parent) = settings_path.parent() { - std::fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; - } - - write_json_file(&settings_path, &provider.settings_config)?; - - let live_after = read_json_file::(&settings_path)?; - if let Some(manager) = config.get_manager_mut(&AppType::Claude) { - if let Some(target) = manager.providers.get_mut(provider_id) { - target.settings_config = live_after; - } - } - - Ok(()) -} - -/// 导出配置文件 -#[tauri::command] -pub async fn export_config_to_file(file_path: String) -> Result { - tauri::async_runtime::spawn_blocking(move || { - let config_path = crate::config::get_app_config_path(); - let config_content = - fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; - - let target_path = PathBuf::from(&file_path); - fs::write(&target_path, &config_content).map_err(|e| AppError::io(&target_path, e))?; - - Ok::<_, AppError>(json!({ - "success": true, - "message": "Configuration exported successfully", - "filePath": file_path - })) - }) - .await - .map_err(|e| format!("导出配置失败: {}", e))? - .map_err(|e: AppError| e.to_string()) -} - -/// 从文件导入配置 -#[tauri::command] -pub async fn import_config_from_file( - file_path: String, - state: tauri::State<'_, crate::store::AppState>, -) -> Result { - let path_buf = PathBuf::from(&file_path); - let (new_config, backup_id) = - tauri::async_runtime::spawn_blocking(move || load_config_for_import(&path_buf)) - .await - .map_err(|e| format!("导入配置失败: {}", e))? - .map_err(|e| e.to_string())?; - - { - let mut guard = state - .config - .write() - .map_err(|e| AppError::from(e).to_string())?; - *guard = new_config; - } - - Ok(json!({ - "success": true, - "message": "Configuration imported successfully", - "backupId": backup_id - })) -} - -/// 从文件导入配置的核心逻辑,供命令及测试复用。 -pub fn import_config_from_path( - file_path: &Path, - state: &crate::store::AppState, -) -> Result { - let (new_config, backup_id) = load_config_for_import(file_path)?; - - { - let mut guard = state.config.write().map_err(AppError::from)?; - *guard = new_config; - } - - Ok(backup_id) -} - -fn load_config_for_import(file_path: &Path) -> Result<(MultiAppConfig, String), AppError> { - let import_content = fs::read_to_string(file_path).map_err(|e| AppError::io(file_path, e))?; - - let new_config: crate::app_config::MultiAppConfig = - serde_json::from_str(&import_content).map_err(|e| AppError::json(file_path, e))?; - - let config_path = crate::config::get_app_config_path(); - let backup_id = create_backup(&config_path)?; - - fs::write(&config_path, &import_content).map_err(|e| AppError::io(&config_path, e))?; - - Ok((new_config, backup_id)) -} - -/// 同步当前供应商配置到对应的 live 文件 -#[tauri::command] -pub async fn sync_current_providers_live( - state: tauri::State<'_, crate::store::AppState>, -) -> Result { - { - let mut config_state = state - .config - .write() - .map_err(|e| AppError::from(e).to_string())?; - sync_current_providers_to_live(&mut config_state).map_err(|e| e.to_string())?; - } - - Ok(json!({ - "success": true, - "message": "Live configuration synchronized" - })) -} - -/// 保存文件对话框 -#[tauri::command] -pub async fn save_file_dialog( - app: tauri::AppHandle, - default_name: String, -) -> Result, String> { - use tauri_plugin_dialog::DialogExt; - - let dialog = app.dialog(); - let result = dialog - .file() - .add_filter("JSON", &["json"]) - .set_file_name(&default_name) - .blocking_save_file(); - - Ok(result.map(|p| p.to_string())) -} - -/// 打开文件对话框 -#[tauri::command] -pub async fn open_file_dialog( - app: tauri::AppHandle, -) -> Result, String> { - use tauri_plugin_dialog::DialogExt; - - let dialog = app.dialog(); - let result = dialog - .file() - .add_filter("JSON", &["json"]) - .blocking_pick_file(); - - Ok(result.map(|p| p.to_string())) -} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a1c8177..5eaadde 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -6,13 +6,11 @@ mod codex_config; mod commands; mod config; mod error; -mod import_export; mod mcp; mod migration; mod provider; mod services; mod settings; -mod speedtest; mod store; mod usage_script; @@ -21,14 +19,11 @@ pub use codex_config::{get_codex_auth_path, get_codex_config_path, write_codex_l pub use commands::*; pub use config::{get_claude_mcp_path, get_claude_settings_path, read_json_file}; pub use error::AppError; -pub use import_export::{ - create_backup, export_config_to_file, import_config_from_path, sync_current_providers_to_live, -}; pub use mcp::{ import_from_claude, import_from_codex, sync_enabled_to_claude, sync_enabled_to_codex, }; pub use provider::Provider; -pub use services::{McpService, ProviderService}; +pub use services::{ConfigService, EndpointLatency, McpService, ProviderService, SpeedtestService}; pub use settings::{update_settings, AppSettings}; pub use store::AppState; @@ -526,11 +521,11 @@ pub fn run() { // provider sort order management commands::update_providers_sort_order, // theirs: config import/export and dialogs - import_export::export_config_to_file, - import_export::import_config_from_file, - import_export::save_file_dialog, - import_export::open_file_dialog, - import_export::sync_current_providers_live, + commands::export_config_to_file, + commands::import_config_from_file, + commands::save_file_dialog, + commands::open_file_dialog, + commands::sync_current_providers_live, update_tray_menu, ]); diff --git a/src-tauri/src/services/config.rs b/src-tauri/src/services/config.rs new file mode 100644 index 0000000..4036725 --- /dev/null +++ b/src-tauri/src/services/config.rs @@ -0,0 +1,229 @@ +use crate::app_config::{AppType, MultiAppConfig}; +use crate::error::AppError; +use crate::provider::Provider; +use crate::store::AppState; +use chrono::Utc; +use serde_json::Value; +use std::fs; +use std::path::Path; + +const MAX_BACKUPS: usize = 10; + +/// 配置导入导出相关业务逻辑 +pub struct ConfigService; + +impl ConfigService { + /// 为当前 config.json 创建备份,返回备份 ID(若文件不存在则返回空字符串)。 + pub fn create_backup(config_path: &Path) -> Result { + if !config_path.exists() { + return Ok(String::new()); + } + + let timestamp = Utc::now().format("%Y%m%d_%H%M%S"); + let backup_id = format!("backup_{}", timestamp); + + let backup_dir = config_path + .parent() + .ok_or_else(|| AppError::Config("Invalid config path".into()))? + .join("backups"); + + fs::create_dir_all(&backup_dir).map_err(|e| AppError::io(&backup_dir, e))?; + + let backup_path = backup_dir.join(format!("{}.json", backup_id)); + let contents = fs::read(config_path).map_err(|e| AppError::io(config_path, e))?; + fs::write(&backup_path, contents).map_err(|e| AppError::io(&backup_path, e))?; + + Self::cleanup_old_backups(&backup_dir, MAX_BACKUPS)?; + + Ok(backup_id) + } + + fn cleanup_old_backups(backup_dir: &Path, retain: usize) -> Result<(), AppError> { + if retain == 0 { + return Ok(()); + } + + let entries = match fs::read_dir(backup_dir) { + Ok(iter) => iter + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry + .path() + .extension() + .map(|ext| ext == "json") + .unwrap_or(false) + }) + .collect::>(), + Err(_) => return Ok(()), + }; + + if entries.len() <= retain { + return Ok(()); + } + + let remove_count = entries.len().saturating_sub(retain); + let mut sorted = entries; + + sorted.sort_by(|a, b| { + let a_time = a.metadata().and_then(|m| m.modified()).ok(); + let b_time = b.metadata().and_then(|m| m.modified()).ok(); + a_time.cmp(&b_time) + }); + + for entry in sorted.into_iter().take(remove_count) { + if let Err(err) = fs::remove_file(entry.path()) { + log::warn!( + "Failed to remove old backup {}: {}", + entry.path().display(), + err + ); + } + } + + Ok(()) + } + + /// 将当前 config.json 拷贝到目标路径。 + pub fn export_config_to_path(target_path: &Path) -> Result<(), AppError> { + let config_path = crate::config::get_app_config_path(); + let config_content = + fs::read_to_string(&config_path).map_err(|e| AppError::io(&config_path, e))?; + fs::write(target_path, config_content).map_err(|e| AppError::io(target_path, e)) + } + + /// 从磁盘文件加载配置并写回 config.json,返回备份 ID 及新配置。 + pub fn load_config_for_import(file_path: &Path) -> Result<(MultiAppConfig, String), AppError> { + let import_content = + fs::read_to_string(file_path).map_err(|e| AppError::io(file_path, e))?; + + let new_config: MultiAppConfig = + serde_json::from_str(&import_content).map_err(|e| AppError::json(file_path, e))?; + + let config_path = crate::config::get_app_config_path(); + let backup_id = Self::create_backup(&config_path)?; + + fs::write(&config_path, &import_content).map_err(|e| AppError::io(&config_path, e))?; + + Ok((new_config, backup_id)) + } + + /// 将外部配置文件内容加载并写入应用状态。 + pub fn import_config_from_path(file_path: &Path, state: &AppState) -> Result { + let (new_config, backup_id) = Self::load_config_for_import(file_path)?; + + { + let mut guard = state.config.write().map_err(AppError::from)?; + *guard = new_config; + } + + Ok(backup_id) + } + + /// 同步当前供应商到对应的 live 配置。 + pub fn sync_current_providers_to_live(config: &mut MultiAppConfig) -> Result<(), AppError> { + Self::sync_current_provider_for_app(config, &AppType::Claude)?; + Self::sync_current_provider_for_app(config, &AppType::Codex)?; + Ok(()) + } + + fn sync_current_provider_for_app( + config: &mut MultiAppConfig, + app_type: &AppType, + ) -> Result<(), AppError> { + let (current_id, provider) = { + let manager = match config.get_manager(app_type) { + Some(manager) => manager, + None => return Ok(()), + }; + + if manager.current.is_empty() { + return Ok(()); + } + + let current_id = manager.current.clone(); + let provider = match manager.providers.get(¤t_id) { + Some(provider) => provider.clone(), + None => { + log::warn!( + "当前应用 {:?} 的供应商 {} 不存在,跳过 live 同步", + app_type, + current_id + ); + return Ok(()); + } + }; + (current_id, provider) + }; + + match app_type { + AppType::Codex => Self::sync_codex_live(config, ¤t_id, &provider)?, + AppType::Claude => Self::sync_claude_live(config, ¤t_id, &provider)?, + } + + Ok(()) + } + + fn sync_codex_live( + config: &mut MultiAppConfig, + provider_id: &str, + provider: &Provider, + ) -> Result<(), AppError> { + let settings = provider.settings_config.as_object().ok_or_else(|| { + AppError::Config(format!("供应商 {} 的 Codex 配置必须是对象", provider_id)) + })?; + let auth = settings.get("auth").ok_or_else(|| { + AppError::Config(format!( + "供应商 {} 的 Codex 配置缺少 auth 字段", + provider_id + )) + })?; + if !auth.is_object() { + return Err(AppError::Config(format!( + "供应商 {} 的 Codex auth 配置必须是 JSON 对象", + provider_id + ))); + } + let cfg_text = settings.get("config").and_then(Value::as_str); + + crate::codex_config::write_codex_live_atomic(auth, cfg_text)?; + crate::mcp::sync_enabled_to_codex(config)?; + + let cfg_text_after = crate::codex_config::read_and_validate_codex_config_text()?; + if let Some(manager) = config.get_manager_mut(&AppType::Codex) { + if let Some(target) = manager.providers.get_mut(provider_id) { + if let Some(obj) = target.settings_config.as_object_mut() { + obj.insert( + "config".to_string(), + serde_json::Value::String(cfg_text_after), + ); + } + } + } + + Ok(()) + } + + fn sync_claude_live( + config: &mut MultiAppConfig, + provider_id: &str, + provider: &Provider, + ) -> Result<(), AppError> { + use crate::config::{read_json_file, write_json_file}; + + let settings_path = crate::config::get_claude_settings_path(); + if let Some(parent) = settings_path.parent() { + fs::create_dir_all(parent).map_err(|e| AppError::io(parent, e))?; + } + + write_json_file(&settings_path, &provider.settings_config)?; + + let live_after = read_json_file::(&settings_path)?; + if let Some(manager) = config.get_manager_mut(&AppType::Claude) { + if let Some(target) = manager.providers.get_mut(provider_id) { + target.settings_config = live_after; + } + } + + Ok(()) + } +} diff --git a/src-tauri/src/services/mod.rs b/src-tauri/src/services/mod.rs index 57d9b2d..1e64211 100644 --- a/src-tauri/src/services/mod.rs +++ b/src-tauri/src/services/mod.rs @@ -1,5 +1,9 @@ +pub mod config; pub mod mcp; pub mod provider; +pub mod speedtest; +pub use config::ConfigService; pub use mcp::McpService; pub use provider::ProviderService; +pub use speedtest::{EndpointLatency, SpeedtestService}; diff --git a/src-tauri/src/services/speedtest.rs b/src-tauri/src/services/speedtest.rs new file mode 100644 index 0000000..3264966 --- /dev/null +++ b/src-tauri/src/services/speedtest.rs @@ -0,0 +1,168 @@ +use futures::future::join_all; +use reqwest::{Client, Url}; +use serde::Serialize; +use std::time::{Duration, Instant}; + +use crate::error::AppError; + +const DEFAULT_TIMEOUT_SECS: u64 = 8; +const MAX_TIMEOUT_SECS: u64 = 30; +const MIN_TIMEOUT_SECS: u64 = 2; + +/// 端点测速结果 +#[derive(Debug, Clone, Serialize)] +pub struct EndpointLatency { + pub url: String, + pub latency: Option, + pub status: Option, + pub error: Option, +} + +/// 网络测速相关业务 +pub struct SpeedtestService; + +impl SpeedtestService { + /// 测试一组端点的响应延迟。 + pub async fn test_endpoints( + urls: Vec, + timeout_secs: Option, + ) -> Result, AppError> { + if urls.is_empty() { + return Ok(vec![]); + } + + let timeout = Self::sanitize_timeout(timeout_secs); + let client = Self::build_client(timeout)?; + + let tasks = urls.into_iter().map(|raw_url| { + let client = client.clone(); + async move { + let trimmed = raw_url.trim().to_string(); + if trimmed.is_empty() { + return EndpointLatency { + url: raw_url, + latency: None, + status: None, + error: Some("URL 不能为空".to_string()), + }; + } + + let parsed_url = match Url::parse(&trimmed) { + Ok(url) => url, + Err(err) => { + return EndpointLatency { + url: trimmed, + latency: None, + status: None, + error: Some(format!("URL 无效: {err}")), + }; + } + }; + + // 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。 + let _ = client.get(parsed_url.clone()).send().await; + + // 第二次请求开始计时,并将其作为结果返回。 + let start = Instant::now(); + match client.get(parsed_url).send().await { + Ok(resp) => EndpointLatency { + url: trimmed, + latency: Some(start.elapsed().as_millis()), + status: Some(resp.status().as_u16()), + error: None, + }, + Err(err) => { + let status = err.status().map(|s| s.as_u16()); + let error_message = if err.is_timeout() { + "请求超时".to_string() + } else if err.is_connect() { + "连接失败".to_string() + } else { + err.to_string() + }; + + EndpointLatency { + url: trimmed, + latency: None, + status, + error: Some(error_message), + } + } + } + } + }); + + Ok(join_all(tasks).await) + } + + fn build_client(timeout_secs: u64) -> Result { + Client::builder() + .timeout(Duration::from_secs(timeout_secs)) + .redirect(reqwest::redirect::Policy::limited(5)) + .user_agent("cc-switch-speedtest/1.0") + .build() + .map_err(|e| AppError::Message(format!("创建 HTTP 客户端失败: {e}"))) + } + + fn sanitize_timeout(timeout_secs: Option) -> u64 { + let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS); + secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sanitize_timeout_clamps_values() { + assert_eq!( + SpeedtestService::sanitize_timeout(Some(1)), + MIN_TIMEOUT_SECS + ); + assert_eq!( + SpeedtestService::sanitize_timeout(Some(999)), + MAX_TIMEOUT_SECS + ); + assert_eq!( + SpeedtestService::sanitize_timeout(Some(10)), + 10.min(MAX_TIMEOUT_SECS).max(MIN_TIMEOUT_SECS) + ); + assert_eq!( + SpeedtestService::sanitize_timeout(None), + DEFAULT_TIMEOUT_SECS + ); + } + + #[test] + fn test_endpoints_handles_empty_list() { + let result = + tauri::async_runtime::block_on(SpeedtestService::test_endpoints(Vec::new(), Some(5))) + .expect("empty list should succeed"); + assert!(result.is_empty()); + } + + #[test] + fn test_endpoints_reports_invalid_url() { + let result = tauri::async_runtime::block_on(SpeedtestService::test_endpoints( + vec!["not a url".into(), "".into()], + None, + )) + .expect("invalid inputs should still succeed"); + + assert_eq!(result.len(), 2); + assert!( + result[0] + .error + .as_deref() + .unwrap_or_default() + .starts_with("URL 无效"), + "invalid url should yield parse error" + ); + assert_eq!( + result[1].error.as_deref(), + Some("URL 不能为空"), + "empty url should report validation error" + ); + } +} diff --git a/src-tauri/src/speedtest.rs b/src-tauri/src/speedtest.rs deleted file mode 100644 index 52fd075..0000000 --- a/src-tauri/src/speedtest.rs +++ /dev/null @@ -1,108 +0,0 @@ -use futures::future::join_all; -use reqwest::{Client, Url}; -use serde::Serialize; -use std::time::{Duration, Instant}; - -use crate::error::AppError; - -const DEFAULT_TIMEOUT_SECS: u64 = 8; -const MAX_TIMEOUT_SECS: u64 = 30; -const MIN_TIMEOUT_SECS: u64 = 2; - -#[derive(Debug, Clone, Serialize)] -pub struct EndpointLatency { - pub url: String, - pub latency: Option, - pub status: Option, - pub error: Option, -} - -fn build_client(timeout_secs: u64) -> Result { - Client::builder() - .timeout(Duration::from_secs(timeout_secs)) - .redirect(reqwest::redirect::Policy::limited(5)) - .user_agent("cc-switch-speedtest/1.0") - .build() - .map_err(|e| AppError::Message(format!("创建 HTTP 客户端失败: {e}"))) -} - -fn sanitize_timeout(timeout_secs: Option) -> u64 { - let secs = timeout_secs.unwrap_or(DEFAULT_TIMEOUT_SECS); - secs.clamp(MIN_TIMEOUT_SECS, MAX_TIMEOUT_SECS) -} - -pub async fn test_endpoints( - urls: Vec, - timeout_secs: Option, -) -> Result, AppError> { - if urls.is_empty() { - return Ok(vec![]); - } - - let timeout = sanitize_timeout(timeout_secs); - let client = build_client(timeout)?; - - let tasks = urls.into_iter().map(|raw_url| { - let client = client.clone(); - async move { - let trimmed = raw_url.trim().to_string(); - if trimmed.is_empty() { - return EndpointLatency { - url: raw_url, - latency: None, - status: None, - error: Some("URL 不能为空".to_string()), - }; - } - - let parsed_url = match Url::parse(&trimmed) { - Ok(url) => url, - Err(err) => { - return EndpointLatency { - url: trimmed, - latency: None, - status: None, - error: Some(format!("URL 无效: {err}")), - }; - } - }; - - // 先进行一次“热身”请求,忽略其结果,仅用于复用连接/绕过首包惩罚 - let _ = client.get(parsed_url.clone()).send().await; - - // 第二次请求开始计时,并将其作为结果返回 - let start = Instant::now(); - match client.get(parsed_url).send().await { - Ok(resp) => { - let latency = start.elapsed().as_millis(); - EndpointLatency { - url: trimmed, - latency: Some(latency), - status: Some(resp.status().as_u16()), - error: None, - } - } - Err(err) => { - let status = err.status().map(|s| s.as_u16()); - let error_message = if err.is_timeout() { - "请求超时".to_string() - } else if err.is_connect() { - "连接失败".to_string() - } else { - err.to_string() - }; - - EndpointLatency { - url: trimmed, - latency: None, - status, - error: Some(error_message), - } - } - } - } - }); - - let results = join_all(tasks).await; - Ok(results) -} diff --git a/src-tauri/tests/import_export_sync.rs b/src-tauri/tests/import_export_sync.rs index 4bc4f11..690512e 100644 --- a/src-tauri/tests/import_export_sync.rs +++ b/src-tauri/tests/import_export_sync.rs @@ -3,8 +3,8 @@ use std::{fs, path::Path, sync::RwLock}; use tauri::async_runtime; use cc_switch_lib::{ - create_backup, get_claude_settings_path, import_config_from_path, read_json_file, - sync_current_providers_to_live, AppError, AppState, AppType, MultiAppConfig, Provider, + get_claude_settings_path, read_json_file, AppError, AppState, AppType, ConfigService, + MultiAppConfig, Provider, }; #[path = "support.rs"] @@ -41,7 +41,7 @@ fn sync_claude_provider_writes_live_settings() { manager.providers.insert("prov-1".to_string(), provider); manager.current = "prov-1".to_string(); - sync_current_providers_to_live(&mut config).expect("sync live settings"); + ConfigService::sync_current_providers_to_live(&mut config).expect("sync live settings"); let settings_path = get_claude_settings_path(); assert!( @@ -110,7 +110,7 @@ fn sync_codex_provider_writes_auth_and_config() { manager.providers.insert("codex-1".to_string(), provider); manager.current = "codex-1".to_string(); - sync_current_providers_to_live(&mut config).expect("sync codex live"); + ConfigService::sync_current_providers_to_live(&mut config).expect("sync codex live"); let auth_path = cc_switch_lib::get_codex_auth_path(); let config_path = cc_switch_lib::get_codex_config_path(); @@ -266,7 +266,7 @@ fn sync_codex_provider_missing_auth_returns_error() { manager.providers.insert(provider.id.clone(), provider); manager.current = "codex-missing-auth".to_string(); - let err = sync_current_providers_to_live(&mut config) + let err = ConfigService::sync_current_providers_to_live(&mut config) .expect_err("sync should fail when auth missing"); match err { cc_switch_lib::AppError::Config(msg) => { @@ -595,7 +595,7 @@ fn create_backup_skips_missing_file() { let config_path = home.join(".cc-switch").join("config.json"); // 未创建文件时应返回空字符串,不报错 - let result = create_backup(&config_path).expect("create backup"); + let result = ConfigService::create_backup(&config_path).expect("create backup"); assert!( result.is_empty(), "expected empty backup id when config file missing" @@ -612,7 +612,7 @@ fn create_backup_generates_snapshot_file() { fs::create_dir_all(&config_dir).expect("prepare config dir"); fs::write(&config_path, r#"{"version":2}"#).expect("write config file"); - let backup_id = create_backup(&config_path).expect("backup success"); + let backup_id = ConfigService::create_backup(&config_path).expect("backup success"); assert!( !backup_id.is_empty(), "backup id should contain timestamp information" @@ -651,7 +651,8 @@ fn create_backup_retains_only_latest_entries() { std::thread::sleep(std::time::Duration::from_secs(1)); - let latest_backup_id = create_backup(&config_path).expect("create backup with cleanup"); + let latest_backup_id = + ConfigService::create_backup(&config_path).expect("create backup with cleanup"); assert!( !latest_backup_id.is_empty(), "backup id should not be empty when config exists" @@ -731,8 +732,8 @@ fn import_config_from_path_overwrites_state_and_creates_backup() { config: RwLock::new(MultiAppConfig::default()), }; - let backup_id = - import_config_from_path(&import_path, &app_state).expect("import should succeed"); + let backup_id = ConfigService::import_config_from_path(&import_path, &app_state) + .expect("import should succeed"); assert!( !backup_id.is_empty(), "expected backup id when original config exists" @@ -787,7 +788,8 @@ fn import_config_from_path_invalid_json_returns_error() { config: RwLock::new(MultiAppConfig::default()), }; - let err = import_config_from_path(&invalid_path, &app_state).expect_err("import should fail"); + let err = ConfigService::import_config_from_path(&invalid_path, &app_state) + .expect_err("import should fail"); match err { AppError::Json { .. } => {} other => panic!("expected json error, got {other:?}"), @@ -805,7 +807,7 @@ fn import_config_from_path_missing_file_produces_io_error() { config: RwLock::new(MultiAppConfig::default()), }; - let err = import_config_from_path(missing_path, &app_state) + let err = ConfigService::import_config_from_path(missing_path, &app_state) .expect_err("import should fail for missing file"); match err { AppError::Io { .. } => {}