diff --git a/CLAUDE.md b/CLAUDE.md index a7e069d..e4263c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ --- agents: Codex, Claude-Code, Gemini-Cli -last-updated: 2025-12-07 +last-updated: 2025-12-16 --- # DuckCoding 开发协作规范 @@ -76,6 +76,19 @@ last-updated: 2025-12-07 ## 架构记忆(2025-12-12) +- **main.rs 模块化重构(2025-12-15)**: + - **问题**:原 `src-tauri/src/main.rs` 文件过大(652行),包含启动、托盘、窗口、迁移、命令注册等多种职责 + - **解决方案**:按启动流程分层,拆分为 `setup/` 模块 + - **新架构**(位于 `src-tauri/src/setup/`): + - `tray.rs` (195行):托盘菜单创建、窗口管理(显示/隐藏/聚焦/恢复)、事件处理 + - `initialization.rs` (161行):启动初始化流程(日志/迁移/Profile/代理自启动/工具注册表) + - `mod.rs` (9行):模块导出 + - **main.rs 重构**(402行,从 652 行减少 -38%): + - 保留:应用启动、状态管理、builder 配置、macOS 事件循环 + - 辅助函数:工作目录设置、配置监听、更新检查调度、单实例判断 + - 命令注册:保留内联(按功能分组注释),避免宏卫生性问题 + - **架构原则**:遵循单一职责原则(SOLID - SRP),按启动流程分层,main() 函数仅保留核心逻辑 + - **代码质量**:所有检查通过(ESLint + Clippy + Prettier + fmt),单元测试 199 通过 - `src-tauri/src/main.rs` 仅保留应用启动与托盘事件注册,所有 Tauri Commands 拆分到 `src-tauri/src/commands/*`,服务实现位于 `services/*`,核心设施放在 `core/*`(HTTP、日志、错误)。 - **配置管理系统(2025-12-12 重构)**: - `services/config/` 模块化拆分为 6 个子模块: @@ -147,14 +160,44 @@ last-updated: 2025-12-07 - `registry.rs`、`installer.rs`、`detection.rs` 已使用 `utils::parse_version_string()`(保持不变) - **测试覆盖**:7 个测试函数(6 个字符串提取测试 + 1 个 semver 解析测试,7 个断言),覆盖所有格式 - **代码减少**:删除 `VersionService` 和 `Detector` 中的重复正则定义(约 15 行) + - **ToolRegistry 模块化拆分(2025-12-13)**: + - **问题**:原 `services/tool/registry.rs` 文件过大(1118行),包含 21 个方法,职责混杂 + - **解决方案**:按职责拆分为 5 个子模块,每个文件 < 400 行 + - **新架构**(位于 `services/tool/registry/`): + - `mod.rs` (57行):ToolRegistry 结构体定义、初始化方法、ToolDetectionProgress + - `detection.rs` (323行):工具检测与持久化(5个方法:`detect_and_persist_local_tools`、`detect_single_tool_by_detector`、`detect_and_persist_single_tool`、`refresh_local_tools`、`detect_single_tool_with_cache`) + - `instance.rs` (229行):实例 CRUD 操作(4个方法:`add_wsl_instance`、`add_ssh_instance`、`delete_instance`、`add_tool_instance`) + - `version_ops.rs` (239行):版本检查与更新(4个方法:`update_instance`、`check_update_for_instance`、`refresh_all_tool_versions`、`detect_install_methods`) + - `query.rs` (286行):查询与辅助工具(6个方法:`get_all_grouped`、`refresh_all`、`get_local_tool_status`、`refresh_and_get_local_status`、`scan_tool_candidates`、`validate_tool_path`) + - **向后兼容**:保持 `use crate::services::tool::ToolRegistry` 路径不变,调用方无需修改 + - **测试迁移**:4 个单元测试随代码迁移到对应子模块(instance.rs: 1个,query.rs: 3个) + - **代码质量**:遵循单一职责原则(SOLID - SRP),每个模块职责明确,易于维护和测试 + - **文件大小减少**:最大文件从 1118 行减少到 323 行(-71%),平均文件 227 行 - **透明代理已重构为多工具架构**: - `ProxyManager` 统一管理三个工具(Claude Code、Codex、Gemini CLI)的代理实例 - `HeadersProcessor` trait 定义工具特定的 headers 处理逻辑(位于 `services/proxy/headers/`) - - `ToolProxyConfig` 存储在 `GlobalConfig.proxy_configs` HashMap 中,每个工具独立配置 + - `ToolProxyConfig` 存储在 `ProxyConfigManager` 管理的 `~/.duckcoding/proxy.json` 中,每个工具独立配置 - 支持三个代理同时运行,端口由用户配置(默认: claude-code=8787, codex=8788, gemini-cli=8789) - 旧的 `transparent_proxy_*` 字段会在读取配置时自动迁移到新结构 - - 新命令:`start_tool_proxy`、`stop_tool_proxy`、`get_all_proxy_status` - - 旧命令保持兼容,内部使用新架构实现 + - **命令层(2025-12-14 清理遗留代码)**: + - 新架构命令:`start_tool_proxy`、`stop_tool_proxy`、`get_all_proxy_status`、`update_proxy_config`、`get_proxy_config`、`get_all_proxy_configs` + - 旧命令已完全删除:`start_transparent_proxy`、`stop_transparent_proxy`、`get_transparent_proxy_status`、`update_transparent_proxy_config` + - 已删除遗留服务:`TransparentProxyConfigService`(原 `services/proxy/transparent_proxy_config.rs`,563行) + - 已删除前端遗留代码:`useTransparentProxy.ts` hook 和旧 API 包装器 + - **代理工具模块化重构(2025-12-16)**: + - **问题**:旧架构 `TransparentProxyService` (454行) 完全未使用,`proxy_instance.rs` (421行) 包含大量重复代码 + - **解决方案**:删除旧架构 + 提取通用工具到 `services/proxy/utils/` + - **已删除文件**: + - `services/proxy/transparent_proxy.rs` (454行):单代理实例旧实现(已被 ProxyManager 替代) + - **新建 utils 模块**(位于 `services/proxy/utils/`,消除重复代码 152 行): + - `body.rs` (48行):统一 `BoxBody` 类型定义和 `box_body()` 工厂函数 + - `loop_detector.rs` (45行):代理回环检测(`is_proxy_loop` 防止配置指向自身) + - `error_responses.rs` (63行):统一 JSON 错误响应模板(配置缺失、回环检测、未授权、内部错误) + - `mod.rs` (10行):模块导出和常用类型重导出 + - **proxy_instance.rs 简化**:从 421 行减少到 269 行(-36%),删除重复的类型定义和错误响应构建逻辑 + - **GlobalConfig 清理**:删除 6 个废弃字段(`transparent_proxy_enabled`、`transparent_proxy_port`、`transparent_proxy_api_key`、`transparent_proxy_allow_public`、`transparent_proxy_real_api_key`、`transparent_proxy_real_base_url`) + - **迁移逻辑**:`migrations/proxy_config.rs` 使用 `serde_json::Value` 手动操作 JSON,保持向后兼容 + - **代码质量**:遵循 DRY 原则,所有检查通过(Clippy + fmt + ESLint + Prettier),测试 199 通过 - **配置管理机制(2025-12-12)**: - 代理启动时自动创建内置 Profile(`dc_proxy_*`),通过 `ProfileManager` 切换配置 - 内置 Profile 在 UI 中不可见(列表查询时过滤 `dc_proxy_` 前缀) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index eac229f..849f9ff 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -891,6 +891,7 @@ dependencies = [ "chrono", "cocoa", "dirs", + "fs2", "futures-util", "http-body-util", "hyper", @@ -1165,6 +1166,16 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "fsevent-sys" version = "4.1.0" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 48c81f1..ff70f86 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -46,6 +46,8 @@ pin-project-lite = "0.2" bytes = "1" futures-util = "0.3" async-trait = "0.1" +# 文件锁 +fs2 = "0.4" # 数据库 rusqlite = { version = "0.32", features = ["bundled"] } # 单例模式 diff --git a/src-tauri/src/commands/balance_commands.rs b/src-tauri/src/commands/balance_commands.rs index a7fb14e..4aadb01 100644 --- a/src-tauri/src/commands/balance_commands.rs +++ b/src-tauri/src/commands/balance_commands.rs @@ -6,7 +6,7 @@ use ::duckcoding::http_client::build_client; use ::duckcoding::models::{BalanceConfig, BalanceStore}; use ::duckcoding::services::balance::BalanceManager; -use ::duckcoding::utils::config::apply_proxy_if_configured; +use ::duckcoding::services::proxy::config::apply_global_proxy; use std::collections::HashMap; /// Tauri command: 通用 API 请求 @@ -26,7 +26,7 @@ pub async fn fetch_api( headers: HashMap, timeout_ms: Option, ) -> Result { - apply_proxy_if_configured(); + apply_global_proxy().ok(); // 验证 HTTP 方法 let method_normalized = method.to_uppercase(); diff --git a/src-tauri/src/commands/config_commands.rs b/src-tauri/src/commands/config_commands.rs index 398bc63..e3670b9 100644 --- a/src-tauri/src/commands/config_commands.rs +++ b/src-tauri/src/commands/config_commands.rs @@ -6,9 +6,8 @@ use ::duckcoding::services::config::{ self, claude, codex, gemini, ClaudeSettingsPayload, CodexSettingsPayload, ExternalConfigChange, GeminiEnvPayload, GeminiSettingsPayload, ImportExternalChangeResult, }; -use ::duckcoding::utils::config::{ - apply_proxy_if_configured, read_global_config, write_global_config, -}; +use ::duckcoding::services::proxy::config::apply_global_proxy; +use ::duckcoding::utils::config::{read_global_config, write_global_config}; use ::duckcoding::GlobalConfig; use ::duckcoding::Tool; @@ -85,7 +84,7 @@ pub async fn get_global_config() -> Result, String> { #[tauri::command] pub async fn generate_api_key_for_tool(tool: String) -> Result { // 应用代理配置(如果已配置) - apply_proxy_if_configured(); + apply_global_proxy().ok(); // 读取全局配置 let global_config = get_global_config() diff --git a/src-tauri/src/commands/onboarding.rs b/src-tauri/src/commands/onboarding.rs index aab5a59..572fc0c 100644 --- a/src-tauri/src/commands/onboarding.rs +++ b/src-tauri/src/commands/onboarding.rs @@ -18,12 +18,6 @@ fn create_minimal_config() -> GlobalConfig { proxy_username: None, proxy_password: None, proxy_bypass_urls: Vec::new(), - transparent_proxy_enabled: false, - transparent_proxy_port: 8787, - transparent_proxy_api_key: None, - transparent_proxy_real_api_key: None, - transparent_proxy_real_base_url: None, - transparent_proxy_allow_public: false, proxy_configs: HashMap::new(), session_endpoint_config_enabled: false, hide_transparent_proxy_tip: false, diff --git a/src-tauri/src/commands/profile_commands.rs b/src-tauri/src/commands/profile_commands.rs index d53e42b..1fa706a 100644 --- a/src-tauri/src/commands/profile_commands.rs +++ b/src-tauri/src/commands/profile_commands.rs @@ -6,22 +6,22 @@ use serde::Deserialize; /// Profile 输入数据(前端传递) #[derive(Debug, Deserialize)] -#[serde(untagged)] +#[serde(tag = "type", rename_all = "kebab-case")] pub enum ProfileInput { - Gemini { - api_key: String, - base_url: String, - #[serde(default)] - model: Option, - }, + #[serde(rename = "claude-code")] + Claude { api_key: String, base_url: String }, + #[serde(rename = "codex")] Codex { api_key: String, base_url: String, - wire_api: String, // 前端和后端都使用 wire_api + wire_api: String, }, - Claude { + #[serde(rename = "gemini-cli")] + Gemini { api_key: String, base_url: String, + #[serde(default)] + model: Option, }, } diff --git a/src-tauri/src/commands/proxy_commands.rs b/src-tauri/src/commands/proxy_commands.rs index e227525..0f375ae 100644 --- a/src-tauri/src/commands/proxy_commands.rs +++ b/src-tauri/src/commands/proxy_commands.rs @@ -3,28 +3,19 @@ use std::collections::HashMap; use std::sync::Arc; use tauri::State; -use tokio::sync::Mutex as TokioMutex; -use ::duckcoding::services::proxy::{ - ProxyManager, TransparentProxyConfigService, TransparentProxyService, -}; +use ::duckcoding::services::proxy::ProxyManager; use ::duckcoding::services::proxy_config_manager::ProxyConfigManager; -use ::duckcoding::utils::config::{read_global_config, write_global_config}; -use ::duckcoding::{GlobalConfig, ProxyConfig, Tool}; +use ::duckcoding::utils::config::read_global_config; // ==================== 类型定义 ==================== -// 透明代理全局状态(旧架构,保持兼容) -pub struct TransparentProxyState { - pub service: Arc>, -} - // 代理管理器状态(新架构) pub struct ProxyManagerState { pub manager: Arc, } -// 透明代理相关的 Tauri Commands +// 透明代理状态(用于新架构的多工具状态返回) #[derive(serde::Serialize)] pub struct TransparentProxyStatus { running: bool, @@ -49,207 +40,6 @@ pub struct TestProxyResult { error: Option, } -// ==================== 辅助函数 ==================== - -// Tauri命令:读取全局配置 -async fn get_global_config() -> Result, String> { - read_global_config() -} - -// Tauri命令:保存全局配置 -async fn save_global_config(config: GlobalConfig) -> Result<(), String> { - write_global_config(&config) -} -#[tauri::command] -pub async fn start_transparent_proxy( - state: State<'_, TransparentProxyState>, -) -> Result { - // 读取全局配置 - let mut config = get_global_config() - .await - .map_err(|e| format!("读取配置失败: {e}"))? - .ok_or_else(|| "全局配置不存在,请先配置用户信息".to_string())?; - let original_config = config.clone(); - - if !config.transparent_proxy_enabled { - return Err("透明代理未启用,请先在设置中启用".to_string()); - } - - let local_api_key = config - .transparent_proxy_api_key - .clone() - .ok_or_else(|| "透明代理保护密钥未设置".to_string())?; - - let proxy_port = config.transparent_proxy_port; - - let tool = Tool::claude_code(); - - // 每次启动都检查并确保配置正确设置 - // 如果还没有备份过真实配置,先备份 - if config.transparent_proxy_real_api_key.is_none() { - // 启用透明代理(保存真实配置并修改 ClaudeCode 配置) - TransparentProxyConfigService::enable_transparent_proxy( - &tool, - &mut config, - proxy_port, - &local_api_key, - ) - .map_err(|e| format!("启用透明代理失败: {e}"))?; - } else { - // 已经备份过配置,只需确保当前配置指向本地代理 - TransparentProxyConfigService::update_config_to_proxy(&tool, proxy_port, &local_api_key) - .map_err(|e| format!("更新代理配置失败: {e}"))?; - } - - // 从全局配置获取真实的 API 配置 - let (target_api_key, target_base_url) = TransparentProxyConfigService::get_real_config(&config) - .map_err(|e| format!("获取真实配置失败: {e}"))?; - - tracing::debug!( - api_key_prefix = &target_api_key[..4.min(target_api_key.len())], - base_url = %target_base_url, - "真实 API 配置" - ); - - // 创建代理配置 - let proxy_config = ProxyConfig { - target_api_key, - target_base_url, - local_api_key, - }; - - // 启动代理服务 - let service = state.service.lock().await; - let allow_public = config.transparent_proxy_allow_public; - if let Err(start_err) = service.start(proxy_config, allow_public).await { - if let Err(disable_err) = - TransparentProxyConfigService::disable_transparent_proxy(&tool, &config) - { - tracing::error!( - error = ?disable_err, - "恢复 ClaudeCode 配置失败(代理启动错误后)" - ); - } - if let Err(save_err) = save_global_config(original_config).await { - tracing::error!( - error = ?save_err, - "恢复全局配置失败(代理启动错误后)" - ); - } - return Err(format!("启动透明代理服务失败: {start_err}")); - } - - // 保存更新后的全局配置 - save_global_config(config.clone()) - .await - .map_err(|e| format!("保存配置失败: {e}"))?; - - Ok(format!( - "✅ 透明代理已启动\n监听端口: {proxy_port}\nClaudeCode 请求将自动转发" - )) -} - -#[tauri::command] -pub async fn stop_transparent_proxy( - state: State<'_, TransparentProxyState>, -) -> Result { - // 读取全局配置 - let config = get_global_config() - .await - .map_err(|e| format!("读取配置失败: {e}"))? - .ok_or_else(|| "全局配置不存在".to_string())?; - - // 停止代理服务 - let service = state.service.lock().await; - service - .stop() - .await - .map_err(|e| format!("停止透明代理服务失败: {e}"))?; - - // 恢复 ClaudeCode 配置 - if config.transparent_proxy_real_api_key.is_some() { - let tool = Tool::claude_code(); - TransparentProxyConfigService::disable_transparent_proxy(&tool, &config) - .map_err(|e| format!("恢复配置失败: {e}"))?; - } - - Ok("✅ 透明代理已停止\nClaudeCode 配置已恢复".to_string()) -} - -#[tauri::command] -pub async fn get_transparent_proxy_status( - state: State<'_, TransparentProxyState>, -) -> Result { - let config = get_global_config().await.ok().flatten(); - let port = config - .as_ref() - .map(|c| c.transparent_proxy_port) - .unwrap_or(8787); - - let service = state.service.lock().await; - let running = service.is_running().await; - - Ok(TransparentProxyStatus { running, port }) -} - -#[tauri::command] -pub async fn update_transparent_proxy_config( - state: State<'_, TransparentProxyState>, - new_api_key: String, - new_base_url: String, -) -> Result { - // 读取全局配置 - let mut config = get_global_config() - .await - .map_err(|e| format!("读取配置失败: {e}"))? - .ok_or_else(|| "全局配置不存在".to_string())?; - - if !config.transparent_proxy_enabled { - return Err("透明代理未启用".to_string()); - } - - let local_api_key = config - .transparent_proxy_api_key - .clone() - .ok_or_else(|| "透明代理保护密钥未设置".to_string())?; - - // 更新全局配置中的真实配置 - let tool = Tool::claude_code(); - TransparentProxyConfigService::update_real_config( - &tool, - &mut config, - &new_api_key, - &new_base_url, - ) - .map_err(|e| format!("更新配置失败: {e}"))?; - - // 保存更新后的全局配置 - save_global_config(config.clone()) - .await - .map_err(|e| format!("保存配置失败: {e}"))?; - - // 创建新的代理配置 - let proxy_config = ProxyConfig { - target_api_key: new_api_key.clone(), - target_base_url: new_base_url.clone(), - local_api_key, - }; - - // 更新代理服务的配置 - let service = state.service.lock().await; - service - .update_config(proxy_config) - .await - .map_err(|e| format!("更新代理配置失败: {e}"))?; - - tracing::info!( - api_key_prefix = &new_api_key[..4.min(new_api_key.len())], - base_url = %new_base_url, - "透明代理配置已更新" - ); - - Ok("✅ 透明代理配置已更新,无需重启".to_string()) -} #[tauri::command] pub fn get_current_proxy() -> Result, String> { Ok(::duckcoding::ProxyService::get_current_proxy()) @@ -343,12 +133,11 @@ pub async fn test_proxy_request( } // ==================== 多工具代理命令(新架构) ==================== -/// 启动指定工具的透明代理 -#[tauri::command] -pub async fn start_tool_proxy( - tool_id: String, - manager_state: State<'_, ProxyManagerState>, -) -> Result { +/// 内部实现:尝试启动代理(支持回滚) +async fn try_start_proxy_internal( + tool_id: &str, + manager_state: &ProxyManagerState, +) -> Result<(String, u16), String> { use ::duckcoding::services::profile_manager::ProfileManager; let profile_mgr = ProfileManager::new().map_err(|e| e.to_string())?; @@ -356,12 +145,12 @@ pub async fn start_tool_proxy( // 读取当前配置 let mut tool_config = proxy_config_mgr - .get_config(&tool_id) + .get_config(tool_id) .map_err(|e| e.to_string())? .ok_or_else(|| format!("工具 {} 的代理配置不存在", tool_id))?; // 检查是否已在运行 - if manager_state.manager.is_running(&tool_id).await { + if manager_state.manager.is_running(tool_id).await { return Err(format!("{} 代理已在运行", tool_id)); } @@ -380,19 +169,19 @@ pub async fn start_tool_proxy( // 1. 读取当前激活的 Profile 名称 let original_profile = profile_mgr - .get_active_profile_name(&tool_id) + .get_active_profile_name(tool_id) .map_err(|e| e.to_string())?; // 2. 保存到 ToolProxyConfig tool_config.original_active_profile = original_profile.clone(); proxy_config_mgr - .update_config(&tool_id, tool_config.clone()) + .update_config(tool_id, tool_config.clone()) .map_err(|e| e.to_string())?; // 3. 验证内置 Profile 是否存在 let proxy_profile_name = format!("dc_proxy_{}", tool_id.replace("-", "_")); - let profile_exists = match tool_id.as_str() { + let profile_exists = match tool_id { "claude-code" => profile_mgr.get_claude_profile(&proxy_profile_name).is_ok(), "codex" => profile_mgr.get_codex_profile(&proxy_profile_name).is_ok(), "gemini-cli" => profile_mgr.get_gemini_profile(&proxy_profile_name).is_ok(), @@ -408,7 +197,7 @@ pub async fn start_tool_proxy( // 4. 激活内置 Profile(这会自动同步到原生配置文件) profile_mgr - .activate_profile(&tool_id, &proxy_profile_name) + .activate_profile(tool_id, &proxy_profile_name) .map_err(|e| format!("激活内置 Profile 失败: {}", e))?; tracing::info!( @@ -424,14 +213,63 @@ pub async fn start_tool_proxy( manager_state .manager - .start_proxy(&tool_id, tool_config) + .start_proxy(tool_id, tool_config) .await .map_err(|e| format!("启动代理失败: {}", e))?; - Ok(format!( - "✅ {} 透明代理已启动\n监听端口: {}\n已切换到代理配置", - tool_id, proxy_port - )) + Ok((tool_id.to_string(), proxy_port)) +} + +/// 启动指定工具的透明代理(带事务回滚) +#[tauri::command] +pub async fn start_tool_proxy( + tool_id: String, + manager_state: State<'_, ProxyManagerState>, +) -> Result { + use ::duckcoding::services::profile_manager::ProfileManager; + + // 备份当前状态(用于回滚) + let profile_mgr = ProfileManager::new().map_err(|e| e.to_string())?; + let proxy_config_mgr = ProxyConfigManager::new().map_err(|e| e.to_string())?; + + let backup_config = proxy_config_mgr + .get_config(&tool_id) + .map_err(|e| e.to_string())? + .ok_or_else(|| format!("工具 {} 的代理配置不存在", tool_id))?; + + let backup_profile = profile_mgr + .get_active_profile_name(&tool_id) + .map_err(|e| e.to_string())?; + + // 执行启动操作 + match try_start_proxy_internal(&tool_id, &manager_state).await { + Ok((tool_id, proxy_port)) => Ok(format!( + "✅ {} 透明代理已启动\n监听端口: {}\n已切换到代理配置", + tool_id, proxy_port + )), + Err(e) => { + // 启动失败,开始回滚 + tracing::warn!("代理启动失败,开始回滚: {}", e); + + // 回滚代理配置 + if let Err(rollback_err) = proxy_config_mgr.update_config(&tool_id, backup_config) { + tracing::error!("回滚代理配置失败: {}", rollback_err); + } else { + tracing::info!("已回滚代理配置"); + } + + // 回滚 Profile 激活状态 + if let Some(name) = backup_profile { + if let Err(rollback_err) = profile_mgr.activate_profile(&tool_id, &name) { + tracing::error!("回滚 Profile 失败: {}", rollback_err); + } else { + tracing::info!("已回滚 Profile 到: {}", name); + } + } + + Err(e) + } + } } /// 停止指定工具的透明代理 @@ -500,14 +338,16 @@ pub async fn stop_tool_proxy( pub async fn get_all_proxy_status( manager_state: State<'_, ProxyManagerState>, ) -> Result, String> { - let config = get_global_config().await.ok().flatten(); + let proxy_config_mgr = ProxyConfigManager::new().map_err(|e| e.to_string())?; + let proxy_store = proxy_config_mgr + .load_proxy_store() + .map_err(|e| e.to_string())?; let mut status_map = HashMap::new(); for tool_id in &["claude-code", "codex", "gemini-cli"] { - let port = config - .as_ref() - .and_then(|c| c.get_proxy_config(tool_id)) + let port = proxy_store + .get_config(tool_id) .map(|tc| tc.port) .unwrap_or_else(|| match *tool_id { "claude-code" => 8787, @@ -633,7 +473,13 @@ pub async fn update_proxy_config( let profile_mgr = ProfileManager::new().map_err(|e| e.to_string())?; let proxy_profile_name = format!("dc_proxy_{}", tool_id.replace("-", "_")); let proxy_endpoint = format!("http://127.0.0.1:{}", config.port); - let proxy_key = config.local_api_key.unwrap(); + + // 安全获取代理密钥,避免 panic + let proxy_key = config + .local_api_key + .as_ref() + .ok_or_else(|| format!("工具 {} 缺少代理密钥配置", tool_id))? + .clone(); match tool_id.as_str() { "claude-code" => { diff --git a/src-tauri/src/commands/stats_commands.rs b/src-tauri/src/commands/stats_commands.rs index 4450a44..4165ab4 100644 --- a/src-tauri/src/commands/stats_commands.rs +++ b/src-tauri/src/commands/stats_commands.rs @@ -2,7 +2,8 @@ // // 包含用量统计、用户额度查询等功能 -use ::duckcoding::utils::config::{apply_proxy_if_configured, read_global_config}; +use ::duckcoding::services::proxy::config::apply_global_proxy; +use ::duckcoding::utils::config::read_global_config; use serde::Serialize; /// 用量统计数据结构 @@ -64,7 +65,7 @@ fn build_reqwest_client() -> Result { #[tauri::command] pub async fn get_usage_stats() -> Result { - apply_proxy_if_configured(); + apply_global_proxy().ok(); let global_config = read_global_config()?.ok_or_else(|| "请先配置用户ID和系统访问令牌".to_string())?; let now = std::time::SystemTime::now() @@ -139,7 +140,7 @@ pub async fn get_usage_stats() -> Result { #[tauri::command] pub async fn get_user_quota() -> Result { - apply_proxy_if_configured(); + apply_global_proxy().ok(); let global_config = read_global_config()?.ok_or_else(|| "请先配置用户ID和系统访问令牌".to_string())?; let client = build_reqwest_client().map_err(|e| format!("创建 HTTP 客户端失败: {e}"))?; diff --git a/src-tauri/src/commands/tool_commands/installation.rs b/src-tauri/src/commands/tool_commands/installation.rs index 7117fc7..1525367 100644 --- a/src-tauri/src/commands/tool_commands/installation.rs +++ b/src-tauri/src/commands/tool_commands/installation.rs @@ -1,8 +1,8 @@ use crate::commands::tool_management::ToolRegistryState; use crate::commands::types::{InstallResult, ToolStatus}; use ::duckcoding::models::{InstallMethod, Tool}; +use ::duckcoding::services::proxy::config::apply_global_proxy; use ::duckcoding::services::InstallerService; -use ::duckcoding::utils::config::apply_proxy_if_configured; /// 检查所有工具的安装状态(新架构:优先从数据库读取) /// @@ -48,7 +48,7 @@ pub async fn install_tool( force: Option, ) -> Result { // 应用代理配置(如果已配置) - apply_proxy_if_configured(); + apply_global_proxy().ok(); let force = force.unwrap_or(false); #[cfg(debug_assertions)] diff --git a/src-tauri/src/commands/tool_commands/update.rs b/src-tauri/src/commands/tool_commands/update.rs index 6ae8366..52f04c1 100644 --- a/src-tauri/src/commands/tool_commands/update.rs +++ b/src-tauri/src/commands/tool_commands/update.rs @@ -1,14 +1,14 @@ use crate::commands::tool_management::ToolRegistryState; use crate::commands::types::{ToolStatus, UpdateResult}; use ::duckcoding::models::Tool; +use ::duckcoding::services::proxy::config::apply_global_proxy; use ::duckcoding::services::VersionService; -use ::duckcoding::utils::config::apply_proxy_if_configured; /// 检查工具更新(不执行更新) #[tauri::command] pub async fn check_update(tool: String) -> Result { // 应用代理配置(如果已配置) - apply_proxy_if_configured(); + apply_global_proxy().ok(); #[cfg(debug_assertions)] tracing::debug!(tool = %tool, "检查更新(使用VersionService)"); @@ -85,7 +85,7 @@ pub async fn refresh_all_tool_versions( #[tauri::command] pub async fn check_all_updates() -> Result, String> { // 应用代理配置(如果已配置) - apply_proxy_if_configured(); + apply_global_proxy().ok(); #[cfg(debug_assertions)] tracing::debug!("批量检查所有工具更新"); diff --git a/src-tauri/src/core/http.rs b/src-tauri/src/core/http.rs index 9ad9f25..309a4a2 100644 --- a/src-tauri/src/core/http.rs +++ b/src-tauri/src/core/http.rs @@ -104,12 +104,6 @@ mod tests { proxy_username: None, proxy_password: None, proxy_bypass_urls: vec![], - transparent_proxy_enabled: false, - transparent_proxy_port: 8787, - transparent_proxy_api_key: None, - transparent_proxy_allow_public: false, - transparent_proxy_real_api_key: None, - transparent_proxy_real_base_url: None, proxy_configs: std::collections::HashMap::new(), session_endpoint_config_enabled: false, hide_transparent_proxy_tip: false, @@ -138,12 +132,6 @@ mod tests { proxy_username: Some("user".to_string()), proxy_password: Some("pass".to_string()), proxy_bypass_urls: vec![], - transparent_proxy_enabled: false, - transparent_proxy_port: 8787, - transparent_proxy_api_key: None, - transparent_proxy_allow_public: false, - transparent_proxy_real_api_key: None, - transparent_proxy_real_base_url: None, proxy_configs: std::collections::HashMap::new(), session_endpoint_config_enabled: false, hide_transparent_proxy_tip: false, diff --git a/src-tauri/src/data/manager.rs b/src-tauri/src/data/manager.rs index 5ceb215..44e1263 100644 --- a/src-tauri/src/data/manager.rs +++ b/src-tauri/src/data/manager.rs @@ -28,11 +28,17 @@ use crate::data::managers::{EnvManager, JsonManager, SqliteManager, TomlManager}; use crate::data::Result; +use once_cell::sync::Lazy; use std::collections::HashMap; use std::path::{Path, PathBuf}; use std::sync::{Arc, RwLock}; use std::time::Duration; +/// 全局 DataManager 单例 +/// +/// 使用全局单例共享缓存,避免重复创建提升性能 +static GLOBAL_DATA_MANAGER: Lazy = Lazy::new(DataManager::new); + /// 统一数据管理器 /// /// 提供所有数据格式管理器的统一访问接口。 @@ -52,6 +58,20 @@ pub struct DataManager { } impl DataManager { + /// 获取全局 DataManager 单例(推荐) + /// + /// 使用全局单例可以共享缓存,提升性能 + /// + /// # 示例 + /// + /// ```rust + /// let manager = DataManager::global(); + /// manager.json().read(path)?; + /// ``` + pub fn global() -> &'static DataManager { + &GLOBAL_DATA_MANAGER + } + /// 创建默认配置的管理器 /// /// 默认配置: diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 1ac2272..bdc96e4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -14,8 +14,6 @@ pub use models::InstallMethod; // InstallMethod is defined in models (tool.rs) pub use services::downloader::FileDownloader; pub use services::installer::InstallerService; pub use services::proxy::ProxyService; -pub use services::transparent_proxy::{ProxyConfig, TransparentProxyService}; -pub use services::transparent_proxy_config::TransparentProxyConfigService; pub use services::update::UpdateService; pub use services::version::VersionService; // Re-export tool registry (unified tool management) diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2d12f3d..9c4ce57 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1,28 +1,21 @@ // Prevents additional console window on Windows in release, DO NOT REMOVE!! #![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] -use duckcoding::utils::config::apply_proxy_if_configured; +use duckcoding::services::config::{NotifyWatcherManager, EXTERNAL_CHANGE_EVENT}; +use duckcoding::services::proxy::config::apply_global_proxy; +use duckcoding::utils::config::read_global_config; use serde::Serialize; use std::env; use std::sync::Mutex; -use tauri::{ - menu::{Menu, MenuItem, PredefinedMenuItem}, - tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, - AppHandle, Emitter, Manager, Runtime, WebviewWindow, -}; +use tauri::{AppHandle, Emitter, Manager}; // 导入 commands 模块 mod commands; use commands::*; -// 导入透明代理服务 -use duckcoding::services::config::{NotifyWatcherManager, EXTERNAL_CHANGE_EVENT}; -use duckcoding::ProxyManager; -use duckcoding::TransparentProxyService; -use std::sync::Arc; -use tokio::sync::Mutex as TokioMutex; +// 导入 setup 模块 +mod setup; -const CLOSE_CONFIRM_EVENT: &str = "duckcoding://request-close-action"; const SINGLE_INSTANCE_EVENT: &str = "single-instance"; struct ExternalWatcherState { @@ -35,263 +28,175 @@ struct SingleInstancePayload { cwd: String, } -fn create_tray_menu(app: &AppHandle) -> tauri::Result> { - let show_item = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?; - let check_update_item = MenuItem::with_id(app, "check_update", "检查更新", true, None::<&str>)?; - let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; - - let menu = Menu::with_items( - app, - &[ - &show_item, - &PredefinedMenuItem::separator(app)?, - &check_update_item, - &PredefinedMenuItem::separator(app)?, - &quit_item, - ], - )?; - - Ok(menu) -} - -fn focus_main_window(app: &AppHandle) { - if let Some(window) = app.get_webview_window("main") { - tracing::info!("聚焦主窗口"); - restore_window_state(&window); +/// 判断是否启用单实例模式 +/// +/// 开发环境:始终禁用(方便调试和与正式版隔离) +/// 生产环境:根据配置决定(默认启用) +fn determine_single_instance_mode() -> bool { + if cfg!(debug_assertions) { + false // 开发环境禁用 } else { - tracing::warn!("尝试聚焦时未找到主窗口"); + // 生产环境读取配置 + read_global_config() + .ok() + .flatten() + .map(|cfg| cfg.single_instance_enabled) + .unwrap_or(true) // 默认启用 } } -fn restore_window_state(window: &WebviewWindow) { - tracing::debug!( - is_visible = ?window.is_visible(), - is_minimized = ?window.is_minimized(), - "恢复窗口状态" - ); - - #[cfg(target_os = "macos")] - #[allow(deprecated)] - { - use cocoa::appkit::NSApplication; - use cocoa::base::nil; - use cocoa::foundation::NSAutoreleasePool; - - unsafe { - let _pool = NSAutoreleasePool::new(nil); - let app_macos = NSApplication::sharedApplication(nil); - app_macos.setActivationPolicy_( - cocoa::appkit::NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, - ); +/// 设置工作目录到项目根目录(跨平台支持) +fn setup_working_directory(app: &tauri::App) -> tauri::Result<()> { + if let Ok(resource_dir) = app.path().resource_dir() { + tracing::debug!(resource_dir = ?resource_dir, "资源目录"); + + if cfg!(debug_assertions) { + // 开发模式: resource_dir 是 src-tauri/target/debug + // 需要回到项目根目录(上三级) + let project_root = resource_dir + .parent() // target + .and_then(|p| p.parent()) // src-tauri + .and_then(|p| p.parent()) // 项目根目录 + .unwrap_or(&resource_dir); + + tracing::debug!(project_root = ?project_root, "开发模式,设置工作目录"); + let _ = env::set_current_dir(project_root); + } else { + // 生产模式: 跨平台支持 + let parent_dir = if cfg!(target_os = "macos") { + // macOS: .app/Contents/Resources/ + resource_dir + .parent() + .and_then(|p| p.parent()) + .unwrap_or(&resource_dir) + } else if cfg!(target_os = "windows") { + // Windows: 通常在应用程序目录 + resource_dir.parent().unwrap_or(&resource_dir) + } else { + // Linux: 通常在 /usr/share/appname 或类似位置 + resource_dir.parent().unwrap_or(&resource_dir) + }; + tracing::debug!(parent_dir = ?parent_dir, "生产模式,设置工作目录"); + let _ = env::set_current_dir(parent_dir); } - tracing::debug!("macOS Dock 图标已恢复"); } - if let Err(e) = window.show() { - tracing::error!(error = ?e, "显示窗口失败"); - } - if let Err(e) = window.unminimize() { - tracing::error!(error = ?e, "取消最小化窗口失败"); - } - if let Err(e) = window.set_focus() { - tracing::error!(error = ?e, "设置窗口焦点失败"); - } - - #[cfg(target_os = "macos")] - #[allow(deprecated)] - { - use cocoa::appkit::NSApplication; - use cocoa::base::nil; - use objc::runtime::YES; - - unsafe { - let ns_app = NSApplication::sharedApplication(nil); - ns_app.activateIgnoringOtherApps_(YES); - } - tracing::debug!("macOS 应用已激活"); - } + tracing::info!(working_dir = ?env::current_dir(), "当前工作目录"); + Ok(()) } -fn hide_window_to_tray(window: &WebviewWindow) { - tracing::info!("隐藏窗口到系统托盘"); - if let Err(e) = window.hide() { - tracing::error!(error = ?e, "隐藏窗口失败"); - } +/// 启动配置文件监听(如果启用) +fn start_config_watcher(app: &tauri::App) -> tauri::Result<()> { + if let Some(state) = app.try_state::() { + let enable_watch = match read_global_config() { + Ok(Some(cfg)) => cfg.external_watch_enabled, + _ => true, + }; + if !enable_watch { + tracing::info!("External config watcher disabled by config"); + } - #[cfg(target_os = "macos")] - #[allow(deprecated)] - { - use cocoa::appkit::NSApplication; - use cocoa::base::nil; - use cocoa::foundation::NSAutoreleasePool; - - unsafe { - let _pool = NSAutoreleasePool::new(nil); - let app_macos = NSApplication::sharedApplication(nil); - app_macos.setActivationPolicy_( - cocoa::appkit::NSApplicationActivationPolicy::NSApplicationActivationPolicyAccessory, - ); + if let Ok(mut guard) = state.manager.lock() { + if guard.is_none() && enable_watch { + match NotifyWatcherManager::start_all(app.handle().clone()) { + Ok(manager) => { + tracing::debug!( + "Config notify watchers started, emitting event {EXTERNAL_CHANGE_EVENT}" + ); + *guard = Some(manager); + } + Err(err) => { + tracing::error!("Failed to start notify watchers: {err:?}"); + } + } + } else { + tracing::info!( + already_running = guard.is_some(), + enable_watch, + "Skip starting notify watcher" + ); + } } - tracing::debug!("macOS Dock 图标已隐藏"); } + + Ok(()) } -/// 初始化内置 Profile(用于透明代理配置切换) -fn initialize_proxy_profiles() -> Result<(), Box> { - use duckcoding::services::profile_manager::ProfileManager; - use duckcoding::services::proxy_config_manager::ProxyConfigManager; - - let proxy_mgr = ProxyConfigManager::new()?; - let profile_mgr = ProfileManager::new()?; - - for tool_id in &["claude-code", "codex", "gemini-cli"] { - if let Ok(Some(config)) = proxy_mgr.get_config(tool_id) { - // 只有在配置完整时才创建内置 Profile - if config.enabled - && config.local_api_key.is_some() - && config.real_api_key.is_some() - && config.real_base_url.is_some() - { - let proxy_profile_name = format!("dc_proxy_{}", tool_id.replace("-", "_")); - let proxy_endpoint = format!("http://127.0.0.1:{}", config.port); - let proxy_key = config.local_api_key.unwrap(); - - let result = match *tool_id { - "claude-code" => profile_mgr.save_claude_profile_internal( - &proxy_profile_name, - proxy_key, - proxy_endpoint, - ), - "codex" => profile_mgr.save_codex_profile_internal( - &proxy_profile_name, - proxy_key, - proxy_endpoint, - Some("responses".to_string()), - ), - "gemini-cli" => profile_mgr.save_gemini_profile_internal( - &proxy_profile_name, - proxy_key, - proxy_endpoint, - None, // 不设置 model,保留用户原有配置 - ), - _ => continue, - }; - - if let Err(e) = result { - tracing::warn!( - tool_id = tool_id, - error = ?e, - "初始化内置 Profile 失败" +/// 延迟检查应用更新 +fn schedule_update_check(app_handle: AppHandle) { + tauri::async_runtime::spawn(async move { + // 延迟1秒,避免影响启动速度 + tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; + tracing::info!("启动时自动检查更新"); + + // 获取 UpdateServiceState 并检查更新 + let state = app_handle.state::(); + match state.service.check_for_updates().await { + Ok(update_info) => { + if update_info.has_update { + tracing::info!( + version = %update_info.latest_version, + "发现新版本" ); + if let Err(e) = app_handle.emit("update-available", &update_info) { + tracing::error!(error = ?e, "发送更新可用事件失败"); + } } else { - tracing::debug!( - tool_id = tool_id, - profile = %proxy_profile_name, - "已初始化内置 Profile" - ); + tracing::debug!("当前已是最新版本"); } } + Err(e) => { + tracing::error!(error = ?e, "启动时检查更新失败"); + } } - } + }); +} + +/// 执行应用启动钩子(setup) +fn setup_app_hooks(app: &mut tauri::App) -> tauri::Result<()> { + // 1. 应用代理配置 + apply_global_proxy().ok(); + + // 2. 设置工作目录 + setup_working_directory(app)?; + + // 3. 启动配置监听 + start_config_watcher(app)?; + + // 4. 创建系统托盘 + setup::tray::setup_system_tray(app)?; + + // 5. 处理窗口关闭事件 + setup::tray::setup_window_close_handler(app)?; + + // 6. 启动后检查更新 + schedule_update_check(app.handle().clone()); Ok(()) } fn main() { - // 🆕 初始化日志系统(必须在最前面) - use duckcoding::core::init_logger; - use duckcoding::utils::config::read_global_config; - - // 从配置文件读取日志配置,失败则使用默认配置 - let log_config = read_global_config() - .ok() - .flatten() - .map(|cfg| cfg.log_config) - .unwrap_or_default(); - - if let Err(e) = init_logger(&log_config) { - // 日志系统初始化失败时使用 eprintln!(因为 tracing 还不可用) - eprintln!("WARNING: Failed to initialize logging system: {}", e); - // 继续运行,但日志功能将不可用 - } - - tracing::info!("DuckCoding 应用启动"); - - // 🆕 初始化内置 Profile(用于透明代理) - if let Err(e) = initialize_proxy_profiles() { - tracing::warn!(error = ?e, "初始化内置 Profile 失败"); - } + // 使用封装的初始化函数 + let init_ctx = tauri::async_runtime::block_on(async { + setup::initialize_app().await.expect("应用初始化失败") + }); - // 创建透明代理服务实例(旧架构,保持兼容) - let transparent_proxy_port = 8787; // 默认端口,实际会从配置读取 - let transparent_proxy_service = TransparentProxyService::new(transparent_proxy_port); - let transparent_proxy_state = TransparentProxyState { - service: Arc::new(TokioMutex::new(transparent_proxy_service)), - }; let watcher_state = ExternalWatcherState { manager: Mutex::new(None), }; - // 创建多工具代理管理器(新架构) - let proxy_manager = Arc::new(ProxyManager::new()); let proxy_manager_state = ProxyManagerState { - manager: proxy_manager.clone(), + manager: init_ctx.proxy_manager, }; - // 异步启动配置了自启动的透明代理 - let proxy_manager_for_auto_start = proxy_manager.clone(); - tauri::async_runtime::spawn(async move { - duckcoding::auto_start_proxies(&proxy_manager_for_auto_start).await; - }); - let update_service_state = UpdateServiceState::new(); - // 执行数据迁移(版本驱动) - tracing::info!("执行数据迁移检查"); - tauri::async_runtime::block_on(async { - let migration_manager = duckcoding::create_migration_manager(); - match migration_manager.run_all().await { - Ok(results) => { - if !results.is_empty() { - tracing::info!("迁移执行完成:{} 个迁移", results.len()); - for result in results { - if result.success { - tracing::info!("✅ {}: {}", result.migration_id, result.message); - } else { - tracing::error!("❌ {}: {}", result.migration_id, result.message); - } - } - } - } - Err(e) => { - tracing::error!("迁移执行失败: {}", e); - } - } - }); - - // 创建工具注册表(统一工具管理系统) - let tool_registry = tauri::async_runtime::block_on(async { - duckcoding::ToolRegistry::new() - .await - .expect("无法创建工具注册表") - }); let tool_registry_state = ToolRegistryState { - registry: Arc::new(TokioMutex::new(tool_registry)), + registry: init_ctx.tool_registry, }; - // 判断是否启用单实例模式 - // 开发环境:始终禁用(方便调试和与正式版隔离) - // 生产环境:根据配置决定(默认启用) - let single_instance_enabled = if cfg!(debug_assertions) { - false // 开发环境禁用 - } else { - // 生产环境读取配置 - read_global_config() - .ok() - .flatten() - .map(|cfg| cfg.single_instance_enabled) - .unwrap_or(true) // 默认启用 - }; + // 判断单实例模式 + let single_instance_enabled = determine_single_instance_mode(); tracing::info!( is_debug = cfg!(debug_assertions), @@ -300,183 +205,12 @@ fn main() { ); let builder = tauri::Builder::default() - .manage(transparent_proxy_state) .manage(proxy_manager_state) .manage(watcher_state) .manage(update_service_state) .manage(tool_registry_state) .setup(|app| { - // 尝试在应用启动时加载全局配置并应用代理设置,确保子进程继承代理 env - apply_proxy_if_configured(); - - // 设置工作目录到项目根目录(跨平台支持) - if let Ok(resource_dir) = app.path().resource_dir() { - tracing::debug!(resource_dir = ?resource_dir, "资源目录"); - - if cfg!(debug_assertions) { - // 开发模式: resource_dir 是 src-tauri/target/debug - // 需要回到项目根目录(上三级) - let project_root = resource_dir - .parent() // target - .and_then(|p| p.parent()) // src-tauri - .and_then(|p| p.parent()) // 项目根目录 - .unwrap_or(&resource_dir); - - tracing::debug!(project_root = ?project_root, "开发模式,设置工作目录"); - let _ = env::set_current_dir(project_root); - } else { - // 生产模式: 跨平台支持 - let parent_dir = if cfg!(target_os = "macos") { - // macOS: .app/Contents/Resources/ - resource_dir - .parent() - .and_then(|p| p.parent()) - .unwrap_or(&resource_dir) - } else if cfg!(target_os = "windows") { - // Windows: 通常在应用程序目录 - resource_dir.parent().unwrap_or(&resource_dir) - } else { - // Linux: 通常在 /usr/share/appname 或类似位置 - resource_dir.parent().unwrap_or(&resource_dir) - }; - tracing::debug!(parent_dir = ?parent_dir, "生产模式,设置工作目录"); - let _ = env::set_current_dir(parent_dir); - } - } - - tracing::info!(working_dir = ?env::current_dir(), "当前工作目录"); - - // 启动通知式配置 watcher(若可用),增加日志方便排查 - if let Some(state) = app.try_state::() { - let enable_watch = match duckcoding::utils::config::read_global_config() { - Ok(Some(cfg)) => cfg.external_watch_enabled, - _ => true, - }; - if !enable_watch { - tracing::info!("External config watcher disabled by config"); - } - - if let Ok(mut guard) = state.manager.lock() { - if guard.is_none() && enable_watch { - match NotifyWatcherManager::start_all(app.handle().clone()) { - Ok(manager) => { - tracing::debug!( - "Config notify watchers started, emitting event {EXTERNAL_CHANGE_EVENT}" - ); - *guard = Some(manager); - } - Err(err) => { - tracing::error!("Failed to start notify watchers: {err:?}"); - } - } - } else { - tracing::info!( - already_running = guard.is_some(), - enable_watch, - "Skip starting notify watcher" - ); - } - } - } - - // 创建系统托盘菜单 - let tray_menu = create_tray_menu(app.handle())?; - let app_handle2 = app.handle().clone(); - - let _tray = TrayIconBuilder::new() - .icon(app.default_window_icon().unwrap().clone()) - .menu(&tray_menu) - .show_menu_on_left_click(false) - .on_menu_event(move |app, event| { - tracing::debug!(event_id = ?event.id, "托盘菜单事件"); - match event.id.as_ref() { - "show" => { - tracing::info!("从托盘显示窗口"); - focus_main_window(app); - } - "check_update" => { - tracing::info!("从托盘请求检查更新"); - // 发送检查更新事件到前端 - if let Err(e) = app.emit("request-check-update", ()) { - tracing::error!(error = ?e, "发送更新检查事件失败"); - } - } - "quit" => { - tracing::info!("从托盘退出应用"); - app.exit(0); - } - _ => {} - } - }) - .on_tray_icon_event(move |_tray, event| { - tracing::trace!(event = ?event, "托盘图标事件"); - match event { - TrayIconEvent::Click { - button: MouseButton::Left, - button_state: MouseButtonState::Up, - .. - } => { - tracing::info!("托盘图标左键点击"); - focus_main_window(&app_handle2); - } - _ => { - // 不打印太多日志 - } - } - }) - .build(app)?; - - // 处理窗口关闭事件 - 最小化到托盘而不是退出 - if let Some(window) = app.get_webview_window("main") { - let window_clone = window.clone(); - - window.on_window_event(move |event| { - if let tauri::WindowEvent::CloseRequested { api, .. } = event { - tracing::info!("窗口关闭请求 - 提示用户选择操作"); - // 阻止默认关闭行为 - api.prevent_close(); - if let Err(err) = window_clone.emit(CLOSE_CONFIRM_EVENT, ()) { - tracing::error!( - error = ?err, - "发送关闭确认事件失败,降级为隐藏窗口" - ); - hide_window_to_tray(&window_clone); - } - } - }); - } - - // 启动后延迟检查更新 - let app_handle_for_update = app.handle().clone(); - tauri::async_runtime::spawn(async move { - // 延迟1秒,避免影响启动速度 - tokio::time::sleep(tokio::time::Duration::from_secs(1)).await; - tracing::info!("启动时自动检查更新"); - - // 获取 UpdateServiceState 并检查更新 - let state = app_handle_for_update.state::(); - match state.service.check_for_updates().await { - Ok(update_info) => { - if update_info.has_update { - tracing::info!( - version = %update_info.latest_version, - "发现新版本" - ); - if let Err(e) = - app_handle_for_update.emit("update-available", &update_info) - { - tracing::error!(error = ?e, "发送更新可用事件失败"); - } - } else { - tracing::debug!("当前已是最新版本"); - } - } - Err(e) => { - tracing::error!(error = ?e, "启动时检查更新失败"); - } - } - }); - + setup_app_hooks(app)?; Ok(()) }) .plugin(tauri_plugin_shell::init()) @@ -502,14 +236,16 @@ fn main() { tracing::error!(error = ?err, "发送单实例事件失败"); } - focus_main_window(app); + setup::focus_main_window(app); })) } else { tracing::info!("单实例插件已禁用(开发环境或用户配置)"); builder }; + // 注册所有 Tauri 命令(按功能分组) let builder = builder.invoke_handler(tauri::generate_handler![ + // 工具检测与状态管理 check_installations, refresh_tool_status, check_node_environment, @@ -525,40 +261,42 @@ fn main() { scan_all_tool_candidates, detect_single_tool, detect_tool_without_save, - // 请使用新的 pm_ 系列命令 + // 全局配置管理 save_global_config, get_global_config, generate_api_key_for_tool, get_external_changes, ack_external_change, import_native_change, + // 使用统计 get_usage_stats, get_user_quota, + // API 请求 fetch_api, + // 余额监控 load_balance_configs, save_balance_config, update_balance_config, delete_balance_config, migrate_balance_from_localstorage, + // 窗口管理 handle_close_action, - // expose current proxy for debugging/testing + // 代理调试 get_current_proxy, apply_proxy_now, test_proxy_request, + // Claude Code 配置 get_claude_settings, save_claude_settings, get_claude_schema, + // Codex 配置 get_codex_settings, save_codex_settings, get_codex_schema, + // Gemini CLI 配置 get_gemini_settings, save_gemini_settings, get_gemini_schema, - // 透明代理相关命令 - start_transparent_proxy, - stop_transparent_proxy, - get_transparent_proxy_status, - update_transparent_proxy_config, // 多工具透明代理命令(新架构) start_tool_proxy, stop_tool_proxy, diff --git a/src-tauri/src/models/config.rs b/src-tauri/src/models/config.rs index d49bc6a..7e03d4b 100644 --- a/src-tauri/src/models/config.rs +++ b/src-tauri/src/models/config.rs @@ -152,21 +152,6 @@ pub struct GlobalConfig { pub proxy_password: Option, #[serde(default)] pub proxy_bypass_urls: Vec, // 代理过滤URL列表 - // 透明代理功能 (实验性) - #[serde(default)] - pub transparent_proxy_enabled: bool, - #[serde(default = "default_transparent_proxy_port")] - pub transparent_proxy_port: u16, - #[serde(default)] - pub transparent_proxy_api_key: Option, // 用于保护本地代理的 API Key - // 保存真实的 ClaudeCode API 配置(透明代理启用时使用) - #[serde(default)] - pub transparent_proxy_real_api_key: Option, - #[serde(default)] - pub transparent_proxy_real_base_url: Option, - // 允许局域网访问透明代理(默认仅本地访问) - #[serde(default)] - pub transparent_proxy_allow_public: bool, // 多工具透明代理配置(新架构) #[serde(default = "default_proxy_configs")] pub proxy_configs: HashMap, @@ -196,10 +181,6 @@ pub struct GlobalConfig { pub single_instance_enabled: bool, } -fn default_transparent_proxy_port() -> u16 { - 8787 -} - fn default_proxy_configs() -> HashMap { let mut configs = HashMap::new(); diff --git a/src-tauri/src/services/config/watcher.rs b/src-tauri/src/services/config/watcher.rs index 3d3b7bc..b31148a 100644 --- a/src-tauri/src/services/config/watcher.rs +++ b/src-tauri/src/services/config/watcher.rs @@ -7,7 +7,7 @@ use super::types::{ExternalConfigChange, ImportExternalChangeResult}; use crate::models::Tool; use crate::services::profile_manager::ProfileManager; -use anyhow::Result; +use anyhow::{anyhow, Result}; use chrono::{DateTime, Utc}; use notify::{ Config as NotifyConfig, Event, EventKind, RecommendedWatcher, RecursiveMode, Watcher, @@ -157,7 +157,7 @@ pub fn detect_external_changes() -> Result> { } let current_checksum = compute_native_checksum(&tool); - let active = active_opt.unwrap(); + let active = active_opt.ok_or_else(|| anyhow!("工具 {} 无激活 Profile", tool.id))?; let last_checksum = active.native_checksum.clone(); if last_checksum.as_ref() != current_checksum.as_ref() { diff --git a/src-tauri/src/services/migration_manager/manager.rs b/src-tauri/src/services/migration_manager/manager.rs index de10997..1be6239 100644 --- a/src-tauri/src/services/migration_manager/manager.rs +++ b/src-tauri/src/services/migration_manager/manager.rs @@ -181,12 +181,6 @@ impl MigrationManager { proxy_username: None, proxy_password: None, proxy_bypass_urls: vec![], - transparent_proxy_enabled: false, - transparent_proxy_port: 8787, - transparent_proxy_api_key: None, - transparent_proxy_real_api_key: None, - transparent_proxy_real_base_url: None, - transparent_proxy_allow_public: false, proxy_configs: std::collections::HashMap::new(), session_endpoint_config_enabled: false, hide_transparent_proxy_tip: false, diff --git a/src-tauri/src/services/migration_manager/migrations/proxy_config.rs b/src-tauri/src/services/migration_manager/migrations/proxy_config.rs index b36c35f..cd7c75c 100644 --- a/src-tauri/src/services/migration_manager/migrations/proxy_config.rs +++ b/src-tauri/src/services/migration_manager/migrations/proxy_config.rs @@ -3,7 +3,6 @@ // 将旧的 transparent_proxy_* 字段迁移到 proxy_configs["claude-code"] use crate::data::DataManager; -use crate::models::GlobalConfig; use crate::services::migration_manager::migration_trait::{Migration, MigrationResult}; use crate::utils::config::global_config_path; use anyhow::Result; @@ -45,43 +44,117 @@ impl Migration for ProxyConfigMigration { let config_path = global_config_path().map_err(|e| anyhow::anyhow!(e))?; let manager = DataManager::new(); - let config_value = manager.json_uncached().read(&config_path)?; - let mut config: GlobalConfig = serde_json::from_value(config_value)?; + let mut config_value = manager.json_uncached().read(&config_path)?; let mut migrated = false; - // 检查是否需要迁移 - if config.transparent_proxy_enabled - || config.transparent_proxy_api_key.is_some() - || config.transparent_proxy_real_api_key.is_some() - { - // 获取或创建 claude-code 的配置 - let claude_config = config - .proxy_configs - .entry("claude-code".to_string()) - .or_default(); - - // 只有当新配置还是默认值时才迁移 - if !claude_config.enabled && claude_config.real_api_key.is_none() { - claude_config.enabled = config.transparent_proxy_enabled; - claude_config.port = config.transparent_proxy_port; - claude_config.local_api_key = config.transparent_proxy_api_key.clone(); - claude_config.real_api_key = config.transparent_proxy_real_api_key.clone(); - claude_config.real_base_url = config.transparent_proxy_real_base_url.clone(); - claude_config.allow_public = config.transparent_proxy_allow_public; - - migrated = true; + // 使用 serde_json::Value 手动处理,避免结构体字段不匹配 + if let Some(config_obj) = config_value.as_object_mut() { + // 检查是否需要迁移(检查旧字段是否存在) + let has_old_fields = config_obj.get("transparent_proxy_enabled").is_some() + || config_obj.get("transparent_proxy_api_key").is_some() + || config_obj.get("transparent_proxy_real_api_key").is_some(); + + if has_old_fields { + // 读取旧字段值 + let old_enabled = config_obj + .get("transparent_proxy_enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let old_port = config_obj + .get("transparent_proxy_port") + .and_then(|v| v.as_u64()) + .unwrap_or(8787) as u16; + let old_local_key = config_obj + .get("transparent_proxy_api_key") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let old_real_key = config_obj + .get("transparent_proxy_real_api_key") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let old_real_url = config_obj + .get("transparent_proxy_real_base_url") + .and_then(|v| v.as_str()) + .map(|s| s.to_string()); + let old_allow_public = config_obj + .get("transparent_proxy_allow_public") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + + // 获取或创建 proxy_configs + let proxy_configs = config_obj + .entry("proxy_configs".to_string()) + .or_insert_with(|| serde_json::json!({})); + + if let Some(proxy_configs_obj) = proxy_configs.as_object_mut() { + // 获取或创建 claude-code 配置 + let claude_config = proxy_configs_obj + .entry("claude-code".to_string()) + .or_insert_with(|| { + serde_json::json!({ + "enabled": false, + "port": 8787, + "local_api_key": null, + "real_api_key": null, + "real_base_url": null, + "real_model_provider": null, + "real_profile_name": null, + "allow_public": false, + "session_endpoint_config_enabled": false, + "auto_start": false, + "original_active_profile": null + }) + }); + + // 只有当新配置还是默认值时才迁移 + if let Some(claude_obj) = claude_config.as_object_mut() { + let is_default = !claude_obj + .get("enabled") + .and_then(|v| v.as_bool()) + .unwrap_or(false) + && claude_obj + .get("real_api_key") + .and_then(|v| v.as_str()) + .is_none(); + + if is_default { + claude_obj + .insert("enabled".to_string(), serde_json::json!(old_enabled)); + claude_obj.insert("port".to_string(), serde_json::json!(old_port)); + if let Some(key) = old_local_key { + claude_obj + .insert("local_api_key".to_string(), serde_json::json!(key)); + } + if let Some(key) = old_real_key { + claude_obj + .insert("real_api_key".to_string(), serde_json::json!(key)); + } + if let Some(url) = old_real_url { + claude_obj + .insert("real_base_url".to_string(), serde_json::json!(url)); + } + claude_obj.insert( + "allow_public".to_string(), + serde_json::json!(old_allow_public), + ); + + migrated = true; + } + } + } + + // 删除旧字段 + config_obj.remove("transparent_proxy_enabled"); + config_obj.remove("transparent_proxy_port"); + config_obj.remove("transparent_proxy_api_key"); + config_obj.remove("transparent_proxy_real_api_key"); + config_obj.remove("transparent_proxy_real_base_url"); + config_obj.remove("transparent_proxy_allow_public"); + + // 保存配置 + manager.json_uncached().write(&config_path, &config_value)?; } - - // 清除旧字段 - config.transparent_proxy_enabled = false; - config.transparent_proxy_api_key = None; - config.transparent_proxy_real_api_key = None; - config.transparent_proxy_real_base_url = None; - - // 保存配置 - let config_value = serde_json::to_value(&config)?; - manager.json_uncached().write(&config_path, &config_value)?; } let message = if migrated { diff --git a/src-tauri/src/services/profile_manager/manager.rs b/src-tauri/src/services/profile_manager/manager.rs index 9f98636..bc41c74 100644 --- a/src-tauri/src/services/profile_manager/manager.rs +++ b/src-tauri/src/services/profile_manager/manager.rs @@ -4,6 +4,8 @@ use super::types::*; use crate::data::DataManager; use anyhow::{anyhow, Context, Result}; use chrono::Utc; +use fs2::FileExt; +use std::fs::File; use std::path::PathBuf; /// 系统保留的 Profile 名称前缀 @@ -48,11 +50,21 @@ impl ProfileManager { } fn save_profiles_store(&self, store: &ProfilesStore) -> Result<()> { + // 创建锁文件(与 profiles.json 同目录) + let lock_path = self.profiles_path.with_extension("lock"); + let lock_file = File::create(&lock_path).context("创建锁文件失败")?; + + // 获取排他锁(阻塞等待其他写操作完成) + lock_file.lock_exclusive().context("获取文件锁失败")?; + + // 执行写入(受锁保护) let value = serde_json::to_value(store)?; self.data_manager .json() - .write(&self.profiles_path, &value) - .map_err(Into::into) + .write(&self.profiles_path, &value)?; + + // 锁在 lock_file drop 时自动释放 + Ok(()) } pub fn load_active_store(&self) -> Result { @@ -64,11 +76,19 @@ impl ProfileManager { } pub fn save_active_store(&self, store: &ActiveStore) -> Result<()> { + // 创建锁文件(与 active.json 同目录) + let lock_path = self.active_path.with_extension("lock"); + let lock_file = File::create(&lock_path).context("创建锁文件失败")?; + + // 获取排他锁(阻塞等待其他写操作完成) + lock_file.lock_exclusive().context("获取文件锁失败")?; + + // 执行写入(受锁保护) let value = serde_json::to_value(store)?; - self.data_manager - .json() - .write(&self.active_path, &value) - .map_err(Into::into) + self.data_manager.json().write(&self.active_path, &value)?; + + // 锁在 lock_file drop 时自动释放 + Ok(()) } // ==================== Claude Code ==================== diff --git a/src-tauri/src/services/profile_manager/native_config.rs b/src-tauri/src/services/profile_manager/native_config.rs index e1523e5..468e994 100644 --- a/src-tauri/src/services/profile_manager/native_config.rs +++ b/src-tauri/src/services/profile_manager/native_config.rs @@ -70,12 +70,17 @@ fn apply_claude_native(tool: &Tool, profile: &ClaudeProfile) -> Result<()> { serde_json::json!({}) }; - let obj = settings.as_object_mut().unwrap(); + let obj = settings + .as_object_mut() + .ok_or_else(|| anyhow!("Claude 配置格式错误:settings 不是对象"))?; if !obj.contains_key("env") { obj.insert("env".to_string(), Value::Object(Map::new())); } - let env = obj.get_mut("env").unwrap().as_object_mut().unwrap(); + let env = obj + .get_mut("env") + .and_then(|v| v.as_object_mut()) + .ok_or_else(|| anyhow!("Claude 配置缺少 env 字段或格式错误"))?; env.insert( "ANTHROPIC_AUTH_TOKEN".to_string(), Value::String(profile.api_key.clone()), @@ -159,9 +164,8 @@ fn apply_codex_native(tool: &Tool, profile: &CodexProfile, provider_name: &str) let providers_table = root_table .get_mut("model_providers") - .unwrap() - .as_table_mut() - .unwrap(); + .and_then(|item| item.as_table_mut()) + .ok_or_else(|| anyhow!("Codex 配置缺少 model_providers 表"))?; // 检查或创建 provider if !providers_table.contains_key(provider_name) { @@ -178,9 +182,8 @@ fn apply_codex_native(tool: &Tool, profile: &CodexProfile, provider_name: &str) // provider 已存在,检查是否需要更新 let provider_table = providers_table .get_mut(provider_name) - .unwrap() - .as_table_mut() - .unwrap(); + .and_then(|item| item.as_table_mut()) + .ok_or_else(|| anyhow!("Provider {} 不存在或格式错误", provider_name))?; let current_base_url = provider_table .get("base_url") @@ -209,10 +212,12 @@ fn apply_codex_native(tool: &Tool, profile: &CodexProfile, provider_name: &str) serde_json::json!({}) }; - auth.as_object_mut().unwrap().insert( - "OPENAI_API_KEY".to_string(), - Value::String(profile.api_key.clone()), - ); + auth.as_object_mut() + .ok_or_else(|| anyhow!("auth.json 格式错误:不是对象"))? + .insert( + "OPENAI_API_KEY".to_string(), + Value::String(profile.api_key.clone()), + ); manager.json_uncached().write(&auth_path, &auth)?; Ok(()) diff --git a/src-tauri/src/services/proxy/config.rs b/src-tauri/src/services/proxy/config.rs new file mode 100644 index 0000000..50e0117 --- /dev/null +++ b/src-tauri/src/services/proxy/config.rs @@ -0,0 +1,36 @@ +//! 代理配置辅助模块 +//! +//! 提供代理配置的高级操作,避免 utils 层依赖 services 层 + +use crate::services::proxy::ProxyService; +use crate::utils::config::read_global_config; +use anyhow::Result; + +/// 应用全局配置中的代理设置到环境变量 +/// +/// 读取 GlobalConfig,如果配置了 HTTP/HTTPS/SOCKS5 代理, +/// 则应用到当前进程的环境变量中 +/// +/// # 使用场景 +/// - 应用启动时 +/// - 保存代理配置后 +/// - 工具安装/更新时需要代理 +pub fn apply_global_proxy() -> Result<()> { + if let Ok(Some(config)) = read_global_config() { + ProxyService::apply_proxy_from_config(&config); + tracing::debug!("已应用全局代理配置到环境变量"); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_apply_global_proxy() { + // 测试无配置文件时不会 panic + let result = apply_global_proxy(); + assert!(result.is_ok()); + } +} diff --git a/src-tauri/src/services/proxy/headers/claude_processor.rs b/src-tauri/src/services/proxy/headers/claude_processor.rs index 8ac49a9..25d9fe0 100644 --- a/src-tauri/src/services/proxy/headers/claude_processor.rs +++ b/src-tauri/src/services/proxy/headers/claude_processor.rs @@ -14,6 +14,7 @@ use reqwest::header::HeaderMap as ReqwestHeaderMap; /// - URL 构建:使用标准拼接(无特殊逻辑) /// - 认证方式:Bearer Token /// - Authorization header 格式:`Bearer sk-ant-xxx` +#[derive(Debug)] pub struct ClaudeHeadersProcessor; #[async_trait] @@ -48,28 +49,34 @@ impl RequestProcessor for ClaudeHeadersProcessor { && !session_api_key.is_empty() { // 记录会话事件(使用自定义配置) - let _ = SESSION_MANAGER.send_event(SessionEvent::NewRequest { + if let Err(e) = SESSION_MANAGER.send_event(SessionEvent::NewRequest { session_id: user_id.to_string(), tool_id: "claude-code".to_string(), timestamp, - }); + }) { + tracing::warn!("Session 事件发送失败: {}", e); + } (session_url, session_api_key) } else { // 使用全局配置并记录会话 - let _ = SESSION_MANAGER.send_event(SessionEvent::NewRequest { + if let Err(e) = SESSION_MANAGER.send_event(SessionEvent::NewRequest { session_id: user_id.to_string(), tool_id: "claude-code".to_string(), timestamp, - }); + }) { + tracing::warn!("Session 事件发送失败: {}", e); + } (base_url.to_string(), api_key.to_string()) } } else { // 会话不存在,使用全局配置并记录新会话 - let _ = SESSION_MANAGER.send_event(SessionEvent::NewRequest { + if let Err(e) = SESSION_MANAGER.send_event(SessionEvent::NewRequest { session_id: user_id.to_string(), tool_id: "claude-code".to_string(), timestamp, - }); + }) { + tracing::warn!("Session 事件发送失败: {}", e); + } (base_url.to_string(), api_key.to_string()) } } else { diff --git a/src-tauri/src/services/proxy/headers/codex_processor.rs b/src-tauri/src/services/proxy/headers/codex_processor.rs index 2bc1b01..11f8191 100644 --- a/src-tauri/src/services/proxy/headers/codex_processor.rs +++ b/src-tauri/src/services/proxy/headers/codex_processor.rs @@ -21,6 +21,7 @@ use reqwest::header::HeaderMap as ReqwestHeaderMap; /// 根据实际需求添加: /// - OpenAI-Organization header 处理 /// - OpenAI-Project header 处理 +#[derive(Debug)] pub struct CodexHeadersProcessor; #[async_trait] diff --git a/src-tauri/src/services/proxy/headers/gemini_processor.rs b/src-tauri/src/services/proxy/headers/gemini_processor.rs index e89bc49..13bc925 100644 --- a/src-tauri/src/services/proxy/headers/gemini_processor.rs +++ b/src-tauri/src/services/proxy/headers/gemini_processor.rs @@ -18,6 +18,7 @@ use reqwest::header::HeaderMap as ReqwestHeaderMap; /// 根据实际需求添加: /// - x-goog-user-project header 处理(计费项目) /// - OAuth 2.0 令牌支持(如果 Gemini CLI 使用 OAuth) +#[derive(Debug)] pub struct GeminiHeadersProcessor; #[async_trait] diff --git a/src-tauri/src/services/proxy/headers/mod.rs b/src-tauri/src/services/proxy/headers/mod.rs index 7baaf51..f984e2c 100644 --- a/src-tauri/src/services/proxy/headers/mod.rs +++ b/src-tauri/src/services/proxy/headers/mod.rs @@ -36,7 +36,7 @@ pub struct ProcessedRequest { /// - 处理其他 headers(添加/修改/删除) /// - 处理请求体(如需要签名等特殊处理) #[async_trait] -pub trait RequestProcessor: Send + Sync { +pub trait RequestProcessor: Send + Sync + std::fmt::Debug { /// 返回工具标识符 fn tool_id(&self) -> &str; @@ -97,22 +97,20 @@ pub trait RequestProcessor: Send + Sync { /// - `tool_id`: 工具标识符 ("claude-code", "codex", "gemini-cli") /// /// # 返回 -/// - 对应工具的 RequestProcessor 实例 -/// -/// # Panics -/// 当 `tool_id` 不被支持时会 panic -pub fn create_request_processor(tool_id: &str) -> Box { +/// - `Ok(Box)`: 对应工具的 RequestProcessor 实例 +/// - `Err`: 当 tool_id 不被支持时返回错误 +pub fn create_request_processor(tool_id: &str) -> Result> { match tool_id { - "claude-code" => Box::new(ClaudeHeadersProcessor), - "codex" => Box::new(CodexHeadersProcessor), - "gemini-cli" => Box::new(GeminiHeadersProcessor), - _ => panic!("Unsupported tool: {tool_id}"), + "claude-code" => Ok(Box::new(ClaudeHeadersProcessor)), + "codex" => Ok(Box::new(CodexHeadersProcessor)), + "gemini-cli" => Ok(Box::new(GeminiHeadersProcessor)), + _ => Err(anyhow::anyhow!("不支持的工具: {}", tool_id)), } } /// 旧工厂函数名称(向后兼容,已弃用) #[deprecated(since = "0.1.0", note = "请使用 create_request_processor")] -pub fn create_headers_processor(tool_id: &str) -> Box { +pub fn create_headers_processor(tool_id: &str) -> Result> { create_request_processor(tool_id) } @@ -122,20 +120,23 @@ mod tests { #[test] fn test_create_request_processor() { - let claude = create_request_processor("claude-code"); + let claude = + create_request_processor("claude-code").expect("should create claude processor"); assert_eq!(claude.tool_id(), "claude-code"); - let codex = create_request_processor("codex"); + let codex = create_request_processor("codex").expect("should create codex processor"); assert_eq!(codex.tool_id(), "codex"); - let gemini = create_request_processor("gemini-cli"); + let gemini = + create_request_processor("gemini-cli").expect("should create gemini processor"); assert_eq!(gemini.tool_id(), "gemini-cli"); } #[test] - #[should_panic(expected = "Unsupported tool")] fn test_create_invalid_processor() { - create_request_processor("invalid-tool"); + let result = create_request_processor("invalid-tool"); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("不支持的工具")); } #[tokio::test] diff --git a/src-tauri/src/services/proxy/mod.rs b/src-tauri/src/services/proxy/mod.rs index a90d96f..2e67568 100644 --- a/src-tauri/src/services/proxy/mod.rs +++ b/src-tauri/src/services/proxy/mod.rs @@ -2,12 +2,12 @@ // // 包含代理配置、透明代理等功能 +pub mod config; // 代理配置辅助模块 pub mod headers; pub mod proxy_instance; pub mod proxy_manager; pub mod proxy_service; -pub mod transparent_proxy; -pub mod transparent_proxy_config; +pub mod utils; pub use headers::{create_request_processor, ProcessedRequest, RequestProcessor}; // 向后兼容的导出(已弃用) @@ -16,5 +16,3 @@ pub use headers::create_headers_processor; pub use proxy_instance::ProxyInstance; pub use proxy_manager::ProxyManager; pub use proxy_service::ProxyService; -pub use transparent_proxy::{ProxyConfig, TransparentProxyService}; -pub use transparent_proxy_config::TransparentProxyConfigService; diff --git a/src-tauri/src/services/proxy/proxy_instance.rs b/src-tauri/src/services/proxy/proxy_instance.rs index f726ca4..8c82aeb 100644 --- a/src-tauri/src/services/proxy/proxy_instance.rs +++ b/src-tauri/src/services/proxy/proxy_instance.rs @@ -8,21 +8,20 @@ use anyhow::{Context, Result}; use bytes::Bytes; use http_body_util::BodyExt; -use hyper::body::{Body, Frame, Incoming}; +use hyper::body::{Frame, Incoming}; use hyper::server::conn::http1; use hyper::service::service_fn; use hyper::{Method, Request, Response, StatusCode}; use hyper_util::rt::TokioIo; -use pin_project_lite::pin_project; use std::convert::Infallible; use std::net::SocketAddr; -use std::pin::Pin; use std::sync::Arc; -use std::task::{Context as TaskContext, Poll}; use tokio::net::TcpListener; use tokio::sync::RwLock; use super::headers::RequestProcessor; +use super::utils::body::{box_body, BoxBody}; +use super::utils::{error_responses, loop_detector}; use crate::models::proxy_config::ToolProxyConfig; /// 单个代理实例 @@ -196,12 +195,7 @@ async fn handle_request( error = ?e, "请求处理失败" ); - Ok(Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(box_body(http_body_util::Full::new(Bytes::from(format!( - "代理错误: {e}" - ))))) - .unwrap()) + Ok(error_responses::internal_error(&e.to_string())) } } } @@ -217,17 +211,7 @@ async fn handle_request_inner( let proxy_config = { let cfg = config.read().await; if cfg.real_api_key.is_none() || cfg.real_base_url.is_none() { - return Ok(Response::builder() - .status(StatusCode::BAD_GATEWAY) - .header("content-type", "application/json") - .body(box_body(http_body_util::Full::new(Bytes::from(format!( - r#"{{ - "error": "CONFIGURATION_MISSING", - "message": "{tool_id} 透明代理配置不完整", - "details": "请先配置有效的 API Key 和 Base URL" -}}"# - ))))) - .unwrap()); + return Ok(error_responses::configuration_missing(tool_id)); } cfg.clone() }; @@ -250,12 +234,7 @@ async fn handle_request_inner( if let Some(local_key) = &proxy_config.local_api_key { if provided_key != local_key { - return Ok(Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body(box_body(http_body_util::Full::new(Bytes::from( - "Unauthorized: Invalid API Key", - )))) - .unwrap()); + return Ok(error_responses::unauthorized()); } } @@ -292,27 +271,8 @@ async fn handle_request_inner( .context("处理出站请求失败")?; // 回环检测 - let loop_urls = vec![ - format!("http://127.0.0.1:{}", own_port), - format!("https://127.0.0.1:{}", own_port), - format!("http://localhost:{}", own_port), - format!("https://localhost:{}", own_port), - ]; - - for loop_url in &loop_urls { - if processed.target_url.starts_with(loop_url) { - return Ok(Response::builder() - .status(StatusCode::BAD_GATEWAY) - .header("content-type", "application/json") - .body(box_body(http_body_util::Full::new(Bytes::from(format!( - r#"{{ - "error": "PROXY_LOOP_DETECTED", - "message": "{tool_id} 透明代理配置错误导致回环", - "details": "请检查代理配置,确保 Base URL 不指向本地代理端口" -}}"# - ))))) - .unwrap()); - } + if loop_detector::is_proxy_loop(&processed.target_url, own_port) { + return Ok(error_responses::proxy_loop_detected(tool_id)); } tracing::debug!( @@ -379,42 +339,3 @@ async fn handle_request_inner( .unwrap()) } } - -// Body 类型定义 -pin_project! { - pub struct BoxBody { - #[pin] - inner: Pin> + Send>>, - } -} - -impl Body for BoxBody { - type Data = Bytes; - type Error = Box; - - fn poll_frame( - self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - ) -> Poll, Self::Error>>> { - self.project().inner.poll_frame(cx) - } - - fn is_end_stream(&self) -> bool { - self.inner.is_end_stream() - } - - fn size_hint(&self) -> hyper::body::SizeHint { - self.inner.size_hint() - } -} - -// 辅助函数:创建 BoxBody -fn box_body(body: B) -> BoxBody -where - B: Body + Send + 'static, - B::Error: Into>, -{ - BoxBody { - inner: Box::pin(body.map_err(Into::into)), - } -} diff --git a/src-tauri/src/services/proxy/proxy_manager.rs b/src-tauri/src/services/proxy/proxy_manager.rs index c83d050..bb993b9 100644 --- a/src-tauri/src/services/proxy/proxy_manager.rs +++ b/src-tauri/src/services/proxy/proxy_manager.rs @@ -59,7 +59,7 @@ impl ProxyManager { } // 创建 RequestProcessor - let processor = create_request_processor(tool_id); + let processor = create_request_processor(tool_id).context("创建请求处理器失败")?; // 创建并启动代理实例 let instance = ProxyInstance::new(tool_id.to_string(), config, processor); diff --git a/src-tauri/src/services/proxy/proxy_service.rs b/src-tauri/src/services/proxy/proxy_service.rs index ebbdbe4..2c13be8 100644 --- a/src-tauri/src/services/proxy/proxy_service.rs +++ b/src-tauri/src/services/proxy/proxy_service.rs @@ -220,12 +220,6 @@ mod tests { proxy_username: None, proxy_password: None, proxy_bypass_urls: vec![], - transparent_proxy_enabled: false, - transparent_proxy_port: 8787, - transparent_proxy_api_key: None, - transparent_proxy_real_api_key: None, - transparent_proxy_real_base_url: None, - transparent_proxy_allow_public: false, proxy_configs: std::collections::HashMap::new(), session_endpoint_config_enabled: false, hide_transparent_proxy_tip: false, @@ -254,12 +248,6 @@ mod tests { proxy_username: Some("user".to_string()), proxy_password: Some("pass".to_string()), proxy_bypass_urls: vec![], - transparent_proxy_enabled: false, - transparent_proxy_port: 8787, - transparent_proxy_api_key: None, - transparent_proxy_real_api_key: None, - transparent_proxy_real_base_url: None, - transparent_proxy_allow_public: false, proxy_configs: std::collections::HashMap::new(), session_endpoint_config_enabled: false, hide_transparent_proxy_tip: false, @@ -291,12 +279,6 @@ mod tests { proxy_username: None, proxy_password: None, proxy_bypass_urls: vec![], - transparent_proxy_enabled: false, - transparent_proxy_port: 8787, - transparent_proxy_api_key: None, - transparent_proxy_real_api_key: None, - transparent_proxy_real_base_url: None, - transparent_proxy_allow_public: false, proxy_configs: std::collections::HashMap::new(), session_endpoint_config_enabled: false, hide_transparent_proxy_tip: false, diff --git a/src-tauri/src/services/proxy/transparent_proxy.rs b/src-tauri/src/services/proxy/transparent_proxy.rs deleted file mode 100644 index 65232c3..0000000 --- a/src-tauri/src/services/proxy/transparent_proxy.rs +++ /dev/null @@ -1,453 +0,0 @@ -// 透明代理服务 - 用于 ClaudeCode 账户快速切换 -// 本地 HTTP 代理,拦截请求并替换 API Key 和 URL,支持 SSE 流式响应 - -use anyhow::{Context, Result}; -use bytes::Bytes; -use http_body_util::BodyExt; -use hyper::body::{Body, Frame, Incoming}; -use hyper::server::conn::http1; -use hyper::service::service_fn; -use hyper::{Method, Request, Response, StatusCode}; -use hyper_util::rt::TokioIo; -use pin_project_lite::pin_project; -use std::convert::Infallible; -use std::net::SocketAddr; -use std::pin::Pin; -use std::sync::Arc; -use std::task::{Context as TaskContext, Poll}; -use tokio::net::TcpListener; -use tokio::sync::RwLock; - -// 代理配置 -#[derive(Clone, Debug)] -pub struct ProxyConfig { - pub target_api_key: String, - pub target_base_url: String, - pub local_api_key: String, // 用于保护本地代理的 API Key -} - -// 代理服务状态 -pub struct TransparentProxyService { - config: Arc>>, - server_handle: Arc>>>, - port: u16, -} - -impl TransparentProxyService { - pub fn new(port: u16) -> Self { - Self { - config: Arc::new(RwLock::new(None)), - server_handle: Arc::new(RwLock::new(None)), - port, - } - } - - /// 启动代理服务 - pub async fn start(&self, config: ProxyConfig, allow_public: bool) -> Result<()> { - // 检查是否已经在运行 - { - let handle = self.server_handle.read().await; - if handle.is_some() { - anyhow::bail!("透明代理已在运行"); - } - } - - // 验证配置有效性 - 允许空配置,但会在运行时检查 - if config.target_api_key.is_empty() { - tracing::warn!("透明代理启动时缺少 API Key 配置,将在运行时拦截请求"); - } - - if config.target_base_url.is_empty() { - tracing::warn!("透明代理启动时缺少 Base URL 配置,将在运行时拦截请求"); - } - - tracing::debug!("透明代理配置加载完成"); - if !config.target_api_key.is_empty() { - tracing::debug!( - api_key_prefix = &config.target_api_key[..4.min(config.target_api_key.len())], - "目标 API Key" - ); - } else { - tracing::debug!("目标 API Key: 未配置"); - } - if !config.target_base_url.is_empty() { - tracing::debug!(base_url = %config.target_base_url, "目标 Base URL"); - } else { - tracing::debug!("目标 Base URL: 未配置"); - } - - // 保存配置 - { - let mut cfg = self.config.write().await; - *cfg = Some(config); - } - - // 绑定到指定地址 - let addr = if allow_public { - SocketAddr::from(([0, 0, 0, 0], self.port)) - } else { - SocketAddr::from(([127, 0, 0, 1], self.port)) - }; - - tracing::info!( - bind_mode = if allow_public { "0.0.0.0" } else { "127.0.0.1" }, - "透明代理绑定模式" - ); - - let listener = TcpListener::bind(addr).await.context("绑定代理端口失败")?; - - tracing::info!(addr = %addr, "透明代理启动成功"); - - let config_clone = Arc::clone(&self.config); - let port = self.port; // 保存端口信息 - - // 启动服务器 - let handle = tokio::spawn(async move { - loop { - match listener.accept().await { - Ok((stream, addr)) => { - let config = Arc::clone(&config_clone); - tokio::spawn(async move { - let io = TokioIo::new(stream); - let service = service_fn(move |req| { - let config = Arc::clone(&config); - async move { handle_request(req, config, port).await } - }); - - if let Err(err) = - http1::Builder::new().serve_connection(io, service).await - { - tracing::error!( - client_addr = %addr, - error = ?err, - "处理连接失败" - ); - } - }); - } - Err(e) => { - tracing::error!(error = ?e, "接受连接失败"); - } - } - } - }); - - // 保存服务器句柄 - { - let mut h = self.server_handle.write().await; - *h = Some(handle); - } - - Ok(()) - } - - /// 停止代理服务 - pub async fn stop(&self) -> Result<()> { - let handle = { - let mut h = self.server_handle.write().await; - h.take() - }; - - if let Some(handle) = handle { - handle.abort(); - tracing::info!("透明代理已停止"); - } - - // 清空配置 - { - let mut cfg = self.config.write().await; - *cfg = None; - } - - Ok(()) - } - - /// 检查服务是否在运行 - pub async fn is_running(&self) -> bool { - let handle = self.server_handle.read().await; - handle.is_some() - } - - /// 更新配置(无需重启) - pub async fn update_config(&self, config: ProxyConfig) -> Result<()> { - let mut cfg = self.config.write().await; - *cfg = Some(config); - tracing::info!("透明代理配置已更新"); - Ok(()) - } -} - -// 处理单个请求 -async fn handle_request( - req: Request, - config: Arc>>, - own_port: u16, -) -> Result, Infallible> { - match handle_request_inner(req, config, own_port).await { - Ok(res) => Ok(res), - Err(e) => { - tracing::error!(error = ?e, "请求处理失败"); - Ok(Response::builder() - .status(StatusCode::INTERNAL_SERVER_ERROR) - .body(box_body(http_body_util::Full::new(Bytes::from(format!( - "代理错误: {e}" - ))))) - .unwrap()) - } - } -} - -async fn handle_request_inner( - req: Request, - config: Arc>>, - own_port: u16, -) -> Result> { - // 获取配置 - let proxy_config = { - let cfg = config.read().await; - match cfg.as_ref() { - Some(config) => { - // 检查配置是否有效 - if config.target_api_key.is_empty() || config.target_base_url.is_empty() { - return Ok(Response::builder() - .status(StatusCode::BAD_GATEWAY) - .header("content-type", "application/json") - .body(box_body(http_body_util::Full::new(Bytes::from(r#"{ - "error": "CONFIGURATION_MISSING", - "message": "透明代理配置不完整", - "details": "检测到透明代理功能已开启,但缺少有效的API配置。请先在DuckCoding中选择一个有效的配置文件,然后再启动透明代理。", - "suggestion": "请检查以下配置:\n1. 确保已选择有效的ClaudeCode配置文件\n2. 配置文件包含有效的API Key和Base URL\n3. 重新启动透明代理服务" -}"#)))) - .unwrap()); - } - config.clone() - } - None => { - return Ok(Response::builder() - .status(StatusCode::BAD_GATEWAY) - .header("content-type", "application/json") - .body(box_body(http_body_util::Full::new(Bytes::from(r#"{ - "error": "PROXY_NOT_CONFIGURED", - "message": "透明代理未配置", - "details": "透明代理服务正在运行,但没有找到有效的转发配置。这可能是因为:\n1. 透明代理启动时没有备份原始配置\n2. 配置文件已损坏或丢失", - "suggestion": "请重新启动透明代理服务以重新配置,或者在设置中禁用透明代理功能" -}"#)))) - .unwrap()); - } - } - }; - - // 验证本地 API Key - let auth_header = req - .headers() - .get("authorization") - .or_else(|| req.headers().get("x-api-key")) - .and_then(|v| v.to_str().ok()) - .unwrap_or(""); - - // 提取 Bearer token - let provided_key = if let Some(stripped) = auth_header.strip_prefix("Bearer ") { - stripped - } else if let Some(stripped) = auth_header.strip_prefix("x-api-key ") { - stripped - } else { - auth_header - }; - - if provided_key != proxy_config.local_api_key { - return Ok(Response::builder() - .status(StatusCode::UNAUTHORIZED) - .body(box_body(http_body_util::Full::new(Bytes::from( - "Unauthorized: Invalid API Key", - )))) - .unwrap()); - } - - // 构建目标 URL - let path = req.uri().path(); - let query = req - .uri() - .query() - .map(|q| format!("?{q}")) - .unwrap_or_default(); - - // 确保 base_url 不包含尾部斜杠 - let base = proxy_config.target_base_url.trim_end_matches('/'); - - // 如果 base_url 以 /v1 结尾,且 path 以 /v1 开头,则去掉 path 中的 /v1 - // 这是因为 Codex 的配置文件要求 base_url 包含 /v1, - // 但 Codex 发送请求时也会带上 /v1 前缀 - let adjusted_path = if base.ends_with("/v1") && path.starts_with("/v1") { - &path[3..] // 去掉 "/v1" - } else { - path - }; - - let target_url = format!("{base}{adjusted_path}{query}"); - - // 回环检测 - 只检测自己的端口 - let own_proxy_url1 = format!("http://127.0.0.1:{own_port}"); - let own_proxy_url2 = format!("https://127.0.0.1:{own_port}"); - let own_proxy_url3 = format!("http://localhost:{own_port}"); - let own_proxy_url4 = format!("https://localhost:{own_port}"); - - if target_url.starts_with(&own_proxy_url1) - || target_url.starts_with(&own_proxy_url2) - || target_url.starts_with(&own_proxy_url3) - || target_url.starts_with(&own_proxy_url4) - { - tracing::error!( - target_url = %target_url, - proxy_port = own_port, - "检测到透明代理回环" - ); - return Ok(Response::builder() - .status(StatusCode::BAD_GATEWAY) - .header("content-type", "application/json") - .body(box_body(http_body_util::Full::new(Bytes::from(r#"{ - "error": "PROXY_LOOP_DETECTED", - "message": "透明代理配置错误导致回环", - "details": "检测到透明代理正在将请求转发给自己,这通常是因为:\n1. 透明代理的真实配置未正确设置\n2. ClaudeCode配置文件中的Base URL仍指向本地代理\n3. 配置更新过程中出现同步问题", - "suggestion": "请尝试以下解决方案:\n1. 在DuckCoding中重新选择一个有效的配置文件\n2. 确保选择的配置文件包含有效的API Key和Base URL\n3. 如果问题持续,请禁用透明代理功能并重新启用" -}"#)))) - .unwrap()); - } - - // 先获取 headers 和 method - let method = req.method().clone(); - let headers = req.headers().clone(); - - tracing::debug!( - method = %method, - path = %path, - target_url = %target_url, - base_url = %base, - api_key_prefix = &proxy_config.target_api_key[..4.min(proxy_config.target_api_key.len())], - "代理请求" - ); - - // 读取请求体(会消费 req) - let body_bytes = if method != Method::GET && method != Method::HEAD { - req.collect().await?.to_bytes() - } else { - Bytes::new() - }; - - // 使用 reqwest 发送请求(支持 HTTPS) - let mut reqwest_builder = reqwest::Client::new().request(method.clone(), &target_url); - - // 复制 headers - for (name, value) in headers.iter() { - let name_str = name.as_str(); - if name_str.eq_ignore_ascii_case("host") { - continue; - } - if name_str.eq_ignore_ascii_case("authorization") - || name_str.eq_ignore_ascii_case("x-api-key") - { - reqwest_builder = reqwest_builder.header( - "authorization", - format!("Bearer {}", proxy_config.target_api_key), - ); - continue; - } - reqwest_builder = reqwest_builder.header(name, value); - } - - // 确保有 Authorization header - if !headers.contains_key("authorization") && !headers.contains_key("x-api-key") { - reqwest_builder = reqwest_builder.header( - "authorization", - format!("Bearer {}", proxy_config.target_api_key), - ); - } - - // 添加请求体 - if !body_bytes.is_empty() { - reqwest_builder = reqwest_builder.body(body_bytes.to_vec()); - } - - // 发送请求 - let upstream_res = reqwest_builder.send().await.context("上游请求失败")?; - - // 获取状态码和 headers - let status = StatusCode::from_u16(upstream_res.status().as_u16()) - .unwrap_or(StatusCode::INTERNAL_SERVER_ERROR); - - // 检查是否是 SSE 流 - let is_sse = upstream_res - .headers() - .get("content-type") - .and_then(|v| v.to_str().ok()) - .map(|v| v.contains("text/event-stream")) - .unwrap_or(false); - - // 构建响应 - let mut response = Response::builder().status(status); - - // 复制所有响应 headers - for (name, value) in upstream_res.headers().iter() { - response = response.header(name.as_str(), value.as_bytes()); - } - - if is_sse { - tracing::debug!("SSE 流式响应"); - // SSE 流式响应 - 使用 bytes_stream - use futures_util::StreamExt; - - let stream = upstream_res.bytes_stream(); - let mapped_stream = stream.map(|result| { - result - .map(Frame::data) - .map_err(|e| Box::new(e) as Box) - }); - - let body = http_body_util::StreamBody::new(mapped_stream); - Ok(response.body(box_body(body)).unwrap()) - } else { - // 普通响应 - 读取完整 body - let body_bytes = upstream_res.bytes().await.context("读取响应体失败")?; - Ok(response - .body(box_body(http_body_util::Full::new(body_bytes))) - .unwrap()) - } -} - -// Body 类型定义 -pin_project! { - struct BoxBody { - #[pin] - inner: Pin> + Send>>, - } -} - -impl Body for BoxBody { - type Data = Bytes; - type Error = Box; - - fn poll_frame( - self: Pin<&mut Self>, - cx: &mut TaskContext<'_>, - ) -> Poll, Self::Error>>> { - self.project().inner.poll_frame(cx) - } - - fn is_end_stream(&self) -> bool { - self.inner.is_end_stream() - } - - fn size_hint(&self) -> hyper::body::SizeHint { - self.inner.size_hint() - } -} - -// 辅助函数:创建 BoxBody -fn box_body(body: B) -> BoxBody -where - B: Body + Send + 'static, - B::Error: Into>, -{ - BoxBody { - inner: Box::pin(body.map_err(Into::into)), - } -} diff --git a/src-tauri/src/services/proxy/transparent_proxy_config.rs b/src-tauri/src/services/proxy/transparent_proxy_config.rs deleted file mode 100644 index 103a703..0000000 --- a/src-tauri/src/services/proxy/transparent_proxy_config.rs +++ /dev/null @@ -1,563 +0,0 @@ -// 透明代理配置管理服务 -use crate::models::{GlobalConfig, Tool, ToolProxyConfig}; -use crate::services::profile_manager::ProfileManager; -use anyhow::{Context, Result}; -use serde_json::{Map, Value}; -use std::collections::HashMap; -use std::fs; - -pub struct TransparentProxyConfigService; - -impl TransparentProxyConfigService { - /// 启用透明代理 - 保存真实配置并修改工具配置指向本地代理 - pub fn enable_transparent_proxy( - tool: &Tool, - global_config: &mut GlobalConfig, - local_proxy_port: u16, - local_proxy_key: &str, - ) -> Result<()> { - // 1. 读取当前工具的真实配置 - let (real_api_key, real_base_url) = Self::read_tool_config(tool)?; - - // 2. 保存真实配置到 proxy_configs - global_config.ensure_proxy_config(&tool.id, local_proxy_port); - if let Some(proxy_config) = global_config.get_proxy_config_mut(&tool.id) { - proxy_config.real_api_key = Some(real_api_key); - proxy_config.real_base_url = Some(real_base_url); - proxy_config.local_api_key = Some(local_proxy_key.to_string()); - - // 对于 Codex,还需要保存原始的 model_provider - if tool.id == "codex" { - let model_provider = Self::read_codex_model_provider(tool)?; - proxy_config.real_model_provider = Some(model_provider); - } - } - - // 兼容旧字段(仅 claude-code) - if tool.id == "claude-code" { - let (real_api_key_clone, real_base_url_clone) = { - let proxy_config = global_config.get_proxy_config(&tool.id); - ( - proxy_config.and_then(|c| c.real_api_key.clone()), - proxy_config.and_then(|c| c.real_base_url.clone()), - ) - }; - global_config.transparent_proxy_real_api_key = real_api_key_clone; - global_config.transparent_proxy_real_base_url = real_base_url_clone; - } - - // 3. 修改工具配置指向本地代理 - Self::write_proxy_config(tool, local_proxy_port, local_proxy_key)?; - - tracing::info!( - tool_id = %tool.id, - "透明代理已启用,配置已指向本地代理" - ); - - Ok(()) - } - - /// 禁用透明代理 - 恢复真实配置到工具 - pub fn disable_transparent_proxy(tool: &Tool, global_config: &GlobalConfig) -> Result<()> { - let (real_api_key, real_base_url, real_model_provider) = - if let Some(proxy_config) = global_config.get_proxy_config(&tool.id) { - let api_key = proxy_config - .real_api_key - .as_ref() - .ok_or_else(|| anyhow::anyhow!("未找到 {} 保存的真实 API Key", tool.id))?; - let base_url = proxy_config - .real_base_url - .as_ref() - .ok_or_else(|| anyhow::anyhow!("未找到 {} 保存的真实 Base URL", tool.id))?; - let model_provider = proxy_config.real_model_provider.clone(); - (api_key.clone(), base_url.clone(), model_provider) - } else { - // 兼容旧字段(仅 claude-code) - if tool.id == "claude-code" { - let api_key = global_config - .transparent_proxy_real_api_key - .as_ref() - .ok_or_else(|| anyhow::anyhow!("未找到保存的真实 API Key"))?; - let base_url = global_config - .transparent_proxy_real_base_url - .as_ref() - .ok_or_else(|| anyhow::anyhow!("未找到保存的真实 Base URL"))?; - (api_key.clone(), base_url.clone(), None) - } else { - anyhow::bail!("未找到 {} 的代理配置", tool.id); - } - }; - - // 恢复真实配置 - Self::write_real_config_with_provider( - tool, - &real_api_key, - &real_base_url, - real_model_provider.as_deref(), - )?; - - tracing::info!( - tool_id = %tool.id, - "透明代理已禁用,配置已恢复" - ); - - Ok(()) - } - - /// 更新透明代理的真实配置(切换配置时调用) - pub fn update_real_config( - tool: &Tool, - global_config: &mut GlobalConfig, - new_api_key: &str, - new_base_url: &str, - ) -> Result<()> { - // 更新 proxy_configs 中保存的真实配置 - if let Some(proxy_config) = global_config.get_proxy_config_mut(&tool.id) { - proxy_config.real_api_key = Some(new_api_key.to_string()); - proxy_config.real_base_url = Some(new_base_url.to_string()); - } - - // 兼容旧字段(仅 claude-code) - if tool.id == "claude-code" { - global_config.transparent_proxy_real_api_key = Some(new_api_key.to_string()); - global_config.transparent_proxy_real_base_url = Some(new_base_url.to_string()); - } - - tracing::info!( - tool_id = %tool.id, - "透明代理真实配置已更新" - ); - - Ok(()) - } - - /// 更新工具配置指向本地代理(不备份真实配置) - pub fn update_config_to_proxy( - tool: &Tool, - local_proxy_port: u16, - local_proxy_key: &str, - ) -> Result<()> { - Self::write_proxy_config(tool, local_proxy_port, local_proxy_key)?; - tracing::info!( - tool_id = %tool.id, - "配置已更新指向本地代理" - ); - Ok(()) - } - - /// 获取真实的 API 配置(用于代理服务) - pub fn get_real_config(global_config: &GlobalConfig) -> Result<(String, String)> { - // 兼容旧字段 - let api_key = global_config - .transparent_proxy_real_api_key - .as_ref() - .ok_or_else(|| anyhow::anyhow!("未找到真实 API Key"))? - .clone(); - - let base_url = global_config - .transparent_proxy_real_base_url - .as_ref() - .ok_or_else(|| anyhow::anyhow!("未找到真实 Base URL"))? - .clone(); - - Ok((api_key, base_url)) - } - - /// 获取指定工具的真实配置 - pub fn get_tool_real_config( - tool_id: &str, - proxy_config: &ToolProxyConfig, - ) -> Result<(String, String)> { - let api_key = proxy_config - .real_api_key - .as_ref() - .ok_or_else(|| anyhow::anyhow!("未找到 {tool_id} 的真实 API Key"))? - .clone(); - - let base_url = proxy_config - .real_base_url - .as_ref() - .ok_or_else(|| anyhow::anyhow!("未找到 {tool_id} 的真实 Base URL"))? - .clone(); - - Ok((api_key, base_url)) - } - - // ==================== 私有方法 ==================== - - /// 读取工具的当前配置(API Key 和 Base URL) - fn read_tool_config(tool: &Tool) -> Result<(String, String)> { - match tool.id.as_str() { - "claude-code" => Self::read_claude_config(tool), - "codex" => Self::read_codex_config(tool), - "gemini-cli" => Self::read_gemini_config(tool), - _ => anyhow::bail!("不支持的工具: {}", tool.id), - } - } - - /// 写入代理配置到工具 - fn write_proxy_config(tool: &Tool, port: u16, api_key: &str) -> Result<()> { - let base_url = format!("http://127.0.0.1:{port}"); - match tool.id.as_str() { - "claude-code" => Self::write_claude_config(tool, api_key, &base_url), - "codex" => Self::write_codex_config(tool, api_key, &base_url), - "gemini-cli" => Self::write_gemini_config(tool, api_key, &base_url), - _ => anyhow::bail!("不支持的工具: {}", tool.id), - } - } - - /// 写入真实配置到工具(带 model_provider 参数,用于 Codex) - fn write_real_config_with_provider( - tool: &Tool, - api_key: &str, - base_url: &str, - model_provider: Option<&str>, - ) -> Result<()> { - match tool.id.as_str() { - "claude-code" => Self::write_claude_config(tool, api_key, base_url), - "codex" => { - Self::write_codex_config_with_provider(tool, api_key, base_url, model_provider) - } - "gemini-cli" => Self::write_gemini_config(tool, api_key, base_url), - _ => anyhow::bail!("不支持的工具: {}", tool.id), - } - } - - // ==================== Claude Code ==================== - - fn read_claude_config(tool: &Tool) -> Result<(String, String)> { - let config_path = tool.config_dir.join(&tool.config_file); - - if !config_path.exists() { - anyhow::bail!("Claude Code 配置文件不存在,请先配置 API"); - } - - let content = fs::read_to_string(&config_path).context("读取 Claude Code 配置失败")?; - let settings: Value = - serde_json::from_str(&content).context("解析 Claude Code 配置失败")?; - - let env = settings - .get("env") - .and_then(|v| v.as_object()) - .ok_or_else(|| anyhow::anyhow!("配置文件缺少 env 字段"))?; - - let api_key = env - .get("ANTHROPIC_AUTH_TOKEN") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("未找到 API Key"))? - .to_string(); - - let base_url = env - .get("ANTHROPIC_BASE_URL") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("未找到 Base URL"))? - .to_string(); - - Ok((api_key, base_url)) - } - - fn write_claude_config(tool: &Tool, api_key: &str, base_url: &str) -> Result<()> { - let config_path = tool.config_dir.join(&tool.config_file); - - let mut settings = if config_path.exists() { - let content = fs::read_to_string(&config_path)?; - serde_json::from_str::(&content).unwrap_or(Value::Object(Map::new())) - } else { - Value::Object(Map::new()) - }; - - if !settings.is_object() { - settings = serde_json::json!({}); - } - - let obj = settings.as_object_mut().unwrap(); - if !obj.contains_key("env") { - obj.insert("env".to_string(), Value::Object(Map::new())); - } - - let env = obj.get_mut("env").unwrap().as_object_mut().unwrap(); - env.insert( - "ANTHROPIC_AUTH_TOKEN".to_string(), - Value::String(api_key.to_string()), - ); - env.insert( - "ANTHROPIC_BASE_URL".to_string(), - Value::String(base_url.to_string()), - ); - - let json = serde_json::to_string_pretty(&settings)?; - fs::write(&config_path, json)?; - - Ok(()) - } - - // ==================== Codex ==================== - - fn read_codex_config(tool: &Tool) -> Result<(String, String)> { - let auth_path = tool.config_dir.join("auth.json"); - let config_path = tool.config_dir.join(&tool.config_file); - - // 读取 API Key from auth.json - if !auth_path.exists() { - anyhow::bail!("Codex auth.json 不存在,请先配置 API"); - } - - let auth_content = fs::read_to_string(&auth_path).context("读取 Codex auth.json 失败")?; - let auth_data: Value = - serde_json::from_str(&auth_content).context("解析 Codex auth.json 失败")?; - - let api_key = auth_data - .get("OPENAI_API_KEY") - .and_then(|v| v.as_str()) - .ok_or_else(|| anyhow::anyhow!("Codex auth.json 中未找到 OPENAI_API_KEY"))? - .to_string(); - - // 读取 Base URL from config.toml - if !config_path.exists() { - anyhow::bail!("Codex config.toml 不存在,请先配置 API"); - } - - let config_content = - fs::read_to_string(&config_path).context("读取 Codex config.toml 失败")?; - let config: toml::Value = - toml::from_str(&config_content).context("解析 Codex config.toml 失败")?; - - // 获取当前 provider - let provider = config - .get("model_provider") - .and_then(|v| v.as_str()) - .unwrap_or("custom"); - - // 从 model_providers.[provider].base_url 获取 - let base_url = config - .get("model_providers") - .and_then(|mp| mp.get(provider)) - .and_then(|p| p.get("base_url")) - .and_then(|v| v.as_str()) - .ok_or_else(|| { - anyhow::anyhow!("Codex config.toml 中未找到 model_providers.{provider}.base_url") - })? - .to_string(); - - Ok((api_key, base_url)) - } - - /// 读取 Codex 当前的 model_provider - fn read_codex_model_provider(tool: &Tool) -> Result { - let config_path = tool.config_dir.join(&tool.config_file); - - if !config_path.exists() { - anyhow::bail!("Codex config.toml 不存在"); - } - - let config_content = - fs::read_to_string(&config_path).context("读取 Codex config.toml 失败")?; - let config: toml::Value = - toml::from_str(&config_content).context("解析 Codex config.toml 失败")?; - - let provider = config - .get("model_provider") - .and_then(|v| v.as_str()) - .unwrap_or("custom") - .to_string(); - - Ok(provider) - } - - fn write_codex_config(tool: &Tool, api_key: &str, base_url: &str) -> Result<()> { - // 默认行为:根据 URL 判断 provider - Self::write_codex_config_with_provider(tool, api_key, base_url, None) - } - - fn write_codex_config_with_provider( - tool: &Tool, - api_key: &str, - base_url: &str, - model_provider: Option<&str>, - ) -> Result<()> { - let auth_path = tool.config_dir.join("auth.json"); - let config_path = tool.config_dir.join(&tool.config_file); - - // 确保目录存在 - fs::create_dir_all(&tool.config_dir)?; - - // 更新 auth.json - let mut auth_data = if auth_path.exists() { - let content = fs::read_to_string(&auth_path)?; - serde_json::from_str::(&content).unwrap_or(Value::Object(Map::new())) - } else { - Value::Object(Map::new()) - }; - - if let Value::Object(ref mut auth_obj) = auth_data { - auth_obj.insert( - "OPENAI_API_KEY".to_string(), - Value::String(api_key.to_string()), - ); - } - - fs::write(&auth_path, serde_json::to_string_pretty(&auth_data)?)?; - - // 更新 config.toml(使用 toml_edit 保留注释) - let mut doc = if config_path.exists() { - let content = fs::read_to_string(&config_path)?; - content - .parse::() - .unwrap_or_else(|_| toml_edit::DocumentMut::new()) - } else { - toml_edit::DocumentMut::new() - }; - - let root_table = doc.as_table_mut(); - - // 判断 provider 类型 - // 如果提供了 model_provider 参数,直接使用它(用于恢复原始配置) - // 否则根据 URL 判断:本地代理使用 "proxy",DuckCoding 使用 "duckcoding",其他使用 "custom" - let provider_key = if let Some(provider) = model_provider { - provider - } else { - let is_local_proxy = base_url.contains("127.0.0.1") || base_url.contains("localhost"); - let is_duckcoding = base_url.contains("duckcoding"); - - if is_local_proxy { - "proxy" - } else if is_duckcoding { - "duckcoding" - } else { - "custom" - } - }; - - // 更新 model_provider - root_table.insert("model_provider", toml_edit::value(provider_key)); - - // 确保 /v1 后缀(配置文件需要包含 /v1) - let normalized_base = base_url.trim_end_matches('/'); - let base_url_with_v1 = if normalized_base.ends_with("/v1") { - normalized_base.to_string() - } else { - format!("{normalized_base}/v1") - }; - - // 确保 model_providers 表存在 - if !root_table - .get("model_providers") - .map(|item| item.is_table()) - .unwrap_or(false) - { - let mut table = toml_edit::Table::new(); - table.set_implicit(false); - root_table.insert("model_providers", toml_edit::Item::Table(table)); - } - - let providers_table = root_table - .get_mut("model_providers") - .and_then(|item| item.as_table_mut()) - .ok_or_else(|| anyhow::anyhow!("model_providers 不是表结构"))?; - - if !providers_table.contains_key(provider_key) { - let mut table = toml_edit::Table::new(); - table.set_implicit(false); - providers_table.insert(provider_key, toml_edit::Item::Table(table)); - } - - if let Some(provider_table) = providers_table - .get_mut(provider_key) - .and_then(|item| item.as_table_mut()) - { - provider_table.insert("name", toml_edit::value(provider_key)); - provider_table.insert("base_url", toml_edit::value(base_url_with_v1)); - provider_table.insert("wire_api", toml_edit::value("responses")); - provider_table.insert("requires_openai_auth", toml_edit::value(true)); - } - - fs::write(&config_path, doc.to_string())?; - - Ok(()) - } - - // ==================== Gemini CLI ==================== - - fn read_gemini_config(tool: &Tool) -> Result<(String, String)> { - let env_path = tool.config_dir.join(".env"); - - if !env_path.exists() { - anyhow::bail!("Gemini CLI .env 不存在,请先配置 API"); - } - - let content = fs::read_to_string(&env_path).context("读取 Gemini .env 失败")?; - - let mut env_vars = HashMap::new(); - for line in content.lines() { - let trimmed = line.trim(); - if !trimmed.is_empty() && !trimmed.starts_with('#') { - if let Some((key, value)) = trimmed.split_once('=') { - env_vars.insert(key.trim().to_string(), value.trim().to_string()); - } - } - } - - let api_key = env_vars - .get("GEMINI_API_KEY") - .ok_or_else(|| anyhow::anyhow!("Gemini .env 中未找到 GEMINI_API_KEY"))? - .clone(); - - let base_url = env_vars - .get("GOOGLE_GEMINI_BASE_URL") - .ok_or_else(|| anyhow::anyhow!("Gemini .env 中未找到 GOOGLE_GEMINI_BASE_URL"))? - .clone(); - - Ok((api_key, base_url)) - } - - fn write_gemini_config(tool: &Tool, api_key: &str, base_url: &str) -> Result<()> { - let env_path = tool.config_dir.join(".env"); - - // 确保目录存在 - fs::create_dir_all(&tool.config_dir)?; - - // 读取现有 .env - let mut env_vars = HashMap::new(); - if env_path.exists() { - let content = fs::read_to_string(&env_path)?; - for line in content.lines() { - let trimmed = line.trim(); - if !trimmed.is_empty() && !trimmed.starts_with('#') { - if let Some((key, value)) = trimmed.split_once('=') { - env_vars.insert(key.trim().to_string(), value.trim().to_string()); - } - } - } - } - - // 更新 API 相关字段 - env_vars.insert("GEMINI_API_KEY".to_string(), api_key.to_string()); - env_vars.insert("GOOGLE_GEMINI_BASE_URL".to_string(), base_url.to_string()); - - // 写入 .env - let mut env_content = String::new(); - for (key, value) in &env_vars { - env_content.push_str(&format!("{key}={value}\n")); - } - - fs::write(&env_path, env_content)?; - - Ok(()) - } - - /// 从备份配置文件读取真实的 API 配置(仅 Claude Code) - pub fn read_real_config_from_backup( - tool: &Tool, - profile_name: &str, - ) -> Result<(String, String)> { - if tool.id != "claude-code" { - anyhow::bail!("从备份读取配置目前仅支持 Claude Code"); - } - - // 使用 ProfileManager 读取 Profile - let profile_manager = ProfileManager::new()?; - let profile = profile_manager.get_claude_profile(profile_name)?; - - Ok((profile.api_key, profile.base_url)) - } -} diff --git a/src-tauri/src/services/proxy/utils/body.rs b/src-tauri/src/services/proxy/utils/body.rs new file mode 100644 index 0000000..2b71650 --- /dev/null +++ b/src-tauri/src/services/proxy/utils/body.rs @@ -0,0 +1,48 @@ +//! HTTP Body 类型定义 +//! +//! 统一的 BoxBody 实现,供 proxy_instance 使用 + +use bytes::Bytes; +use http_body_util::BodyExt; +use hyper::body::{Body, Frame}; +use pin_project_lite::pin_project; +use std::pin::Pin; +use std::task::{Context, Poll}; + +pin_project! { + pub struct BoxBody { + #[pin] + inner: Pin> + Send>>, + } +} + +impl Body for BoxBody { + type Data = Bytes; + type Error = Box; + + fn poll_frame( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + self.project().inner.poll_frame(cx) + } + + fn is_end_stream(&self) -> bool { + self.inner.is_end_stream() + } + + fn size_hint(&self) -> hyper::body::SizeHint { + self.inner.size_hint() + } +} + +/// 创建 BoxBody 的辅助函数 +pub fn box_body(body: B) -> BoxBody +where + B: Body + Send + 'static, + B::Error: Into>, +{ + BoxBody { + inner: Box::pin(body.map_err(Into::into)), + } +} diff --git a/src-tauri/src/services/proxy/utils/error_responses.rs b/src-tauri/src/services/proxy/utils/error_responses.rs new file mode 100644 index 0000000..0d24c2c --- /dev/null +++ b/src-tauri/src/services/proxy/utils/error_responses.rs @@ -0,0 +1,59 @@ +//! 代理错误响应模板 +//! +//! 统一的 JSON 错误格式和响应构建 + +use bytes::Bytes; +use hyper::{Response, StatusCode}; + +use super::body::{box_body, BoxBody}; + +/// 配置缺失错误 +pub fn configuration_missing(tool_id: &str) -> Response { + Response::builder() + .status(StatusCode::BAD_GATEWAY) + .header("content-type", "application/json") + .body(box_body(http_body_util::Full::new(Bytes::from(format!( + r#"{{ + "error": "CONFIGURATION_MISSING", + "message": "{tool_id} 透明代理配置不完整", + "details": "请先配置有效的 API Key 和 Base URL" +}}"# + ))))) + .unwrap() +} + +/// 代理回环错误 +pub fn proxy_loop_detected(tool_id: &str) -> Response { + Response::builder() + .status(StatusCode::BAD_GATEWAY) + .header("content-type", "application/json") + .body(box_body(http_body_util::Full::new(Bytes::from(format!( + r#"{{ + "error": "PROXY_LOOP_DETECTED", + "message": "{tool_id} 透明代理配置错误导致回环", + "details": "请检查代理配置,确保 Base URL 不指向本地代理端口" +}}"# + ))))) + .unwrap() +} + +/// 未授权错误 +pub fn unauthorized() -> Response { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .body(box_body(http_body_util::Full::new(Bytes::from( + "Unauthorized: Invalid API Key", + )))) + .unwrap() +} + +/// 内部错误 +pub fn internal_error(message: &str) -> Response { + Response::builder() + .status(StatusCode::INTERNAL_SERVER_ERROR) + .body(box_body(http_body_util::Full::new(Bytes::from(format!( + "代理错误: {}", + message + ))))) + .unwrap() +} diff --git a/src-tauri/src/services/proxy/utils/loop_detector.rs b/src-tauri/src/services/proxy/utils/loop_detector.rs new file mode 100644 index 0000000..9f2f425 --- /dev/null +++ b/src-tauri/src/services/proxy/utils/loop_detector.rs @@ -0,0 +1,45 @@ +//! 代理回环检测工具 +//! +//! 防止代理配置指向自身导致无限循环 + +/// 检查目标 URL 是否指向自身代理端口 +/// +/// # 参数 +/// - `target_url`: 目标 URL +/// - `own_port`: 当前代理监听的端口 +/// +/// # 返回 +/// - `true`: 检测到回环 +/// - `false`: 未检测到回环 +pub fn is_proxy_loop(target_url: &str, own_port: u16) -> bool { + let loop_urls = vec![ + format!("http://127.0.0.1:{}", own_port), + format!("https://127.0.0.1:{}", own_port), + format!("http://localhost:{}", own_port), + format!("https://localhost:{}", own_port), + ]; + + for loop_url in &loop_urls { + if target_url.starts_with(loop_url) { + return true; + } + } + + false +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_loop_detection() { + assert!(is_proxy_loop("http://127.0.0.1:8787/v1/messages", 8787)); + assert!(is_proxy_loop("https://localhost:8787/api", 8787)); + assert!(!is_proxy_loop( + "https://api.anthropic.com/v1/messages", + 8787 + )); + assert!(!is_proxy_loop("http://127.0.0.1:8788/v1/messages", 8787)); + } +} diff --git a/src-tauri/src/services/proxy/utils/mod.rs b/src-tauri/src/services/proxy/utils/mod.rs new file mode 100644 index 0000000..816ae9d --- /dev/null +++ b/src-tauri/src/services/proxy/utils/mod.rs @@ -0,0 +1,10 @@ +//! 代理工具模块 +//! +//! 包含通用的工具函数和类型定义 + +pub mod body; +pub mod error_responses; +pub mod loop_detector; + +// 重新导出常用类型 +pub use body::{box_body, BoxBody}; diff --git a/src-tauri/src/services/session/manager.rs b/src-tauri/src/services/session/manager.rs index 74186fa..dfd76ca 100644 --- a/src-tauri/src/services/session/manager.rs +++ b/src-tauri/src/services/session/manager.rs @@ -8,11 +8,16 @@ use crate::services::session::db_utils::{ use crate::services::session::models::{ProxySession, SessionEvent, SessionListResponse}; use anyhow::Result; use lazy_static::lazy_static; +use once_cell::sync::Lazy; use std::path::{Path, PathBuf}; use std::sync::Arc; use tokio::sync::mpsc; +use tokio::sync::CancellationToken; use tokio::time::{interval, Duration}; +/// 全局取消令牌,用于优雅关闭后台任务 +static CANCELLATION_TOKEN: Lazy = Lazy::new(CancellationToken::new); + /// 会话管理器单例 pub struct SessionManager { manager: Arc, @@ -75,6 +80,15 @@ impl SessionManager { loop { tokio::select! { + _ = CANCELLATION_TOKEN.cancelled() => { + // 应用关闭,刷盘缓冲区 + if !buffer.is_empty() { + Self::flush_events(&manager, &db_path, &mut buffer); + tracing::info!("Session 事件已刷盘: {} 条", buffer.len()); + } + tracing::info!("Session 批量写入任务已停止"); + break; + } // 接收事件 Some(event) = event_receiver.recv() => { buffer.push(event); @@ -101,17 +115,23 @@ impl SessionManager { let mut cleanup_interval = interval(Duration::from_secs(3600)); loop { - cleanup_interval.tick().await; - - // 清理三个工具的过期会话 - for tool_id in &["claude-code", "codex", "gemini-cli"] { - let _ = Self::cleanup_old_sessions_internal( - &manager_clone, - &db_path_clone, - tool_id, - 1000, - 30, - ); + tokio::select! { + _ = CANCELLATION_TOKEN.cancelled() => { + tracing::info!("Session 清理任务已停止"); + break; + } + _ = cleanup_interval.tick() => { + // 清理三个工具的过期会话 + for tool_id in &["claude-code", "codex", "gemini-cli"] { + let _ = Self::cleanup_old_sessions_internal( + &manager_clone, + &db_path_clone, + tool_id, + 1000, + 30, + ); + } + } } } }); @@ -468,3 +488,11 @@ mod tests { assert_eq!(session.api_key, "sk-test"); } } + +/// 关闭 SessionManager 后台任务 +/// +/// 在应用关闭时调用,优雅地停止所有后台任务并刷盘缓冲区数据 +pub fn shutdown_session_manager() { + tracing::info!("SessionManager 关闭信号已发送"); + CANCELLATION_TOKEN.cancel(); +} diff --git a/src-tauri/src/services/tool/registry.rs b/src-tauri/src/services/tool/registry.rs deleted file mode 100644 index 255de0d..0000000 --- a/src-tauri/src/services/tool/registry.rs +++ /dev/null @@ -1,1118 +0,0 @@ -use crate::models::{InstallMethod, SSHConfig, Tool, ToolInstance, ToolType}; -use crate::services::tool::{DetectorRegistry, ToolInstanceDB}; -use crate::utils::{CommandExecutor, WSLExecutor}; -use anyhow::Result; -use std::collections::HashMap; -use std::sync::Arc; -use tokio::sync::Mutex; - -/// 工具检测进度(用于前端显示) -#[derive(Debug, Clone, serde::Serialize)] -pub struct ToolDetectionProgress { - pub tool_id: String, - pub tool_name: String, - pub status: String, // "pending", "detecting", "done" - pub installed: Option, - pub version: Option, -} - -/// 工具注册表 - 统一管理所有工具实例 -pub struct ToolRegistry { - db: Arc>, - detector_registry: DetectorRegistry, - command_executor: CommandExecutor, - wsl_executor: WSLExecutor, -} - -impl ToolRegistry { - /// 创建新的工具注册表 - pub async fn new() -> Result { - let db = ToolInstanceDB::new()?; - - // 初始化配置文件(如果不存在) - // 注意:迁移逻辑已移到 MigrationManager,这里仅初始化 - db.init_tables()?; - - Ok(Self { - db: Arc::new(Mutex::new(db)), - detector_registry: DetectorRegistry::new(), - command_executor: CommandExecutor::new(), - wsl_executor: WSLExecutor::new(), - }) - } - - /// 检查数据库中是否已有本地工具数据 - pub async fn has_local_tools_in_db(&self) -> Result { - let db = self.db.lock().await; - db.has_local_tools() - } - - /// 获取所有工具实例(按工具ID分组)- 只从数据库读取 - pub async fn get_all_grouped(&self) -> Result>> { - tracing::debug!("开始从数据库获取所有工具实例"); - let mut grouped: HashMap> = HashMap::new(); - - // 从数据库读取所有实例 - let db = self.db.lock().await; - let db_instances = match db.get_all_instances() { - Ok(instances) => { - tracing::debug!("从数据库读取到 {} 个实例", instances.len()); - instances - } - Err(e) => { - tracing::warn!("从数据库读取实例失败: {}, 使用空列表", e); - Vec::new() - } - }; - drop(db); - - for instance in db_instances { - grouped - .entry(instance.base_id.clone()) - .or_default() - .push(instance); - } - - // 确保所有工具都有条目(即使没有实例) - for tool_id in &["claude-code", "codex", "gemini-cli"] { - grouped.entry(tool_id.to_string()).or_default(); - } - - tracing::debug!("完成获取所有工具实例,共 {} 个工具", grouped.len()); - Ok(grouped) - } - - /// 检测本地工具并持久化到数据库(并行检测,用于新手引导) - pub async fn detect_and_persist_local_tools(&self) -> Result> { - let detectors = self.detector_registry.all_detectors(); - tracing::info!("开始并行检测 {} 个本地工具", detectors.len()); - - // 并行检测所有工具 - let futures: Vec<_> = detectors - .iter() - .map(|detector| self.detect_single_tool_by_detector(detector.clone())) - .collect(); - - let results = futures_util::future::join_all(futures).await; - - // 收集结果并保存到数据库 - let mut instances = Vec::new(); - let db = self.db.lock().await; - - for instance in results { - tracing::info!( - "工具 {} 检测完成: installed={}, version={:?}", - instance.tool_name, - instance.installed, - instance.version - ); - // 使用 upsert 避免重复插入 - if let Err(e) = db.upsert_instance(&instance) { - tracing::warn!("保存工具实例失败: {}", e); - } - instances.push(instance); - } - drop(db); - - tracing::info!("本地工具检测并持久化完成"); - Ok(instances) - } - - /// 使用 Detector 检测单个工具(新方法) - async fn detect_single_tool_by_detector( - &self, - detector: std::sync::Arc, - ) -> ToolInstance { - let tool_id = detector.tool_id(); - let tool_name = detector.tool_name(); - tracing::debug!("检测工具: {}", tool_name); - - // 使用 Detector 进行检测 - let installed = detector.is_installed(&self.command_executor).await; - - let (version, install_path, install_method) = if installed { - let version = detector.get_version(&self.command_executor).await; - let path = detector.get_install_path(&self.command_executor).await; - let method = detector.detect_install_method(&self.command_executor).await; - (version, path, method) - } else { - (None, None, None) - }; - - // 检测安装器路径(基于安装方法) - let installer_path = if let (true, Some(method)) = (installed, &install_method) { - match method { - InstallMethod::Npm => { - // 检测 npm 路径:先用 which/where - let npm_detect_cmd = if cfg!(target_os = "windows") { - "where npm" - } else { - "which npm" - }; - - match self.command_executor.execute_async(npm_detect_cmd).await { - result if result.success => { - let path = result.stdout.lines().next().unwrap_or("").trim(); - if !path.is_empty() { - Some(path.to_string()) - } else { - None - } - } - _ => None, - } - } - InstallMethod::Brew => { - // 检测 brew 路径(仅 macOS) - match self.command_executor.execute_async("which brew").await { - result if result.success => { - let path = result.stdout.trim(); - if !path.is_empty() { - Some(path.to_string()) - } else { - None - } - } - _ => None, - } - } - _ => None, - } - } else { - None - }; - - tracing::debug!( - "工具 {} 检测结果: installed={}, version={:?}, path={:?}, method={:?}, installer={:?}", - tool_name, - installed, - version, - install_path, - install_method, - installer_path - ); - - // 创建 ToolInstance(需要获取 Tool 的完整信息) - let tool = Tool::by_id(tool_id).unwrap_or_else(|| { - tracing::warn!("未找到工具定义: {}, 使用对应的静态方法", tool_id); - match tool_id { - "claude-code" => Tool::claude_code(), - "codex" => Tool::codex(), - "gemini-cli" => Tool::gemini_cli(), - _ => panic!("未知工具ID: {}", tool_id), - } - }); - - let now = chrono::Utc::now().timestamp(); - let instance_id = format!("{}-local-{}", tool_id, now); - - ToolInstance { - instance_id, - base_id: tool.id.clone(), - tool_name: tool.name.clone(), - tool_type: ToolType::Local, - install_method, - installed, - version, - install_path, - installer_path, // 使用检测到的安装器路径 - wsl_distro: None, - ssh_config: None, - is_builtin: true, - created_at: now, - updated_at: now, - } - } - - /// 检测单个本地工具并持久化(公开方法) - /// - /// 工作流程: - /// 1. 删除该工具的所有现有本地实例(避免重复) - /// 2. 执行检测 - /// 3. 检查路径是否与其他工具冲突 - /// 4. 如果检测到且无冲突,保存到数据库 - /// - /// 返回:工具实例 - pub async fn detect_and_persist_single_tool(&self, tool_id: &str) -> Result { - let detector = self - .detector_registry - .get(tool_id) - .ok_or_else(|| anyhow::anyhow!("未找到工具 {} 的检测器", tool_id))?; - - tracing::info!("开始检测单个工具: {}", tool_id); - - // 1. 删除该工具的所有本地实例(避免重复) - let db = self.db.lock().await; - let all_instances = db.get_all_instances()?; - for inst in &all_instances { - if inst.base_id == tool_id && inst.tool_type == ToolType::Local { - tracing::info!("删除旧实例: {}", inst.instance_id); - let _ = db.delete_instance(&inst.instance_id); - } - } - drop(db); - - // 2. 执行检测 - let instance = self.detect_single_tool_by_detector(detector).await; - - // 3. 检查路径冲突(如果检测到路径) - if instance.installed { - if let Some(detected_path) = &instance.install_path { - let db = self.db.lock().await; - let all_instances = db.get_all_instances()?; - drop(db); - - // 检查是否有其他工具使用了相同路径 - if let Some(existing) = all_instances.iter().find(|inst| { - inst.install_path.as_ref() == Some(detected_path) - && inst.tool_type == ToolType::Local - && inst.base_id != tool_id // 排除同一工具 - }) { - return Err(anyhow::anyhow!( - "路径冲突:检测到的路径 {} 已被 {} 使用", - detected_path, - existing.tool_name - )); - } - } - } - - // 4. 保存到数据库 - let db = self.db.lock().await; - if instance.installed { - db.upsert_instance(&instance)?; - tracing::info!("工具 {} 检测并保存成功", instance.tool_name); - } else { - tracing::info!("工具 {} 未检测到", instance.tool_name); - } - drop(db); - - Ok(instance) - } - - /// 刷新本地工具状态(重新检测,更新存在的,删除不存在的) - pub async fn refresh_local_tools(&self) -> Result> { - tracing::info!("刷新本地工具状态(重新检测)"); - - let detectors = self.detector_registry.all_detectors(); - - // 并行检测所有工具 - let futures: Vec<_> = detectors - .iter() - .map(|detector| self.detect_single_tool_by_detector(detector.clone())) - .collect(); - - let results = futures_util::future::join_all(futures).await; - - // 获取数据库中现有的本地工具实例 - let db = self.db.lock().await; - let existing_local = db.get_local_instances().unwrap_or_default(); - - // 收集检测到的工具 ID - let detected_ids: std::collections::HashSet = results - .iter() - .filter(|r| r.installed) - .map(|r| r.instance_id.clone()) - .collect(); - - // 删除数据库中存在但本地已不存在的工具 - for existing in &existing_local { - if !detected_ids.contains(&existing.instance_id) { - tracing::info!("工具 {} 已不存在,从数据库删除", existing.tool_name); - if let Err(e) = db.delete_instance(&existing.instance_id) { - tracing::warn!("删除工具实例失败: {}", e); - } - } - } - - // 更新或插入检测到的工具 - let mut instances = Vec::new(); - for instance in results { - if instance.installed { - tracing::info!( - "工具 {} 检测完成: installed={}, version={:?}", - instance.tool_name, - instance.installed, - instance.version - ); - if let Err(e) = db.upsert_instance(&instance) { - tracing::warn!("保存工具实例失败: {}", e); - } - instances.push(instance); - } - } - drop(db); - - tracing::info!("本地工具刷新完成,共 {} 个已安装工具", instances.len()); - Ok(instances) - } - - /// 添加WSL工具实例 - pub async fn add_wsl_instance(&self, base_id: &str, distro_name: &str) -> Result { - // 检查WSL是否可用 - if !WSLExecutor::is_available() { - return Err(anyhow::anyhow!("WSL 不可用,请确保已安装 WSL")); - } - - // 获取工具定义 - let tool = - Tool::by_id(base_id).ok_or_else(|| anyhow::anyhow!("未知的工具ID: {}", base_id))?; - - // 提取命令名称 - let cmd_name = tool - .check_command - .split_whitespace() - .next() - .ok_or_else(|| anyhow::anyhow!("无效的检查命令"))?; - - // 在指定WSL发行版中检测工具 - let (installed, version, install_path) = self - .wsl_executor - .detect_tool_in_distro(Some(distro_name), cmd_name) - .await?; - - // 创建实例 - let instance = ToolInstance::create_wsl_instance( - base_id.to_string(), - tool.name.clone(), - distro_name.to_string(), - installed, - version, - install_path, - ); - - // 保存到数据库 - let db = self.db.lock().await; - db.add_instance(&instance)?; - drop(db); - - Ok(instance) - } - - /// 添加SSH工具实例(本期仅存储配置,不实现检测) - pub async fn add_ssh_instance( - &self, - base_id: &str, - ssh_config: SSHConfig, - ) -> Result { - // 获取工具定义 - let tool = - Tool::by_id(base_id).ok_or_else(|| anyhow::anyhow!("未知的工具ID: {}", base_id))?; - - // 创建SSH实例(本期不检测,installed设为false) - let instance = ToolInstance::create_ssh_instance( - base_id.to_string(), - tool.name.clone(), - ssh_config, - false, // 本期不检测 - None, - None, - ); - - // 检查是否已存在 - let db = self.db.lock().await; - if db.instance_exists(&instance.instance_id)? { - return Err(anyhow::anyhow!("该SSH实例已存在")); - } - db.add_instance(&instance)?; - drop(db); - - Ok(instance) - } - - /// 删除工具实例(仅限SSH类型) - pub async fn delete_instance(&self, instance_id: &str) -> Result<()> { - let db = self.db.lock().await; - - // 获取实例 - let instance = db - .get_instance(instance_id)? - .ok_or_else(|| anyhow::anyhow!("实例不存在: {}", instance_id))?; - - // 检查是否为SSH类型 - if instance.tool_type != ToolType::SSH { - return Err(anyhow::anyhow!("仅允许删除SSH类型的实例")); - } - - // 检查是否为内置实例 - if instance.is_builtin { - return Err(anyhow::anyhow!("不允许删除内置实例")); - } - - // 删除 - db.delete_instance(instance_id)?; - drop(db); - - Ok(()) - } - - /// 刷新所有工具实例(重新检测本地工具并更新数据库) - pub async fn refresh_all(&self) -> Result>> { - // 重新检测本地工具并保存 - self.detect_and_persist_local_tools().await?; - - // 返回所有工具实例 - self.get_all_grouped().await - } - - /// 检测工具的安装方式(用于更新时选择正确的方法) - pub async fn detect_install_methods(&self) -> Result> { - let mut methods = HashMap::new(); - - let detectors = self.detector_registry.all_detectors(); - for detector in detectors { - let tool_id = detector.tool_id(); - if let Some(method) = detector.detect_install_method(&self.command_executor).await { - methods.insert(tool_id.to_string(), method); - } - } - - Ok(methods) - } - - /// 获取本地工具的轻量级状态(供 Dashboard 使用) - /// 优先从数据库读取,如果数据库为空则执行检测并持久化 - pub async fn get_local_tool_status(&self) -> Result> { - tracing::debug!("获取本地工具轻量级状态"); - - // 从数据库读取所有实例(不主动检测) - let grouped = self.get_all_grouped().await?; - - // 转换为轻量级 ToolStatus - let mut statuses = Vec::new(); - let detectors = self.detector_registry.all_detectors(); - - for detector in detectors { - let tool_id = detector.tool_id(); - let tool_name = detector.tool_name(); - - if let Some(instances) = grouped.get(tool_id) { - // 找到 Local 类型的实例 - if let Some(local_instance) = instances - .iter() - .find(|i| i.tool_type == crate::models::ToolType::Local) - { - statuses.push(crate::models::ToolStatus { - id: tool_id.to_string(), - name: tool_name.to_string(), - installed: local_instance.installed, - version: local_instance.version.clone(), - }); - } else { - // 没有本地实例,返回未安装状态 - statuses.push(crate::models::ToolStatus { - id: tool_id.to_string(), - name: tool_name.to_string(), - installed: false, - version: None, - }); - } - } else { - // 数据库中没有该工具的任何实例 - statuses.push(crate::models::ToolStatus { - id: tool_id.to_string(), - name: tool_name.to_string(), - installed: false, - version: None, - }); - } - } - - tracing::debug!("获取本地工具状态完成,共 {} 个工具", statuses.len()); - Ok(statuses) - } - - /// 刷新本地工具状态并返回轻量级视图(供刷新按钮使用) - /// 重新检测 → 更新数据库 → 返回 ToolStatus - pub async fn refresh_and_get_local_status(&self) -> Result> { - tracing::info!("刷新本地工具状态(重新检测)"); - - // 重新检测本地工具 - let instances = self.refresh_local_tools().await?; - - // 转换为轻量级状态 - let mut statuses = Vec::new(); - let detectors = self.detector_registry.all_detectors(); - - for detector in detectors { - let tool_id = detector.tool_id(); - let tool_name = detector.tool_name(); - - if let Some(instance) = instances.iter().find(|i| i.base_id == tool_id) { - statuses.push(crate::models::ToolStatus { - id: tool_id.to_string(), - name: tool_name.to_string(), - installed: instance.installed, - version: instance.version.clone(), - }); - } else { - statuses.push(crate::models::ToolStatus { - id: tool_id.to_string(), - name: tool_name.to_string(), - installed: false, - version: None, - }); - } - } - - tracing::info!("刷新完成,共 {} 个已安装工具", instances.len()); - Ok(statuses) - } - - /// 更新工具实例(使用配置的安装器) - /// - /// # 参数 - /// - instance_id: 实例ID - /// - force: 是否强制更新 - /// - /// # 返回 - /// - Ok(UpdateResult): 更新结果(包含新版本) - /// - Err: 更新失败 - pub async fn update_instance( - &self, - instance_id: &str, - force: bool, - ) -> Result { - use crate::models::ToolType; - use crate::services::tool::InstallerService; - - // 1. 从数据库获取实例信息 - let db = self.db.lock().await; - let all_instances = db.get_all_instances()?; - drop(db); - - let instance = all_instances - .iter() - .find(|inst| inst.instance_id == instance_id && inst.tool_type == ToolType::Local) - .ok_or_else(|| anyhow::anyhow!("未找到实例: {}", instance_id))?; - - // 2. 使用 InstallerService 执行更新 - let installer = InstallerService::new(); - let result = installer - .update_instance_by_installer(instance, force) - .await?; - - // 3. 如果更新成功,更新数据库中的版本号 - if result.success { - if let Some(ref new_version) = result.current_version { - let db = self.db.lock().await; - let mut updated_instance = instance.clone(); - updated_instance.version = Some(new_version.clone()); - updated_instance.updated_at = chrono::Utc::now().timestamp(); - - if let Err(e) = db.update_instance(&updated_instance) { - tracing::warn!("更新数据库版本失败: {}", e); - } - } - } - - Ok(result) - } - - /// 检查工具实例更新(使用配置的路径) - /// - /// # 参数 - /// - instance_id: 实例ID - /// - /// # 返回 - /// - Ok(UpdateResult): 更新信息(包含当前版本和最新版本) - /// - Err: 检查失败 - pub async fn check_update_for_instance( - &self, - instance_id: &str, - ) -> Result { - use crate::models::ToolType; - use crate::services::VersionService; - use crate::utils::parse_version_string; - - // 1. 从数据库获取实例信息 - let db = self.db.lock().await; - let all_instances = db.get_all_instances()?; - drop(db); - - let instance = all_instances - .iter() - .find(|inst| inst.instance_id == instance_id && inst.tool_type == ToolType::Local) - .ok_or_else(|| anyhow::anyhow!("未找到实例: {}", instance_id))?; - - // 2. 使用 install_path 执行 --version 获取当前版本 - let current_version = if let Some(path) = &instance.install_path { - let version_cmd = format!("{} --version", path); - tracing::info!("实例 {} 版本检查命令: {:?}", instance_id, version_cmd); - - let result = self.command_executor.execute_async(&version_cmd).await; - - if result.success { - let raw_version = result.stdout.trim(); - Some(parse_version_string(raw_version)) - } else { - anyhow::bail!("版本号获取错误:无法执行命令 {}", version_cmd); - } - } else { - // 没有路径,使用数据库中的版本 - instance.version.clone() - }; - - // 3. 检查远程最新版本 - let tool_id = &instance.base_id; - let version_service = VersionService::new(); - let version_info = version_service - .check_version( - &crate::models::Tool::by_id(tool_id) - .ok_or_else(|| anyhow::anyhow!("未知工具: {}", tool_id))?, - ) - .await; - - let update_result = match version_info { - Ok(info) => crate::models::UpdateResult { - success: true, - message: "检查完成".to_string(), - has_update: info.has_update, - current_version: current_version.clone(), - latest_version: info.latest_version, - mirror_version: info.mirror_version, - mirror_is_stale: Some(info.mirror_is_stale), - tool_id: Some(tool_id.clone()), - }, - Err(e) => crate::models::UpdateResult { - success: true, - message: format!("无法检查更新: {e}"), - has_update: false, - current_version: current_version.clone(), - latest_version: None, - mirror_version: None, - mirror_is_stale: None, - tool_id: Some(tool_id.clone()), - }, - }; - - // 4. 如果当前版本有变化,更新数据库 - if current_version != instance.version { - let db = self.db.lock().await; - let mut updated_instance = instance.clone(); - updated_instance.version = current_version.clone(); - updated_instance.updated_at = chrono::Utc::now().timestamp(); - - if let Err(e) = db.update_instance(&updated_instance) { - tracing::warn!("更新实例 {} 版本失败: {}", instance_id, e); - } else { - tracing::info!( - "实例 {} 版本已同步更新: {:?} -> {:?}", - instance_id, - instance.version, - current_version - ); - } - } - - Ok(update_result) - } - - /// 刷新数据库中所有工具的版本号(使用配置的路径检测) - /// - /// # 返回 - /// - Ok(Vec): 更新后的工具状态列表 - /// - Err: 刷新失败 - pub async fn refresh_all_tool_versions(&self) -> Result> { - use crate::models::ToolType; - use crate::utils::parse_version_string; - - let db = self.db.lock().await; - let all_instances = db.get_all_instances()?; - drop(db); - - let mut statuses = Vec::new(); - - for instance in all_instances - .iter() - .filter(|i| i.tool_type == ToolType::Local) - { - // 使用 install_path 检测版本 - let new_version = if let Some(path) = &instance.install_path { - let version_cmd = format!("{} --version", path); - tracing::info!("工具 {} 版本检查: {:?}", instance.tool_name, version_cmd); - - let result = self.command_executor.execute_async(&version_cmd).await; - - if result.success { - let raw_version = result.stdout.trim(); - Some(parse_version_string(raw_version)) - } else { - // 版本获取失败,保持原版本 - tracing::warn!("工具 {} 版本检测失败,保持原版本", instance.tool_name); - instance.version.clone() - } - } else { - tracing::warn!("工具 {} 缺少安装路径,保持原版本", instance.tool_name); - instance.version.clone() - }; - - tracing::info!("工具 {} 新版本号: {:?}", instance.tool_name, new_version); - - // 如果版本号有变化,更新数据库 - if new_version != instance.version { - let db = self.db.lock().await; - let mut updated_instance = instance.clone(); - updated_instance.version = new_version.clone(); - updated_instance.updated_at = chrono::Utc::now().timestamp(); - - if let Err(e) = db.update_instance(&updated_instance) { - tracing::warn!("更新实例 {} 失败: {}", instance.instance_id, e); - } else { - tracing::info!( - "工具 {} 版本已更新: {:?} -> {:?}", - instance.tool_name, - instance.version, - new_version - ); - } - } - - // 添加到返回列表 - statuses.push(crate::models::ToolStatus { - id: instance.base_id.clone(), - name: instance.tool_name.clone(), - installed: instance.installed, - version: new_version, - }); - } - - Ok(statuses) - } - - /// 扫描所有工具候选(用于自动扫描) - /// - /// # 参数 - /// - tool_id: 工具ID(如 "claude-code") - /// - /// # 返回 - /// - Ok(Vec): 候选列表 - /// - Err: 扫描失败 - pub async fn scan_tool_candidates( - &self, - tool_id: &str, - ) -> Result> { - use crate::utils::{parse_version_string, scan_installer_paths, scan_tool_executables}; - - // 1. 扫描所有工具路径 - let tool_paths = scan_tool_executables(tool_id); - let mut candidates = Vec::new(); - - // 2. 对每个工具路径:获取版本和安装器 - for tool_path in tool_paths { - // 获取版本 - let version_cmd = format!("{} --version", tool_path); - let result = self.command_executor.execute_async(&version_cmd).await; - - let version = if result.success { - let raw = result.stdout.trim(); - parse_version_string(raw) - } else { - // 版本获取失败,跳过此候选 - continue; - }; - - // 扫描安装器 - let installer_candidates = scan_installer_paths(&tool_path); - let installer_path = installer_candidates.first().map(|c| c.path.clone()); - let install_method = installer_candidates - .first() - .map(|c| c.installer_type.clone()) - .unwrap_or(crate::models::InstallMethod::Official); - - candidates.push(crate::utils::ToolCandidate { - tool_path: tool_path.clone(), - installer_path, - install_method, - version, - }); - } - - Ok(candidates) - } - - /// 验证用户指定的工具路径是否有效 - /// - /// # 参数 - /// - path: 工具路径 - /// - /// # 返回 - /// - Ok(String): 版本号字符串 - /// - Err: 验证失败 - pub async fn validate_tool_path(&self, path: &str) -> Result { - use std::path::PathBuf; - - let path_buf = PathBuf::from(path); - - // 检查文件是否存在 - if !path_buf.exists() { - anyhow::bail!("路径不存在: {}", path); - } - - // 检查是否是文件 - if !path_buf.is_file() { - anyhow::bail!("路径不是文件: {}", path); - } - - // 执行 --version 命令 - let version_cmd = format!("{} --version", path); - let result = self.command_executor.execute_async(&version_cmd).await; - - if !result.success { - anyhow::bail!("命令执行失败,退出码: {:?}", result.exit_code); - } - - // 解析版本号 - let version_str = result.stdout.trim(); - if version_str.is_empty() { - anyhow::bail!("无法获取版本信息"); - } - - // 简单验证:版本号应该包含数字 - if !version_str.chars().any(|c| c.is_numeric()) { - anyhow::bail!("无效的版本信息: {}", version_str); - } - - Ok(version_str.to_string()) - } - - /// 添加手动配置的工具实例 - /// - /// # 参数 - /// - tool_id: 工具ID - /// - path: 工具路径 - /// - install_method: 安装方法 - /// - installer_path: 安装器路径(非 Other 类型时必需) - /// - /// # 返回 - /// - Ok(ToolStatus): 工具状态 - /// - Err: 添加失败 - pub async fn add_tool_instance( - &self, - tool_id: &str, - path: &str, - install_method: InstallMethod, - installer_path: Option, - ) -> Result { - use std::path::PathBuf; - - // 1. 验证工具路径 - let version = self.validate_tool_path(path).await?; - - // 2. 验证安装器路径(非 Other 类型时需要) - if install_method != InstallMethod::Other { - if let Some(ref installer) = installer_path { - let installer_buf = PathBuf::from(installer); - if !installer_buf.exists() { - anyhow::bail!("安装器路径不存在: {}", installer); - } - if !installer_buf.is_file() { - anyhow::bail!("安装器路径不是文件: {}", installer); - } - } else { - anyhow::bail!("非「其他」类型必须提供安装器路径"); - } - } - - // 3. 检查路径是否已存在 - let db = self.db.lock().await; - let all_instances = db.get_all_instances()?; - - // 路径冲突检查 - if let Some(existing) = all_instances.iter().find(|inst| { - inst.install_path.as_ref() == Some(&path.to_string()) - && inst.tool_type == ToolType::Local - }) { - anyhow::bail!( - "路径冲突:该路径已被 {} 使用,无法重复添加", - existing.tool_name - ); - } - - // 4. 获取工具显示名称 - let tool_name = match tool_id { - "claude-code" => "Claude Code", - "codex" => "CodeX", - "gemini-cli" => "Gemini CLI", - _ => tool_id, - }; - - // 5. 创建 ToolInstance(使用时间戳确保唯一性) - let now = chrono::Utc::now().timestamp(); - let instance_id = format!("{}-local-{}", tool_id, now); - let instance = ToolInstance { - instance_id: instance_id.clone(), - base_id: tool_id.to_string(), - tool_name: tool_name.to_string(), - tool_type: ToolType::Local, - install_method: Some(install_method), - installed: true, - version: Some(version.clone()), - install_path: Some(path.to_string()), - installer_path, - wsl_distro: None, - ssh_config: None, - is_builtin: false, - created_at: now, - updated_at: now, - }; - - // 6. 保存到数据库 - db.add_instance(&instance)?; - - // 7. 返回 ToolStatus 格式 - Ok(crate::models::ToolStatus { - id: tool_id.to_string(), - name: tool_name.to_string(), - installed: true, - version: Some(version), - }) - } - - /// 检测单个工具并保存到数据库(带缓存优化) - /// - /// # 参数 - /// - tool_id: 工具ID - /// - force_redetect: 是否强制重新检测 - /// - /// # 返回 - /// - Ok(ToolStatus): 工具状态 - /// - Err: 检测失败 - pub async fn detect_single_tool_with_cache( - &self, - tool_id: &str, - force_redetect: bool, - ) -> Result { - use crate::models::ToolType; - - if !force_redetect { - // 1. 先查询数据库中是否已有该工具的本地实例 - let db = self.db.lock().await; - let all_instances = db.get_all_instances()?; - drop(db); - - // 查找该工具的本地实例 - if let Some(existing) = all_instances.iter().find(|inst| { - inst.base_id == tool_id && inst.tool_type == ToolType::Local && inst.installed - }) { - // 如果已有实例且已安装,直接返回 - tracing::info!("工具 {} 已在数据库中,直接返回", existing.tool_name); - return Ok(crate::models::ToolStatus { - id: tool_id.to_string(), - name: existing.tool_name.clone(), - installed: true, - version: existing.version.clone(), - }); - } - } - - // 2. 执行单工具检测(会删除旧实例避免重复) - let instance = self.detect_and_persist_single_tool(tool_id).await?; - - // 3. 返回 ToolStatus 格式 - Ok(crate::models::ToolStatus { - id: tool_id.to_string(), - name: instance.tool_name.clone(), - installed: instance.installed, - version: instance.version.clone(), - }) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::models::InstallMethod; - - /// 测试 ToolRegistry 创建 - #[tokio::test] - async fn test_registry_creation() { - let result = ToolRegistry::new().await; - assert!(result.is_ok(), "ToolRegistry 创建应该成功"); - } - - /// 测试版本解析在 Registry 上下文中工作正常 - #[tokio::test] - async fn test_validate_tool_path_with_invalid_path() { - let registry = ToolRegistry::new().await.expect("创建 Registry 失败"); - - // 测试不存在的路径 - let result = registry.validate_tool_path("/nonexistent/path").await; - assert!(result.is_err(), "不存在的路径应该返回错误"); - assert!( - result.unwrap_err().to_string().contains("路径不存在"), - "错误信息应包含'路径不存在'" - ); - } - - /// 测试添加工具实例的参数验证 - #[tokio::test] - async fn test_add_tool_instance_validates_installer_path() { - let registry = ToolRegistry::new().await.expect("创建 Registry 失败"); - - // 测试:npm 安装方法但未提供安装器路径 - let result = registry - .add_tool_instance( - "claude-code", - "/some/valid/path", // 这会在验证工具路径时失败,但我们主要测试安装器验证 - InstallMethod::Npm, - None, // 未提供安装器路径 - ) - .await; - - // 应该失败(可能是路径验证失败,也可能是安装器路径验证失败) - assert!(result.is_err(), "缺少安装器路径应该失败"); - } - - /// 测试工具名称映射 - #[test] - fn test_tool_name_mapping() { - // 这个测试验证 add_tool_instance 中的工具名称映射逻辑 - let test_cases = vec![ - ("claude-code", "Claude Code"), - ("codex", "CodeX"), - ("gemini-cli", "Gemini CLI"), - ]; - - for (tool_id, expected_name) in test_cases { - let tool_name = match tool_id { - "claude-code" => "Claude Code", - "codex" => "CodeX", - "gemini-cli" => "Gemini CLI", - _ => tool_id, - }; - assert_eq!(tool_name, expected_name, "工具名称映射应该正确"); - } - } - - /// 测试 has_local_tools_in_db 方法 - #[tokio::test] - async fn test_has_local_tools_in_db() { - let registry = ToolRegistry::new().await.expect("创建 Registry 失败"); - - // 这个测试依赖于实际数据库状态,仅验证方法可调用 - let result = registry.has_local_tools_in_db().await; - assert!(result.is_ok(), "has_local_tools_in_db 应该可以执行"); - } - - /// 测试 get_local_tool_status 方法 - #[tokio::test] - async fn test_get_local_tool_status() { - let registry = ToolRegistry::new().await.expect("创建 Registry 失败"); - - // 测试获取本地工具状态 - let result = registry.get_local_tool_status().await; - assert!(result.is_ok(), "get_local_tool_status 应该可以执行"); - - // 验证返回的工具列表包含已知工具 - if let Ok(statuses) = result { - let tool_ids: Vec = statuses.iter().map(|s| s.id.clone()).collect(); - assert!( - tool_ids.contains(&"claude-code".to_string()) - || tool_ids.contains(&"codex".to_string()) - || tool_ids.contains(&"gemini-cli".to_string()), - "应该包含至少一个已知工具" - ); - } - } -} diff --git a/src-tauri/src/services/tool/registry/detection.rs b/src-tauri/src/services/tool/registry/detection.rs new file mode 100644 index 0000000..cd0a957 --- /dev/null +++ b/src-tauri/src/services/tool/registry/detection.rs @@ -0,0 +1,327 @@ +//! 工具检测模块 +//! +//! 负责工具的自动检测、持久化和缓存管理 + +use super::ToolRegistry; +use crate::models::{InstallMethod, Tool, ToolInstance, ToolType}; +use anyhow::Result; + +impl ToolRegistry { + /// 检测本地工具并持久化到数据库(并行检测,用于新手引导) + pub async fn detect_and_persist_local_tools(&self) -> Result> { + let detectors = self.detector_registry.all_detectors(); + tracing::info!("开始并行检测 {} 个本地工具", detectors.len()); + + // 并行检测所有工具 + let futures: Vec<_> = detectors + .iter() + .map(|detector| self.detect_single_tool_by_detector(detector.clone())) + .collect(); + + let results = futures_util::future::join_all(futures).await; + + // 收集结果并保存到数据库 + let mut instances = Vec::new(); + let db = self.db.read().await; + + for instance in results { + tracing::info!( + "工具 {} 检测完成: installed={}, version={:?}", + instance.tool_name, + instance.installed, + instance.version + ); + // 使用 upsert 避免重复插入 + if let Err(e) = db.upsert_instance(&instance) { + tracing::warn!("保存工具实例失败: {}", e); + } + instances.push(instance); + } + drop(db); + + tracing::info!("本地工具检测并持久化完成"); + Ok(instances) + } + + /// 使用 Detector 检测单个工具(新方法) + async fn detect_single_tool_by_detector( + &self, + detector: std::sync::Arc, + ) -> ToolInstance { + let tool_id = detector.tool_id(); + let tool_name = detector.tool_name(); + tracing::debug!("检测工具: {}", tool_name); + + // 使用 Detector 进行检测 + let installed = detector.is_installed(&self.command_executor).await; + + let (version, install_path, install_method) = if installed { + let version = detector.get_version(&self.command_executor).await; + let path = detector.get_install_path(&self.command_executor).await; + let method = detector.detect_install_method(&self.command_executor).await; + (version, path, method) + } else { + (None, None, None) + }; + + // 检测安装器路径(基于安装方法) + let installer_path = if let (true, Some(method)) = (installed, &install_method) { + match method { + InstallMethod::Npm => { + // 检测 npm 路径:先用 which/where + let npm_detect_cmd = if cfg!(target_os = "windows") { + "where npm" + } else { + "which npm" + }; + + match self.command_executor.execute_async(npm_detect_cmd).await { + result if result.success => { + let path = result.stdout.lines().next().unwrap_or("").trim(); + if !path.is_empty() { + Some(path.to_string()) + } else { + None + } + } + _ => None, + } + } + InstallMethod::Brew => { + // 检测 brew 路径(仅 macOS) + match self.command_executor.execute_async("which brew").await { + result if result.success => { + let path = result.stdout.trim(); + if !path.is_empty() { + Some(path.to_string()) + } else { + None + } + } + _ => None, + } + } + _ => None, + } + } else { + None + }; + + tracing::debug!( + "工具 {} 检测结果: installed={}, version={:?}, path={:?}, method={:?}, installer={:?}", + tool_name, + installed, + version, + install_path, + install_method, + installer_path + ); + + // 创建 ToolInstance(需要获取 Tool 的完整信息) + let tool = Tool::by_id(tool_id).unwrap_or_else(|| { + tracing::warn!("未找到工具定义: {}, 使用静态方法", tool_id); + match tool_id { + "claude-code" => Tool::claude_code(), + "codex" => Tool::codex(), + "gemini-cli" => Tool::gemini_cli(), + _ => { + // 返回一个默认 Tool,避免 panic + tracing::error!("未知工具ID: {}", tool_id); + Tool::claude_code() // 默认返回 claude-code + } + } + }); + + let now = chrono::Utc::now().timestamp(); + let instance_id = format!("{}-local-{}", tool_id, now); + + ToolInstance { + instance_id, + base_id: tool.id.clone(), + tool_name: tool.name.clone(), + tool_type: ToolType::Local, + install_method, + installed, + version, + install_path, + installer_path, // 使用检测到的安装器路径 + wsl_distro: None, + ssh_config: None, + is_builtin: true, + created_at: now, + updated_at: now, + } + } + + /// 检测单个本地工具并持久化(公开方法) + /// + /// 工作流程: + /// 1. 删除该工具的所有现有本地实例(避免重复) + /// 2. 执行检测 + /// 3. 检查路径是否与其他工具冲突 + /// 4. 如果检测到且无冲突,保存到数据库 + /// + /// 返回:工具实例 + pub async fn detect_and_persist_single_tool(&self, tool_id: &str) -> Result { + let detector = self + .detector_registry + .get(tool_id) + .ok_or_else(|| anyhow::anyhow!("未找到工具 {} 的检测器", tool_id))?; + + tracing::info!("开始检测单个工具: {}", tool_id); + + // 1. 删除该工具的所有本地实例(避免重复) + let db = self.db.read().await; + let all_instances = db.get_all_instances()?; + for inst in &all_instances { + if inst.base_id == tool_id && inst.tool_type == ToolType::Local { + tracing::info!("删除旧实例: {}", inst.instance_id); + let _ = db.delete_instance(&inst.instance_id); + } + } + drop(db); + + // 2. 执行检测 + let instance = self.detect_single_tool_by_detector(detector).await; + + // 3. 检查路径冲突(如果检测到路径) + if instance.installed { + if let Some(detected_path) = &instance.install_path { + let db = self.db.read().await; + let all_instances = db.get_all_instances()?; + drop(db); + + // 检查是否有其他工具使用了相同路径 + if let Some(existing) = all_instances.iter().find(|inst| { + inst.install_path.as_ref() == Some(detected_path) + && inst.tool_type == ToolType::Local + && inst.base_id != tool_id // 排除同一工具 + }) { + return Err(anyhow::anyhow!( + "路径冲突:检测到的路径 {} 已被 {} 使用", + detected_path, + existing.tool_name + )); + } + } + } + + // 4. 保存到数据库 + let db = self.db.read().await; + if instance.installed { + db.upsert_instance(&instance)?; + tracing::info!("工具 {} 检测并保存成功", instance.tool_name); + } else { + tracing::info!("工具 {} 未检测到", instance.tool_name); + } + drop(db); + + Ok(instance) + } + + /// 刷新本地工具状态(重新检测,更新存在的,删除不存在的) + pub async fn refresh_local_tools(&self) -> Result> { + tracing::info!("刷新本地工具状态(重新检测)"); + + let detectors = self.detector_registry.all_detectors(); + + // 并行检测所有工具 + let futures: Vec<_> = detectors + .iter() + .map(|detector| self.detect_single_tool_by_detector(detector.clone())) + .collect(); + + let results = futures_util::future::join_all(futures).await; + + // 获取数据库中现有的本地工具实例 + let db = self.db.read().await; + let existing_local = db.get_local_instances().unwrap_or_default(); + + // 收集检测到的工具 ID + let detected_ids: std::collections::HashSet = results + .iter() + .filter(|r| r.installed) + .map(|r| r.instance_id.clone()) + .collect(); + + // 删除数据库中存在但本地已不存在的工具 + for existing in &existing_local { + if !detected_ids.contains(&existing.instance_id) { + tracing::info!("工具 {} 已不存在,从数据库删除", existing.tool_name); + if let Err(e) = db.delete_instance(&existing.instance_id) { + tracing::warn!("删除工具实例失败: {}", e); + } + } + } + + // 更新或插入检测到的工具 + let mut instances = Vec::new(); + for instance in results { + if instance.installed { + tracing::info!( + "工具 {} 检测完成: installed={}, version={:?}", + instance.tool_name, + instance.installed, + instance.version + ); + if let Err(e) = db.upsert_instance(&instance) { + tracing::warn!("保存工具实例失败: {}", e); + } + instances.push(instance); + } + } + drop(db); + + tracing::info!("本地工具刷新完成,共 {} 个已安装工具", instances.len()); + Ok(instances) + } + + /// 检测单个工具并保存到数据库(带缓存优化) + /// + /// # 参数 + /// - tool_id: 工具ID + /// - force_redetect: 是否强制重新检测 + /// + /// # 返回 + /// - Ok(ToolStatus): 工具状态 + /// - Err: 检测失败 + pub async fn detect_single_tool_with_cache( + &self, + tool_id: &str, + force_redetect: bool, + ) -> Result { + use crate::models::ToolType; + + if !force_redetect { + // 1. 先查询数据库中是否已有该工具的本地实例 + let db = self.db.read().await; + let all_instances = db.get_all_instances()?; + drop(db); + + // 查找该工具的本地实例 + if let Some(existing) = all_instances.iter().find(|inst| { + inst.base_id == tool_id && inst.tool_type == ToolType::Local && inst.installed + }) { + // 如果已有实例且已安装,直接返回 + tracing::info!("工具 {} 已在数据库中,直接返回", existing.tool_name); + return Ok(crate::models::ToolStatus { + id: tool_id.to_string(), + name: existing.tool_name.clone(), + installed: true, + version: existing.version.clone(), + }); + } + } + + // 2. 执行单工具检测(会删除旧实例避免重复) + let instance = self.detect_and_persist_single_tool(tool_id).await?; + + // 3. 返回 ToolStatus 格式 + Ok(crate::models::ToolStatus { + id: tool_id.to_string(), + name: instance.tool_name.clone(), + installed: instance.installed, + version: instance.version.clone(), + }) + } +} diff --git a/src-tauri/src/services/tool/registry/instance.rs b/src-tauri/src/services/tool/registry/instance.rs new file mode 100644 index 0000000..f9dc4b4 --- /dev/null +++ b/src-tauri/src/services/tool/registry/instance.rs @@ -0,0 +1,225 @@ +//! 工具实例管理模块 +//! +//! 负责工具实例的添加、删除操作(Local/WSL/SSH) + +use super::ToolRegistry; +use crate::models::{InstallMethod, SSHConfig, Tool, ToolInstance, ToolType}; +use crate::utils::WSLExecutor; +use anyhow::Result; + +impl ToolRegistry { + /// 添加WSL工具实例 + pub async fn add_wsl_instance(&self, base_id: &str, distro_name: &str) -> Result { + // 检查WSL是否可用 + if !WSLExecutor::is_available() { + return Err(anyhow::anyhow!("WSL 不可用,请确保已安装 WSL")); + } + + // 获取工具定义 + let tool = + Tool::by_id(base_id).ok_or_else(|| anyhow::anyhow!("未知的工具ID: {}", base_id))?; + + // 提取命令名称 + let cmd_name = tool + .check_command + .split_whitespace() + .next() + .ok_or_else(|| anyhow::anyhow!("无效的检查命令"))?; + + // 在指定WSL发行版中检测工具 + let (installed, version, install_path) = self + .wsl_executor + .detect_tool_in_distro(Some(distro_name), cmd_name) + .await?; + + // 创建实例 + let instance = ToolInstance::create_wsl_instance( + base_id.to_string(), + tool.name.clone(), + distro_name.to_string(), + installed, + version, + install_path, + ); + + // 保存到数据库 + let mut db = self.db.write().await; + db.add_instance(&instance)?; + drop(db); + + Ok(instance) + } + + /// 添加SSH工具实例(本期仅存储配置,不实现检测) + pub async fn add_ssh_instance( + &self, + base_id: &str, + ssh_config: SSHConfig, + ) -> Result { + // 获取工具定义 + let tool = + Tool::by_id(base_id).ok_or_else(|| anyhow::anyhow!("未知的工具ID: {}", base_id))?; + + // 创建SSH实例(本期不检测,installed设为false) + let instance = ToolInstance::create_ssh_instance( + base_id.to_string(), + tool.name.clone(), + ssh_config, + false, // 本期不检测 + None, + None, + ); + + // 检查是否已存在 + let mut db = self.db.write().await; + if db.instance_exists(&instance.instance_id)? { + return Err(anyhow::anyhow!("该SSH实例已存在")); + } + db.add_instance(&instance)?; + drop(db); + + Ok(instance) + } + + /// 删除工具实例(仅限SSH类型) + pub async fn delete_instance(&self, instance_id: &str) -> Result<()> { + let mut db = self.db.write().await; + + // 获取实例 + let instance = db + .get_instance(instance_id)? + .ok_or_else(|| anyhow::anyhow!("实例不存在: {}", instance_id))?; + + // 检查是否为SSH类型 + if instance.tool_type != ToolType::SSH { + return Err(anyhow::anyhow!("仅允许删除SSH类型的实例")); + } + + // 检查是否为内置实例 + if instance.is_builtin { + return Err(anyhow::anyhow!("不允许删除内置实例")); + } + + // 删除 + db.delete_instance(instance_id)?; + drop(db); + + Ok(()) + } + + /// 添加手动配置的工具实例 + /// + /// # 参数 + /// - tool_id: 工具ID + /// - path: 工具路径 + /// - install_method: 安装方法 + /// - installer_path: 安装器路径(非 Other 类型时必需) + /// + /// # 返回 + /// - Ok(ToolStatus): 工具状态 + /// - Err: 添加失败 + pub async fn add_tool_instance( + &self, + tool_id: &str, + path: &str, + install_method: InstallMethod, + installer_path: Option, + ) -> Result { + use std::path::PathBuf; + + // 1. 验证工具路径 + let version = self.validate_tool_path(path).await?; + + // 2. 验证安装器路径(非 Other 类型时需要) + if install_method != InstallMethod::Other { + if let Some(ref installer) = installer_path { + let installer_buf = PathBuf::from(installer); + if !installer_buf.exists() { + anyhow::bail!("安装器路径不存在: {}", installer); + } + if !installer_buf.is_file() { + anyhow::bail!("安装器路径不是文件: {}", installer); + } + } else { + anyhow::bail!("非「其他」类型必须提供安装器路径"); + } + } + + // 3. 检查路径是否已存在 + let mut db = self.db.write().await; + let all_instances = db.get_all_instances()?; + + // 路径冲突检查 + if let Some(existing) = all_instances.iter().find(|inst| { + inst.install_path.as_ref() == Some(&path.to_string()) + && inst.tool_type == ToolType::Local + }) { + anyhow::bail!( + "路径冲突:该路径已被 {} 使用,无法重复添加", + existing.tool_name + ); + } + + // 4. 获取工具显示名称 + let tool_name = match tool_id { + "claude-code" => "Claude Code", + "codex" => "CodeX", + "gemini-cli" => "Gemini CLI", + _ => tool_id, + }; + + // 5. 创建 ToolInstance(使用时间戳确保唯一性) + let now = chrono::Utc::now().timestamp(); + let instance_id = format!("{}-local-{}", tool_id, now); + let instance = ToolInstance { + instance_id: instance_id.clone(), + base_id: tool_id.to_string(), + tool_name: tool_name.to_string(), + tool_type: ToolType::Local, + install_method: Some(install_method), + installed: true, + version: Some(version.clone()), + install_path: Some(path.to_string()), + installer_path, + wsl_distro: None, + ssh_config: None, + is_builtin: false, + created_at: now, + updated_at: now, + }; + + // 6. 保存到数据库 + db.add_instance(&instance)?; + + // 7. 返回 ToolStatus 格式 + Ok(crate::models::ToolStatus { + id: tool_id.to_string(), + name: tool_name.to_string(), + installed: true, + version: Some(version), + }) + } +} + +#[cfg(test)] +mod tests { + #[test] + fn test_tool_name_mapping() { + // 这个测试验证 add_tool_instance 中的工具名称映射逻辑 + let test_cases = vec![ + ("claude-code", "Claude Code"), + ("codex", "CodeX"), + ("gemini-cli", "Gemini CLI"), + ]; + + for (tool_id, expected_name) in test_cases { + let tool_name = match tool_id { + "claude-code" => "Claude Code", + "codex" => "CodeX", + "gemini-cli" => "Gemini CLI", + _ => tool_id, + }; + assert_eq!(tool_name, expected_name, "工具名称映射应该正确"); + } + } +} diff --git a/src-tauri/src/services/tool/registry/mod.rs b/src-tauri/src/services/tool/registry/mod.rs new file mode 100644 index 0000000..e5b373f --- /dev/null +++ b/src-tauri/src/services/tool/registry/mod.rs @@ -0,0 +1,56 @@ +//! Tool Registry Module +//! +//! 工具注册表模块,按职责拆分为多个子模块 + +mod detection; +mod instance; +mod query; +mod version_ops; + +use crate::services::tool::{DetectorRegistry, ToolInstanceDB}; +use crate::utils::{CommandExecutor, WSLExecutor}; +use anyhow::Result; +use std::sync::Arc; +use tokio::sync::RwLock; // 改用 RwLock + +/// 工具检测进度(用于前端显示) +#[derive(Debug, Clone, serde::Serialize)] +pub struct ToolDetectionProgress { + pub tool_id: String, + pub tool_name: String, + pub status: String, // "pending", "detecting", "done" + pub installed: Option, + pub version: Option, +} + +/// 工具注册表 - 统一管理所有工具实例 +pub struct ToolRegistry { + pub(super) db: Arc>, // 改用 RwLock + pub(super) detector_registry: DetectorRegistry, + pub(super) command_executor: CommandExecutor, + pub(super) wsl_executor: WSLExecutor, +} + +impl ToolRegistry { + /// 创建新的工具注册表 + pub async fn new() -> Result { + let db = ToolInstanceDB::new()?; + + // 初始化配置文件(如果不存在) + // 注意:迁移逻辑已移到 MigrationManager,这里仅初始化 + db.init_tables()?; + + Ok(Self { + db: Arc::new(RwLock::new(db)), // 改用 RwLock + detector_registry: DetectorRegistry::new(), + command_executor: CommandExecutor::new(), + wsl_executor: WSLExecutor::new(), + }) + } + + /// 检查数据库中是否已有本地工具数据 + pub async fn has_local_tools_in_db(&self) -> Result { + let db = self.db.read().await; // 读锁 + db.has_local_tools() + } +} diff --git a/src-tauri/src/services/tool/registry/query.rs b/src-tauri/src/services/tool/registry/query.rs new file mode 100644 index 0000000..2a4f1a2 --- /dev/null +++ b/src-tauri/src/services/tool/registry/query.rs @@ -0,0 +1,283 @@ +//! 查询与辅助工具模块 +//! +//! 负责工具状态查询、扫描、验证等辅助操作 + +use super::ToolRegistry; +use crate::models::{ToolInstance, ToolType}; +use crate::utils::{ + parse_version_string, scan_installer_paths, scan_tool_executables, ToolCandidate, +}; +use anyhow::Result; +use std::collections::HashMap; + +impl ToolRegistry { + /// 获取所有工具实例(按工具ID分组)- 只从数据库读取 + pub async fn get_all_grouped(&self) -> Result>> { + tracing::debug!("开始从数据库获取所有工具实例"); + let mut grouped: HashMap> = HashMap::new(); + + // 从数据库读取所有实例 + let db = self.db.read().await; + let db_instances = match db.get_all_instances() { + Ok(instances) => { + tracing::debug!("从数据库读取到 {} 个实例", instances.len()); + instances + } + Err(e) => { + tracing::warn!("从数据库读取实例失败: {}, 使用空列表", e); + Vec::new() + } + }; + drop(db); + + for instance in db_instances { + grouped + .entry(instance.base_id.clone()) + .or_default() + .push(instance); + } + + // 确保所有工具都有条目(即使没有实例) + for tool_id in &["claude-code", "codex", "gemini-cli"] { + grouped.entry(tool_id.to_string()).or_default(); + } + + tracing::debug!("完成获取所有工具实例,共 {} 个工具", grouped.len()); + Ok(grouped) + } + + /// 刷新所有工具实例(重新检测本地工具并更新数据库) + pub async fn refresh_all(&self) -> Result>> { + // 重新检测本地工具并保存 + self.detect_and_persist_local_tools().await?; + + // 返回所有工具实例 + self.get_all_grouped().await + } + + /// 获取本地工具的轻量级状态(供 Dashboard 使用) + /// 优先从数据库读取,如果数据库为空则执行检测并持久化 + pub async fn get_local_tool_status(&self) -> Result> { + tracing::debug!("获取本地工具轻量级状态"); + + // 从数据库读取所有实例(不主动检测) + let grouped = self.get_all_grouped().await?; + + // 转换为轻量级 ToolStatus + let mut statuses = Vec::new(); + let detectors = self.detector_registry.all_detectors(); + + for detector in detectors { + let tool_id = detector.tool_id(); + let tool_name = detector.tool_name(); + + if let Some(instances) = grouped.get(tool_id) { + // 找到 Local 类型的实例 + if let Some(local_instance) = + instances.iter().find(|i| i.tool_type == ToolType::Local) + { + statuses.push(crate::models::ToolStatus { + id: tool_id.to_string(), + name: tool_name.to_string(), + installed: local_instance.installed, + version: local_instance.version.clone(), + }); + } else { + // 没有本地实例,返回未安装状态 + statuses.push(crate::models::ToolStatus { + id: tool_id.to_string(), + name: tool_name.to_string(), + installed: false, + version: None, + }); + } + } else { + // 数据库中没有该工具的任何实例 + statuses.push(crate::models::ToolStatus { + id: tool_id.to_string(), + name: tool_name.to_string(), + installed: false, + version: None, + }); + } + } + + tracing::debug!("获取本地工具状态完成,共 {} 个工具", statuses.len()); + Ok(statuses) + } + + /// 刷新本地工具状态并返回轻量级视图(供刷新按钮使用) + /// 重新检测 → 更新数据库 → 返回 ToolStatus + pub async fn refresh_and_get_local_status(&self) -> Result> { + tracing::info!("刷新本地工具状态(重新检测)"); + + // 重新检测本地工具 + let instances = self.refresh_local_tools().await?; + + // 转换为轻量级状态 + let mut statuses = Vec::new(); + let detectors = self.detector_registry.all_detectors(); + + for detector in detectors { + let tool_id = detector.tool_id(); + let tool_name = detector.tool_name(); + + if let Some(instance) = instances.iter().find(|i| i.base_id == tool_id) { + statuses.push(crate::models::ToolStatus { + id: tool_id.to_string(), + name: tool_name.to_string(), + installed: instance.installed, + version: instance.version.clone(), + }); + } else { + statuses.push(crate::models::ToolStatus { + id: tool_id.to_string(), + name: tool_name.to_string(), + installed: false, + version: None, + }); + } + } + + tracing::info!("刷新完成,共 {} 个已安装工具", instances.len()); + Ok(statuses) + } + + /// 扫描所有工具候选(用于自动扫描) + /// + /// # 参数 + /// - tool_id: 工具ID(如 "claude-code") + /// + /// # 返回 + /// - Ok(Vec): 候选列表 + /// - Err: 扫描失败 + pub async fn scan_tool_candidates(&self, tool_id: &str) -> Result> { + // 1. 扫描所有工具路径 + let tool_paths = scan_tool_executables(tool_id); + let mut candidates = Vec::new(); + + // 2. 对每个工具路径:获取版本和安装器 + for tool_path in tool_paths { + // 获取版本 + let version_cmd = format!("{} --version", tool_path); + let result = self.command_executor.execute_async(&version_cmd).await; + + let version = if result.success { + let raw = result.stdout.trim(); + parse_version_string(raw) + } else { + // 版本获取失败,跳过此候选 + continue; + }; + + // 扫描安装器 + let installer_candidates = scan_installer_paths(&tool_path); + let installer_path = installer_candidates.first().map(|c| c.path.clone()); + let install_method = installer_candidates + .first() + .map(|c| c.installer_type.clone()) + .unwrap_or(crate::models::InstallMethod::Official); + + candidates.push(ToolCandidate { + tool_path: tool_path.clone(), + installer_path, + install_method, + version, + }); + } + + Ok(candidates) + } + + /// 验证用户指定的工具路径是否有效 + /// + /// # 参数 + /// - path: 工具路径 + /// + /// # 返回 + /// - Ok(String): 版本号字符串 + /// - Err: 验证失败 + pub async fn validate_tool_path(&self, path: &str) -> Result { + use std::path::PathBuf; + + let path_buf = PathBuf::from(path); + + // 检查文件是否存在 + if !path_buf.exists() { + anyhow::bail!("路径不存在: {}", path); + } + + // 检查是否是文件 + if !path_buf.is_file() { + anyhow::bail!("路径不是文件: {}", path); + } + + // 执行 --version 命令 + let version_cmd = format!("{} --version", path); + let result = self.command_executor.execute_async(&version_cmd).await; + + if !result.success { + anyhow::bail!("命令执行失败,退出码: {:?}", result.exit_code); + } + + // 解析版本号 + let version_str = result.stdout.trim(); + if version_str.is_empty() { + anyhow::bail!("无法获取版本信息"); + } + + // 简单验证:版本号应该包含数字 + if !version_str.chars().any(|c| c.is_numeric()) { + anyhow::bail!("无效的版本信息: {}", version_str); + } + + Ok(version_str.to_string()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn test_validate_tool_path_with_invalid_path() { + let registry = ToolRegistry::new().await.expect("创建 Registry 失败"); + + // 测试不存在的路径 + let result = registry.validate_tool_path("/nonexistent/path").await; + assert!(result.is_err(), "不存在的路径应该返回错误"); + assert!( + result.unwrap_err().to_string().contains("路径不存在"), + "错误信息应包含'路径不存在'" + ); + } + + #[tokio::test] + async fn test_has_local_tools_in_db() { + let registry = ToolRegistry::new().await.expect("创建 Registry 失败"); + + // 这个测试依赖于实际数据库状态,仅验证方法可调用 + let result = registry.has_local_tools_in_db().await; + assert!(result.is_ok(), "has_local_tools_in_db 应该可以执行"); + } + + #[tokio::test] + async fn test_get_local_tool_status() { + let registry = ToolRegistry::new().await.expect("创建 Registry 失败"); + + // 测试获取本地工具状态 + let result = registry.get_local_tool_status().await; + assert!(result.is_ok(), "get_local_tool_status 应该可以执行"); + + // 验证返回的工具列表包含已知工具 + if let Ok(statuses) = result { + let tool_ids: Vec = statuses.iter().map(|s| s.id.clone()).collect(); + assert!( + tool_ids.contains(&"claude-code".to_string()) + || tool_ids.contains(&"codex".to_string()) + || tool_ids.contains(&"gemini-cli".to_string()), + "应该包含至少一个已知工具" + ); + } + } +} diff --git a/src-tauri/src/services/tool/registry/version_ops.rs b/src-tauri/src/services/tool/registry/version_ops.rs new file mode 100644 index 0000000..b76680a --- /dev/null +++ b/src-tauri/src/services/tool/registry/version_ops.rs @@ -0,0 +1,230 @@ +//! 版本检查与更新模块 +//! +//! 负责工具版本的检查、更新、刷新操作 + +use super::ToolRegistry; +use crate::models::{InstallMethod, Tool, ToolType, UpdateResult}; +use crate::services::{tool::InstallerService, VersionService}; +use crate::utils::parse_version_string; +use anyhow::Result; +use std::collections::HashMap; + +impl ToolRegistry { + /// 更新工具实例(使用配置的安装器) + /// + /// # 参数 + /// - instance_id: 实例ID + /// - force: 是否强制更新 + /// + /// # 返回 + /// - Ok(UpdateResult): 更新结果(包含新版本) + /// - Err: 更新失败 + pub async fn update_instance(&self, instance_id: &str, force: bool) -> Result { + // 1. 从数据库获取实例信息 + let mut db = self.db.write().await; + let all_instances = db.get_all_instances()?; + drop(db); + + let instance = all_instances + .iter() + .find(|inst| inst.instance_id == instance_id && inst.tool_type == ToolType::Local) + .ok_or_else(|| anyhow::anyhow!("未找到实例: {}", instance_id))?; + + // 2. 使用 InstallerService 执行更新 + let installer = InstallerService::new(); + let result = installer + .update_instance_by_installer(instance, force) + .await?; + + // 3. 如果更新成功,更新数据库中的版本号 + if result.success { + if let Some(ref new_version) = result.current_version { + let mut db = self.db.write().await; + let mut updated_instance = instance.clone(); + updated_instance.version = Some(new_version.clone()); + updated_instance.updated_at = chrono::Utc::now().timestamp(); + + if let Err(e) = db.update_instance(&updated_instance) { + tracing::warn!("更新数据库版本失败: {}", e); + } + } + } + + Ok(result) + } + + /// 检查工具实例更新(使用配置的路径) + /// + /// # 参数 + /// - instance_id: 实例ID + /// + /// # 返回 + /// - Ok(UpdateResult): 更新信息(包含当前版本和最新版本) + /// - Err: 检查失败 + pub async fn check_update_for_instance(&self, instance_id: &str) -> Result { + // 1. 从数据库获取实例信息 + let mut db = self.db.write().await; + let all_instances = db.get_all_instances()?; + drop(db); + + let instance = all_instances + .iter() + .find(|inst| inst.instance_id == instance_id && inst.tool_type == ToolType::Local) + .ok_or_else(|| anyhow::anyhow!("未找到实例: {}", instance_id))?; + + // 2. 使用 install_path 执行 --version 获取当前版本 + let current_version = if let Some(path) = &instance.install_path { + let version_cmd = format!("{} --version", path); + tracing::info!("实例 {} 版本检查命令: {:?}", instance_id, version_cmd); + + let result = self.command_executor.execute_async(&version_cmd).await; + + if result.success { + let raw_version = result.stdout.trim(); + Some(parse_version_string(raw_version)) + } else { + anyhow::bail!("版本号获取错误:无法执行命令 {}", version_cmd); + } + } else { + // 没有路径,使用数据库中的版本 + instance.version.clone() + }; + + // 3. 检查远程最新版本 + let tool_id = &instance.base_id; + let version_service = VersionService::new(); + let version_info = version_service + .check_version( + &Tool::by_id(tool_id).ok_or_else(|| anyhow::anyhow!("未知工具: {}", tool_id))?, + ) + .await; + + let update_result = match version_info { + Ok(info) => UpdateResult { + success: true, + message: "检查完成".to_string(), + has_update: info.has_update, + current_version: current_version.clone(), + latest_version: info.latest_version, + mirror_version: info.mirror_version, + mirror_is_stale: Some(info.mirror_is_stale), + tool_id: Some(tool_id.clone()), + }, + Err(e) => UpdateResult { + success: true, + message: format!("无法检查更新: {e}"), + has_update: false, + current_version: current_version.clone(), + latest_version: None, + mirror_version: None, + mirror_is_stale: None, + tool_id: Some(tool_id.clone()), + }, + }; + + // 4. 如果当前版本有变化,更新数据库 + if current_version != instance.version { + let mut db = self.db.write().await; + let mut updated_instance = instance.clone(); + updated_instance.version = current_version.clone(); + updated_instance.updated_at = chrono::Utc::now().timestamp(); + + if let Err(e) = db.update_instance(&updated_instance) { + tracing::warn!("更新实例 {} 版本失败: {}", instance_id, e); + } else { + tracing::info!( + "实例 {} 版本已同步更新: {:?} -> {:?}", + instance_id, + instance.version, + current_version + ); + } + } + + Ok(update_result) + } + + /// 刷新数据库中所有工具的版本号(使用配置的路径检测) + /// + /// # 返回 + /// - Ok(Vec): 更新后的工具状态列表 + /// - Err: 刷新失败 + pub async fn refresh_all_tool_versions(&self) -> Result> { + let mut db = self.db.write().await; + let all_instances = db.get_all_instances()?; + drop(db); + + let mut statuses = Vec::new(); + + for instance in all_instances + .iter() + .filter(|i| i.tool_type == ToolType::Local) + { + // 使用 install_path 检测版本 + let new_version = if let Some(path) = &instance.install_path { + let version_cmd = format!("{} --version", path); + tracing::info!("工具 {} 版本检查: {:?}", instance.tool_name, version_cmd); + + let result = self.command_executor.execute_async(&version_cmd).await; + + if result.success { + let raw_version = result.stdout.trim(); + Some(parse_version_string(raw_version)) + } else { + // 版本获取失败,保持原版本 + tracing::warn!("工具 {} 版本检测失败,保持原版本", instance.tool_name); + instance.version.clone() + } + } else { + tracing::warn!("工具 {} 缺少安装路径,保持原版本", instance.tool_name); + instance.version.clone() + }; + + tracing::info!("工具 {} 新版本号: {:?}", instance.tool_name, new_version); + + // 如果版本号有变化,更新数据库 + if new_version != instance.version { + let mut db = self.db.write().await; + let mut updated_instance = instance.clone(); + updated_instance.version = new_version.clone(); + updated_instance.updated_at = chrono::Utc::now().timestamp(); + + if let Err(e) = db.update_instance(&updated_instance) { + tracing::warn!("更新实例 {} 失败: {}", instance.instance_id, e); + } else { + tracing::info!( + "工具 {} 版本已更新: {:?} -> {:?}", + instance.tool_name, + instance.version, + new_version + ); + } + } + + // 添加到返回列表 + statuses.push(crate::models::ToolStatus { + id: instance.base_id.clone(), + name: instance.tool_name.clone(), + installed: instance.installed, + version: new_version, + }); + } + + Ok(statuses) + } + + /// 检测工具的安装方式(用于更新时选择正确的方法) + pub async fn detect_install_methods(&self) -> Result> { + let mut methods = HashMap::new(); + + let detectors = self.detector_registry.all_detectors(); + for detector in detectors { + let tool_id = detector.tool_id(); + if let Some(method) = detector.detect_install_method(&self.command_executor).await { + methods.insert(tool_id.to_string(), method); + } + } + + Ok(methods) + } +} diff --git a/src-tauri/src/setup/initialization.rs b/src-tauri/src/setup/initialization.rs new file mode 100644 index 0000000..adf7696 --- /dev/null +++ b/src-tauri/src/setup/initialization.rs @@ -0,0 +1,156 @@ +use duckcoding::core::init_logger; +use duckcoding::services::profile_manager::ProfileManager; +use duckcoding::services::proxy_config_manager::ProxyConfigManager; +use duckcoding::utils::config::read_global_config; +use duckcoding::{ProxyManager, ToolRegistry}; +use std::sync::Arc; +use tokio::sync::Mutex as TokioMutex; + +/// 启动初始化上下文 +/// +/// 包含应用启动所需的核心服务实例 +pub struct InitializationContext { + pub proxy_manager: Arc, + pub tool_registry: Arc>, +} + +/// 初始化日志系统 +/// +/// 从全局配置读取日志配置,失败则使用默认配置 +fn init_logging() -> Result<(), Box> { + let log_config = read_global_config() + .ok() + .flatten() + .map(|cfg| cfg.log_config) + .unwrap_or_default(); + + if let Err(e) = init_logger(&log_config) { + // 日志系统初始化失败时使用 eprintln!(因为 tracing 还不可用) + eprintln!("WARNING: Failed to initialize logging system: {}", e); + // 继续运行,但日志功能将不可用 + } + + tracing::info!("DuckCoding 应用启动"); + Ok(()) +} + +/// 初始化内置 Profile(用于透明代理配置切换) +/// +/// 为每个启用且配置完整的代理工具创建内置 Profile +fn initialize_proxy_profiles() -> Result<(), Box> { + let proxy_mgr = ProxyConfigManager::new()?; + let profile_mgr = ProfileManager::new()?; + + for tool_id in &["claude-code", "codex", "gemini-cli"] { + if let Ok(Some(config)) = proxy_mgr.get_config(tool_id) { + // 检查配置完整性并解构 + if let (true, Some(proxy_key), Some(_real_key), Some(_real_url)) = ( + config.enabled, + &config.local_api_key, + &config.real_api_key, + &config.real_base_url, + ) { + let proxy_profile_name = format!("dc_proxy_{}", tool_id.replace("-", "_")); + let proxy_endpoint = format!("http://127.0.0.1:{}", config.port); + + let result = match *tool_id { + "claude-code" => profile_mgr.save_claude_profile_internal( + &proxy_profile_name, + proxy_key.clone(), + proxy_endpoint, + ), + "codex" => profile_mgr.save_codex_profile_internal( + &proxy_profile_name, + proxy_key.clone(), + proxy_endpoint, + Some("responses".to_string()), + ), + "gemini-cli" => profile_mgr.save_gemini_profile_internal( + &proxy_profile_name, + proxy_key.clone(), + proxy_endpoint, + None, // 不设置 model,保留用户原有配置 + ), + _ => continue, + }; + + if let Err(e) = result { + tracing::warn!( + tool_id = tool_id, + error = ?e, + "初始化内置 Profile 失败" + ); + } else { + tracing::debug!( + tool_id = tool_id, + profile = %proxy_profile_name, + "已初始化内置 Profile" + ); + } + } + } + } + + Ok(()) +} + +/// 执行数据迁移(版本驱动) +async fn run_migrations() -> Result<(), Box> { + tracing::info!("执行数据迁移检查"); + let migration_manager = duckcoding::create_migration_manager(); + match migration_manager.run_all().await { + Ok(results) => { + if !results.is_empty() { + tracing::info!("迁移执行完成:{} 个迁移", results.len()); + for result in results { + if result.success { + tracing::info!("✅ {}: {}", result.migration_id, result.message); + } else { + tracing::error!("❌ {}: {}", result.migration_id, result.message); + } + } + } + } + Err(e) => { + tracing::error!("迁移执行失败: {}", e); + return Err(e.into()); + } + } + Ok(()) +} + +/// 自动启动配置的代理 +async fn auto_start_proxies(proxy_manager: &Arc) { + duckcoding::auto_start_proxies(proxy_manager).await; +} + +/// 执行所有启动初始化任务 +/// +/// 按顺序执行:日志 → Profile → 迁移 → 工具注册表 → 代理管理器 +pub async fn initialize_app() -> Result> { + // 1. 初始化日志 + init_logging()?; + + // 2. 初始化内置 Profile + if let Err(e) = initialize_proxy_profiles() { + tracing::warn!(error = ?e, "初始化内置 Profile 失败"); + } + + // 3. 执行数据迁移 + run_migrations().await?; + + // 4. 创建工具注册表 + let tool_registry = ToolRegistry::new().await.expect("无法创建工具注册表"); + + // 5. 创建代理管理器并异步启动自启动代理 + let proxy_manager = Arc::new(ProxyManager::new()); + let proxy_manager_for_auto_start = proxy_manager.clone(); + tauri::async_runtime::spawn(async move { + auto_start_proxies(&proxy_manager_for_auto_start).await; + }); + + Ok(InitializationContext { + proxy_manager, + tool_registry: Arc::new(TokioMutex::new(tool_registry)), + }) +} diff --git a/src-tauri/src/setup/mod.rs b/src-tauri/src/setup/mod.rs new file mode 100644 index 0000000..3ca21da --- /dev/null +++ b/src-tauri/src/setup/mod.rs @@ -0,0 +1,9 @@ +// 托盘菜单和窗口管理 +pub mod tray; + +// 启动初始化逻辑 +pub mod initialization; + +// 重新导出常用函数供 main.rs 使用 +pub use initialization::initialize_app; +pub use tray::focus_main_window; diff --git a/src-tauri/src/setup/tray.rs b/src-tauri/src/setup/tray.rs new file mode 100644 index 0000000..f8b2082 --- /dev/null +++ b/src-tauri/src/setup/tray.rs @@ -0,0 +1,187 @@ +use tauri::{ + menu::{Menu, MenuItem, PredefinedMenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, + AppHandle, Emitter, Manager, Runtime, WebviewWindow, +}; + +/// 创建系统托盘菜单 +pub fn create_tray_menu(app: &AppHandle) -> tauri::Result> { + let show_item = MenuItem::with_id(app, "show", "显示窗口", true, None::<&str>)?; + let check_update_item = MenuItem::with_id(app, "check_update", "检查更新", true, None::<&str>)?; + let quit_item = MenuItem::with_id(app, "quit", "退出", true, None::<&str>)?; + + let menu = Menu::with_items( + app, + &[ + &show_item, + &PredefinedMenuItem::separator(app)?, + &check_update_item, + &PredefinedMenuItem::separator(app)?, + &quit_item, + ], + )?; + + Ok(menu) +} + +/// 聚焦主窗口 +pub fn focus_main_window(app: &AppHandle) { + if let Some(window) = app.get_webview_window("main") { + tracing::info!("聚焦主窗口"); + restore_window_state(&window); + } else { + tracing::warn!("尝试聚焦时未找到主窗口"); + } +} + +/// 恢复窗口状态(跨平台支持) +pub fn restore_window_state(window: &WebviewWindow) { + tracing::debug!( + is_visible = ?window.is_visible(), + is_minimized = ?window.is_minimized(), + "恢复窗口状态" + ); + + #[cfg(target_os = "macos")] + #[allow(deprecated)] + { + use cocoa::appkit::NSApplication; + use cocoa::base::nil; + use cocoa::foundation::NSAutoreleasePool; + + unsafe { + let _pool = NSAutoreleasePool::new(nil); + let app_macos = NSApplication::sharedApplication(nil); + app_macos.setActivationPolicy_( + cocoa::appkit::NSApplicationActivationPolicy::NSApplicationActivationPolicyRegular, + ); + } + tracing::debug!("macOS Dock 图标已恢复"); + } + + if let Err(e) = window.show() { + tracing::error!(error = ?e, "显示窗口失败"); + } + if let Err(e) = window.unminimize() { + tracing::error!(error = ?e, "取消最小化窗口失败"); + } + if let Err(e) = window.set_focus() { + tracing::error!(error = ?e, "设置窗口焦点失败"); + } + + #[cfg(target_os = "macos")] + #[allow(deprecated)] + { + use cocoa::appkit::NSApplication; + use cocoa::base::nil; + use objc::runtime::YES; + + unsafe { + let ns_app = NSApplication::sharedApplication(nil); + ns_app.activateIgnoringOtherApps_(YES); + } + tracing::debug!("macOS 应用已激活"); + } +} + +/// 隐藏窗口到系统托盘 +pub fn hide_window_to_tray(window: &WebviewWindow) { + tracing::info!("隐藏窗口到系统托盘"); + if let Err(e) = window.hide() { + tracing::error!(error = ?e, "隐藏窗口失败"); + } + + #[cfg(target_os = "macos")] + #[allow(deprecated)] + { + use cocoa::appkit::NSApplication; + use cocoa::base::nil; + use cocoa::foundation::NSAutoreleasePool; + + unsafe { + let _pool = NSAutoreleasePool::new(nil); + let app_macos = NSApplication::sharedApplication(nil); + app_macos.setActivationPolicy_( + cocoa::appkit::NSApplicationActivationPolicy::NSApplicationActivationPolicyAccessory, + ); + } + tracing::debug!("macOS Dock 图标已隐藏"); + } +} + +/// 设置系统托盘(包含事件处理) +pub fn setup_system_tray(app: &tauri::App) -> tauri::Result<()> { + let tray_menu = create_tray_menu(app.handle())?; + let app_handle2 = app.handle().clone(); + + let _tray = TrayIconBuilder::new() + .icon(app.default_window_icon().unwrap().clone()) + .menu(&tray_menu) + .show_menu_on_left_click(false) + .on_menu_event(move |app, event| { + tracing::debug!(event_id = ?event.id, "托盘菜单事件"); + match event.id.as_ref() { + "show" => { + tracing::info!("从托盘显示窗口"); + focus_main_window(app); + } + "check_update" => { + tracing::info!("从托盘请求检查更新"); + // 发送检查更新事件到前端 + if let Err(e) = app.emit("request-check-update", ()) { + tracing::error!(error = ?e, "发送更新检查事件失败"); + } + } + "quit" => { + tracing::info!("从托盘退出应用"); + app.exit(0); + } + _ => {} + } + }) + .on_tray_icon_event(move |_tray, event| { + tracing::trace!(event = ?event, "托盘图标事件"); + match event { + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + .. + } => { + tracing::info!("托盘图标左键点击"); + focus_main_window(&app_handle2); + } + _ => { + // 不打印太多日志 + } + } + }) + .build(app)?; + + Ok(()) +} + +const CLOSE_CONFIRM_EVENT: &str = "duckcoding://request-close-action"; + +/// 设置窗口关闭处理(最小化到托盘而不是退出) +pub fn setup_window_close_handler(app: &tauri::App) -> tauri::Result<()> { + if let Some(window) = app.get_webview_window("main") { + let window_clone = window.clone(); + + window.on_window_event(move |event| { + if let tauri::WindowEvent::CloseRequested { api, .. } = event { + tracing::info!("窗口关闭请求 - 提示用户选择操作"); + // 阻止默认关闭行为 + api.prevent_close(); + if let Err(err) = window_clone.emit(CLOSE_CONFIRM_EVENT, ()) { + tracing::error!( + error = ?err, + "发送关闭确认事件失败,降级为隐藏窗口" + ); + hide_window_to_tray(&window_clone); + } + } + }); + } + + Ok(()) +} diff --git a/src-tauri/src/utils/config.rs b/src-tauri/src/utils/config.rs index cd78b90..5def25b 100644 --- a/src-tauri/src/utils/config.rs +++ b/src-tauri/src/utils/config.rs @@ -1,5 +1,4 @@ use crate::data::DataManager; -use crate::services::proxy::ProxyService; use crate::GlobalConfig; use std::fs; use std::path::PathBuf; @@ -51,7 +50,10 @@ pub fn read_global_config() -> Result, String> { Ok(Some(config)) } -/// 写入全局配置,同时设置权限并更新当前进程代理 +/// 写入全局配置 +/// +/// 注意:此函数仅写入配置文件,不会自动应用代理设置。 +/// 如需应用代理,请在写入后调用 `services::proxy::config::apply_global_proxy()` pub fn write_global_config(config: &GlobalConfig) -> Result<(), String> { let config_path = global_config_path()?; @@ -65,17 +67,9 @@ pub fn write_global_config(config: &GlobalConfig) -> Result<(), String> { .write(&config_path, &config_value) .map_err(|e| format!("Failed to write config: {e}"))?; - ProxyService::apply_proxy_from_config(config); Ok(()) } -/// 如配置存在代理设置,则立即应用到环境变量 -pub fn apply_proxy_if_configured() { - if let Ok(Some(config)) = read_global_config() { - ProxyService::apply_proxy_from_config(&config); - } -} - #[cfg(test)] mod tests { use super::*; diff --git a/src/lib/tauri-commands/proxy.ts b/src/lib/tauri-commands/proxy.ts index 3fae7f6..e1c6def 100644 --- a/src/lib/tauri-commands/proxy.ts +++ b/src/lib/tauri-commands/proxy.ts @@ -2,47 +2,7 @@ // 负责透明代理的启动、停止、状态查询和配置管理 import { invoke } from '@tauri-apps/api/core'; -import type { TransparentProxyStatus, AllProxyStatus, ToolProxyConfig, ToolId } from './types'; - -// ==================== 旧版单工具代理 API(兼容性保留)==================== - -/** - * 启动透明代理(旧版,使用 claude-code 工具) - * @deprecated 请使用 startToolProxy - */ -export async function startTransparentProxy(): Promise { - return await invoke('start_transparent_proxy'); -} - -/** - * 停止透明代理(旧版,使用 claude-code 工具) - * @deprecated 请使用 stopToolProxy - */ -export async function stopTransparentProxy(): Promise { - return await invoke('stop_transparent_proxy'); -} - -/** - * 获取透明代理状态(旧版,使用 claude-code 工具) - * @deprecated 请使用 getAllProxyStatus - */ -export async function getTransparentProxyStatus(): Promise { - return await invoke('get_transparent_proxy_status'); -} - -/** - * 更新透明代理配置(旧版,使用 claude-code 工具) - * @deprecated 请使用 updateProxyConfig - */ -export async function updateTransparentProxyConfig( - newApiKey: string, - newBaseUrl: string, -): Promise { - return await invoke('update_transparent_proxy_config', { - newApiKey, - newBaseUrl, - }); -} +import type { AllProxyStatus, ToolProxyConfig, ToolId } from './types'; // ==================== 多工具透明代理 API(新架构)==================== diff --git a/src/lib/tauri-commands/types.ts b/src/lib/tauri-commands/types.ts index 1009d14..2f30212 100644 --- a/src/lib/tauri-commands/types.ts +++ b/src/lib/tauri-commands/types.ts @@ -54,14 +54,6 @@ export interface GlobalConfig { proxy_username?: string; proxy_password?: string; proxy_bypass_urls?: string[]; // 代理过滤URL列表 - // 透明代理功能 (实验性) - transparent_proxy_enabled?: boolean; - transparent_proxy_port?: number; - transparent_proxy_api_key?: string; - transparent_proxy_allow_public?: boolean; - // 保存真实的 API 配置 - transparent_proxy_real_api_key?: string; - transparent_proxy_real_base_url?: string; // 多工具透明代理配置(新架构) proxy_configs?: Record; // 会话级端点配置开关(默认关闭) diff --git a/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts b/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts index 14519b6..7a81cdd 100644 --- a/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts +++ b/src/pages/ProfileManagementPage/hooks/useProfileManagement.ts @@ -238,12 +238,14 @@ function buildProfilePayload(toolId: ToolId, data: ProfileFormData): ProfilePayl switch (toolId) { case 'claude-code': return { + type: 'claude-code', api_key: data.api_key, base_url: data.base_url, }; case 'codex': return { + type: 'codex', api_key: data.api_key, base_url: data.base_url, wire_api: data.wire_api || 'responses', // 确保有 wire_api @@ -251,6 +253,7 @@ function buildProfilePayload(toolId: ToolId, data: ProfileFormData): ProfilePayl case 'gemini-cli': return { + type: 'gemini-cli', api_key: data.api_key, base_url: data.base_url, model: data.model && data.model !== '' ? data.model : undefined, // 空值不设置 model diff --git a/src/pages/SettingsPage/components/InstallPackageSelector.tsx b/src/pages/SettingsPage/components/InstallPackageSelector.tsx index 7947f48..9bc571e 100644 --- a/src/pages/SettingsPage/components/InstallPackageSelector.tsx +++ b/src/pages/SettingsPage/components/InstallPackageSelector.tsx @@ -20,12 +20,21 @@ interface PackageOption { recommended?: boolean; } +import type { UpdateInfo } from '@/lib/tauri-commands/types'; + +interface PlatformInfo { + is_windows: boolean; + is_macos: boolean; + is_linux: boolean; + arch: string; +} + interface InstallPackageSelectorProps { isOpen: boolean; onClose: () => void; - updateInfo: any; + updateInfo: UpdateInfo; onDownloadSelected: (url: string) => void; - platformInfo: any; + platformInfo: PlatformInfo; } export function InstallPackageSelector({ diff --git a/src/pages/SettingsPage/components/UpdateTab.tsx b/src/pages/SettingsPage/components/UpdateTab.tsx index 05d1c7c..399b6af 100644 --- a/src/pages/SettingsPage/components/UpdateTab.tsx +++ b/src/pages/SettingsPage/components/UpdateTab.tsx @@ -445,13 +445,15 @@ export function UpdateTab({ updateInfo: externalUpdateInfo, onUpdateCheck }: Upd {/* 安装包选择器 */} - setShowPackageSelector(false)} - updateInfo={updateInfo} - onDownloadSelected={downloadAndInstallSpecificPackage} - platformInfo={platformInfo} - /> + {updateInfo && platformInfo && ( + setShowPackageSelector(false)} + updateInfo={updateInfo} + onDownloadSelected={downloadAndInstallSpecificPackage} + platformInfo={platformInfo} + /> + )} ); } diff --git a/src/pages/SettingsPage/hooks/useSettingsForm.ts b/src/pages/SettingsPage/hooks/useSettingsForm.ts index 9552856..f147f2d 100644 --- a/src/pages/SettingsPage/hooks/useSettingsForm.ts +++ b/src/pages/SettingsPage/hooks/useSettingsForm.ts @@ -35,10 +35,6 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo ]); // 实验性功能 - 透明代理 - const [transparentProxyEnabled, setTransparentProxyEnabled] = useState(false); - const [transparentProxyPort, setTransparentProxyPort] = useState(8787); - const [transparentProxyApiKey, setTransparentProxyApiKey] = useState(''); - const [transparentProxyAllowPublic, setTransparentProxyAllowPublic] = useState(false); // 状态 const [globalConfig, setGlobalConfig] = useState(initialConfig); @@ -69,10 +65,6 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo '*.lan', ], ); - setTransparentProxyEnabled(initialConfig.transparent_proxy_enabled || false); - setTransparentProxyPort(initialConfig.transparent_proxy_port || 8787); - setTransparentProxyApiKey(initialConfig.transparent_proxy_api_key || ''); - setTransparentProxyAllowPublic(initialConfig.transparent_proxy_allow_public || false); } }, [initialConfig]); @@ -90,10 +82,6 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo throw new Error('代理地址和端口不能为空'); } - if (transparentProxyEnabled && (!transparentProxyApiKey.trim() || transparentProxyPort <= 0)) { - throw new Error('透明代理 API Key 和端口不能为空'); - } - setSavingSettings(true); try { const configToSave: GlobalConfig = { @@ -106,12 +94,6 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo proxy_username: proxyUsername.trim(), proxy_password: proxyPassword, proxy_bypass_urls: proxyBypassUrls.map((url) => url.trim()).filter((url) => url.length > 0), - transparent_proxy_enabled: transparentProxyEnabled, - transparent_proxy_port: transparentProxyPort, - transparent_proxy_api_key: transparentProxyApiKey.trim(), - transparent_proxy_allow_public: transparentProxyAllowPublic, - transparent_proxy_real_api_key: globalConfig?.transparent_proxy_real_api_key || '', - transparent_proxy_real_base_url: globalConfig?.transparent_proxy_real_base_url || '', }; await saveGlobalConfig(configToSave); @@ -131,22 +113,14 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo proxyUsername, proxyPassword, proxyBypassUrls, - transparentProxyEnabled, - transparentProxyPort, - transparentProxyApiKey, - transparentProxyAllowPublic, globalConfig, onConfigChange, ]); - // 生成代理 API Key + // 生成代理 API Key(已废弃,透明代理功能已移除) const generateProxyKey = useCallback(() => { - const charset = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789'; - let result = 'dc-proxy-'; - for (let i = 0; i < 32; i++) { - result += charset.charAt(Math.floor(Math.random() * charset.length)); - } - setTransparentProxyApiKey(result); + // 功能已移除 + console.warn('generateProxyKey 功能已废弃'); }, []); // 测试代理连接 @@ -254,16 +228,6 @@ export function useSettingsForm({ initialConfig, onConfigChange }: UseSettingsFo proxyBypassUrls, setProxyBypassUrls, - // Transparent proxy settings - transparentProxyEnabled, - setTransparentProxyEnabled, - transparentProxyPort, - setTransparentProxyPort, - transparentProxyApiKey, - setTransparentProxyApiKey, - transparentProxyAllowPublic, - setTransparentProxyAllowPublic, - // State globalConfig, savingSettings, diff --git a/src/pages/SettingsPage/hooks/useTransparentProxy.ts b/src/pages/SettingsPage/hooks/useTransparentProxy.ts deleted file mode 100644 index 36bc2f7..0000000 --- a/src/pages/SettingsPage/hooks/useTransparentProxy.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { useState, useCallback } from 'react'; -import { - startTransparentProxy, - stopTransparentProxy, - getTransparentProxyStatus, - type TransparentProxyStatus, -} from '@/lib/tauri-commands'; - -export function useTransparentProxy() { - const [transparentProxyStatus, setTransparentProxyStatus] = - useState(null); - const [startingProxy, setStartingProxy] = useState(false); - const [stoppingProxy, setStoppingProxy] = useState(false); - - // 加载透明代理状态 - const loadTransparentProxyStatus = useCallback(async () => { - try { - const status = await getTransparentProxyStatus(); - setTransparentProxyStatus(status); - } catch (error) { - console.error('Failed to load transparent proxy status:', error); - throw error; - } - }, []); - - // 启动透明代理 - const handleStartProxy = useCallback(async (): Promise => { - setStartingProxy(true); - try { - const result = await startTransparentProxy(); - await loadTransparentProxyStatus(); - return result; - } finally { - setStartingProxy(false); - } - }, [loadTransparentProxyStatus]); - - // 停止透明代理 - const handleStopProxy = useCallback(async (): Promise => { - setStoppingProxy(true); - try { - const result = await stopTransparentProxy(); - await loadTransparentProxyStatus(); - return result; - } finally { - setStoppingProxy(false); - } - }, [loadTransparentProxyStatus]); - - return { - transparentProxyStatus, - startingProxy, - stoppingProxy, - loadTransparentProxyStatus, - handleStartProxy, - handleStopProxy, - }; -} diff --git a/src/types/profile.ts b/src/types/profile.ts index 8659f78..59e1a8a 100644 --- a/src/types/profile.ts +++ b/src/types/profile.ts @@ -31,9 +31,14 @@ export interface GeminiProfilePayload { } /** - * Profile Payload 联合类型 + * Profile Payload 联合类型(前端传递给后端) + * + * 使用 tagged union 确保类型正确匹配 */ -export type ProfilePayload = ClaudeProfilePayload | CodexProfilePayload | GeminiProfilePayload; +export type ProfilePayload = + | ({ type: 'claude-code' } & ClaudeProfilePayload) + | ({ type: 'codex' } & CodexProfilePayload) + | ({ type: 'gemini-cli' } & GeminiProfilePayload); /** * Profile 完整数据(包含时间戳)