From 8b92982112e48e6d749bd51f6b9f9cbefaa8141f Mon Sep 17 00:00:00 2001 From: Dex Miller Date: Tue, 13 Jan 2026 10:55:53 +0800 Subject: [PATCH] Feature/global proxy (#596) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * refactor(proxy): simplify logging for better readability - Delete 17 verbose debug logs from handlers, streaming, and response_processor - Convert excessive INFO logs to DEBUG level for internal processing details - Add 2 critical INFO logs in forwarder.rs for failover scenarios: - Log when switching to next provider after failure - Log when all providers have been exhausted - Fix clippy uninlined_format_args warning This reduces log noise while maintaining visibility into key user-facing decisions. * fix: replace unsafe unwrap() calls with proper error handling - database/dao/mcp.rs: Use map_err for serde_json serialization - database/dao/providers.rs: Use map_err for settings_config and meta serialization - commands/misc.rs: Use expect() for compile-time regex pattern - services/prompt.rs: Use unwrap_or_default() for SystemTime - deeplink/provider.rs: Replace unwrap() with is_none_or pattern for Option checks Reduces potential panic points from 26 to 1 (static regex init, safe). * refactor(proxy): simplify verbose logging output - Remove response JSON full output logging in response_processor - Remove per-request INFO logs in provider_router (failover status, provider selection) - Change model mapping log from INFO to DEBUG - Change usage logging failure from INFO to WARN - Remove redundant debug logs for circuit breaker operations Reduces log noise significantly while preserving important warnings and errors. * feat(proxy): add structured log codes for i18n support Add error code system to proxy module logs for multi-language support: - CB-001~006: Circuit breaker state transitions and triggers - SRV-001~004: Proxy server lifecycle events - FWD-001~002: Request forwarding and failover - FO-001~005: Failover switch operations - USG-001~002: Usage logging errors Log format: [CODE] Chinese message Frontend/log tools can map codes to any language. New file: src/proxy/log_codes.rs - centralized code definitions * chore: bump version to 3.9.1 * style: format code with prettier and rustfmt * fix(ui): allow number inputs to be fully cleared before saving - Convert numeric state to string type for controlled inputs - Use isNaN() check instead of || fallback to allow 0 values - Apply fix to ProxyPanel, CircuitBreakerConfigPanel, AutoFailoverConfigPanel, and ModelTestConfigPanel * feat(pricing): support @ separator in model name matching - Refactor model name cleaning into chained method calls - Add @ to - replacement (e.g., gpt-5.2-codex@low → gpt-5.2-codex-low) - Add test case for @ separator matching * feat(proxy): add global proxy settings support Add ability to configure a global HTTP/HTTPS proxy for all outbound requests including provider API calls, speed tests, and stream checks. * fix(proxy): improve validation and error handling in proxy config panels - Add StopTimeout/StopFailed error types for proper stop() error reporting - Replace silent clamp with validation-and-block in config panels - Add listenAddress format validation in ProxyPanel - Use log_codes constants instead of hardcoded strings - Use once_cell::Lazy for regex precompilation * fix(proxy): harden error handling and input validation - Handle RwLock poisoning in settings.rs with unwrap_or_else - Add fallback for dirs::home_dir() in config modules - Normalize localhost to 127.0.0.1 in ProxyPanel - Format IPv6 addresses with brackets for valid URLs - Strict port validation with pure digit regex - Treat NaN as validation failure in config panels - Log warning on cost_multiplier parse failure - Align timeoutSeconds range to [0, 300] across all panels * feat(proxy): add local proxy auto-scan and fix hot-reload - Add scan_local_proxies command to detect common proxy ports - Fix SkillService not using updated proxy after hot-reload - Move global proxy settings to advanced tab - Add error handling for scan failures * fix(proxy): allow localhost input in proxy address field * fix(proxy): restore request timeout and fix proxy hot-reload issues - Add URL scheme validation in build_client (http/https/socks5/socks5h) - Restore per-request timeout for speedtest, stream_check, usage_script, forwarder - Fix set_global_proxy_url to validate before persisting to DB - Mask proxy credentials in all log outputs - Fix forwarder hot-reload by fetching client on each request * style: format code with prettier * fix(proxy): improve global proxy stability and error handling - Fix RwLock silent failures with explicit error propagation - Handle init() duplicate calls gracefully with warning log - Align fallback client config with build_client settings - Make scan_local_proxies async to avoid UI blocking - Add mixed mode support for Clash 7890 port (http+socks5) - Use multiple test targets for better proxy connectivity test - Clear invalid proxy config on init failure - Restore timeout constraints in usage_script - Fix mask_url output for URLs without port - Add structured error codes [GP-001 to GP-009] * feat(proxy): add username/password authentication support - Add separate username and password input fields - Implement password visibility toggle with eye icon - Add clear button to reset all proxy fields - Auto-extract auth info from saved URL and merge on save - Update i18n translations (zh/en/ja) * fix(proxy): fix double encoding issue in proxy auth and add debug logs - Remove encodeURIComponent in mergeAuth() since URL object's username/password setters already do percent-encoding automatically - Add GP-010 debug log for database read operations - Add GP-011 debug log to track incoming URL info (length, has_auth) - Fix username.trim() in fallback branch for consistent behavior --- src-tauri/src/commands/global_proxy.rs | 247 ++++++++++++++ src-tauri/src/commands/misc.rs | 7 +- src-tauri/src/commands/mod.rs | 2 + src-tauri/src/database/dao/settings.rs | 35 ++ src-tauri/src/lib.rs | 37 +++ src-tauri/src/proxy/forwarder.rs | 63 +--- src-tauri/src/proxy/http_client.rs | 301 ++++++++++++++++++ src-tauri/src/proxy/mod.rs | 1 + src-tauri/src/services/skill.rs | 16 +- src-tauri/src/services/speedtest.rs | 30 +- src-tauri/src/services/stream_check.rs | 37 ++- src-tauri/src/usage_script.rs | 22 +- src/App.tsx | 4 +- src/components/providers/ProviderCard.tsx | 4 +- src/components/proxy/ProxyPanel.tsx | 14 +- .../settings/GlobalProxySettings.tsx | 275 ++++++++++++++++ src/components/settings/SettingsPage.tsx | 24 ++ src/hooks/useGlobalProxy.ts | 109 +++++++ src/i18n/locales/en.json | 20 +- src/i18n/locales/ja.json | 20 +- src/i18n/locales/zh.json | 20 +- src/lib/api/globalProxy.ts | 85 +++++ 22 files changed, 1251 insertions(+), 122 deletions(-) create mode 100644 src-tauri/src/commands/global_proxy.rs create mode 100644 src-tauri/src/proxy/http_client.rs create mode 100644 src/components/settings/GlobalProxySettings.tsx create mode 100644 src/hooks/useGlobalProxy.ts create mode 100644 src/lib/api/globalProxy.ts diff --git a/src-tauri/src/commands/global_proxy.rs b/src-tauri/src/commands/global_proxy.rs new file mode 100644 index 000000000..dcec13e5c --- /dev/null +++ b/src-tauri/src/commands/global_proxy.rs @@ -0,0 +1,247 @@ +//! 全局出站代理相关命令 +//! +//! 提供获取、设置和测试全局代理的 Tauri 命令。 + +use crate::proxy::http_client; +use crate::store::AppState; +use serde::Serialize; +use std::net::{Ipv4Addr, SocketAddrV4, TcpStream}; +use std::time::{Duration, Instant}; + +/// 获取全局代理 URL +/// +/// 返回当前配置的代理 URL,null 表示直连。 +#[tauri::command] +pub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result, String> { + let result = state.db.get_global_proxy_url().map_err(|e| e.to_string())?; + log::debug!( + "[GlobalProxy] [GP-010] Read from database: {}", + result + .as_ref() + .map(|u| http_client::mask_url(u)) + .unwrap_or_else(|| "None".to_string()) + ); + Ok(result) +} + +/// 设置全局代理 URL +/// +/// - 传入非空字符串:启用代理 +/// - 传入空字符串:清除代理(直连) +/// +/// 执行顺序:先验证 → 写 DB → 再应用 +/// 这样确保 DB 写失败时不会出现运行态与持久化不一致的问题 +#[tauri::command] +pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> { + // 调试:显示接收到的 URL 信息(不包含敏感内容) + let has_auth = url.contains('@') && (url.starts_with("http://") || url.starts_with("socks")); + log::debug!( + "[GlobalProxy] [GP-011] Received URL: length={}, has_auth={}", + url.len(), + has_auth + ); + + let url_opt = if url.trim().is_empty() { + None + } else { + Some(url.as_str()) + }; + + // 1. 先验证代理配置是否有效(不应用) + http_client::validate_proxy(url_opt)?; + + // 2. 验证成功后保存到数据库 + state + .db + .set_global_proxy_url(url_opt) + .map_err(|e| e.to_string())?; + + // 3. DB 写入成功后再应用到运行态 + http_client::apply_proxy(url_opt)?; + + log::info!( + "[GlobalProxy] [GP-009] Configuration updated: {}", + url_opt + .map(http_client::mask_url) + .unwrap_or_else(|| "direct connection".to_string()) + ); + + Ok(()) +} + +/// 代理测试结果 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ProxyTestResult { + /// 是否连接成功 + pub success: bool, + /// 延迟(毫秒) + pub latency_ms: u64, + /// 错误信息 + pub error: Option, +} + +/// 测试代理连接 +/// +/// 通过指定的代理 URL 发送测试请求,返回连接结果和延迟。 +/// 使用多个测试目标,任一成功即认为代理可用。 +#[tauri::command] +pub async fn test_proxy_url(url: String) -> Result { + if url.trim().is_empty() { + return Err("Proxy URL is empty".to_string()); + } + + let start = Instant::now(); + + // 构建带代理的临时客户端 + let proxy = reqwest::Proxy::all(&url).map_err(|e| format!("Invalid proxy URL: {e}"))?; + + let client = reqwest::Client::builder() + .proxy(proxy) + .timeout(std::time::Duration::from_secs(10)) + .connect_timeout(std::time::Duration::from_secs(10)) + .build() + .map_err(|e| format!("Failed to build client: {e}"))?; + + // 使用多个测试目标,提高兼容性 + // 优先使用 httpbin(专门用于 HTTP 测试),回退到其他公共端点 + let test_urls = [ + "https://httpbin.org/get", + "https://www.google.com", + "https://api.anthropic.com", + ]; + + let mut last_error = None; + + for test_url in test_urls { + match client.head(test_url).send().await { + Ok(resp) => { + let latency = start.elapsed().as_millis() as u64; + log::debug!( + "[GlobalProxy] Test successful: {} -> {} via {} ({}ms)", + http_client::mask_url(&url), + test_url, + resp.status(), + latency + ); + return Ok(ProxyTestResult { + success: true, + latency_ms: latency, + error: None, + }); + } + Err(e) => { + log::debug!("[GlobalProxy] Test to {test_url} failed: {e}"); + last_error = Some(e); + } + } + } + + // 所有测试目标都失败 + let latency = start.elapsed().as_millis() as u64; + let error_msg = last_error + .map(|e| e.to_string()) + .unwrap_or_else(|| "All test targets failed".to_string()); + + log::debug!( + "[GlobalProxy] Test failed: {} -> {} ({}ms)", + http_client::mask_url(&url), + error_msg, + latency + ); + + Ok(ProxyTestResult { + success: false, + latency_ms: latency, + error: Some(error_msg), + }) +} + +/// 获取当前出站代理状态 +/// +/// 返回当前是否启用了出站代理以及代理 URL。 +#[tauri::command] +pub fn get_upstream_proxy_status() -> UpstreamProxyStatus { + let url = http_client::get_current_proxy_url(); + UpstreamProxyStatus { + enabled: url.is_some(), + proxy_url: url, + } +} + +/// 出站代理状态信息 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct UpstreamProxyStatus { + /// 是否启用代理 + pub enabled: bool, + /// 代理 URL + pub proxy_url: Option, +} + +/// 检测到的代理信息 +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct DetectedProxy { + /// 代理 URL + pub url: String, + /// 代理类型 (http/socks5) + pub proxy_type: String, + /// 端口 + pub port: u16, +} + +/// 常见代理端口配置 +/// 格式:(端口, 主要类型, 是否同时支持 http 和 socks5) +/// 对于 mixed 端口,会同时返回两种协议供用户选择 +const PROXY_PORTS: &[(u16, &str, bool)] = &[ + (7890, "http", true), // Clash (mixed mode) + (7891, "socks5", false), // Clash SOCKS only + (1080, "socks5", false), // 通用 SOCKS5 + (8080, "http", false), // 通用 HTTP + (8888, "http", false), // Charles/Fiddler + (3128, "http", false), // Squid + (10808, "socks5", false), // V2Ray SOCKS + (10809, "http", false), // V2Ray HTTP +]; + +/// 扫描本地代理 +/// +/// 检测常见端口是否有代理服务在运行。 +/// 使用异步任务避免阻塞 UI 线程。 +#[tauri::command] +pub async fn scan_local_proxies() -> Vec { + // 使用 spawn_blocking 避免阻塞主线程 + tokio::task::spawn_blocking(|| { + let mut found = Vec::new(); + + for &(port, primary_type, is_mixed) in PROXY_PORTS { + let addr = SocketAddrV4::new(Ipv4Addr::LOCALHOST, port); + if TcpStream::connect_timeout(&addr.into(), Duration::from_millis(100)).is_ok() { + // 添加主要类型 + found.push(DetectedProxy { + url: format!("{primary_type}://127.0.0.1:{port}"), + proxy_type: primary_type.to_string(), + port, + }); + // 对于 mixed 端口,同时添加另一种协议 + if is_mixed { + let alt_type = if primary_type == "http" { + "socks5" + } else { + "http" + }; + found.push(DetectedProxy { + url: format!("{alt_type}://127.0.0.1:{port}"), + proxy_type: alt_type.to_string(), + port, + }); + } + } + } + + found + }) + .await + .unwrap_or_default() +} diff --git a/src-tauri/src/commands/misc.rs b/src-tauri/src/commands/misc.rs index 4375921c1..97857dc1a 100644 --- a/src-tauri/src/commands/misc.rs +++ b/src-tauri/src/commands/misc.rs @@ -91,11 +91,8 @@ pub async fn get_tool_versions() -> Result, String> { let tools = vec!["claude", "codex", "gemini"]; let mut results = Vec::new(); - // 用于获取远程版本的 client - let client = reqwest::Client::builder() - .user_agent("cc-switch/1.0") - .build() - .map_err(|e| e.to_string())?; + // 使用全局 HTTP 客户端(已包含代理配置) + let client = crate::proxy::http_client::get(); for tool in tools { // 1. 获取本地版本 - 先尝试直接执行,失败则扫描常见路径 diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 85dfb3adc..09b48b646 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -4,6 +4,7 @@ mod config; mod deeplink; mod env; mod failover; +mod global_proxy; mod import_export; mod mcp; mod misc; @@ -20,6 +21,7 @@ pub use config::*; pub use deeplink::*; pub use env::*; pub use failover::*; +pub use global_proxy::*; pub use import_export::*; pub use mcp::*; pub use misc::*; diff --git a/src-tauri/src/database/dao/settings.rs b/src-tauri/src/database/dao/settings.rs index 65d2328f9..708391651 100644 --- a/src-tauri/src/database/dao/settings.rs +++ b/src-tauri/src/database/dao/settings.rs @@ -63,6 +63,41 @@ impl Database { } } + // --- 全局出站代理 --- + + /// 全局代理 URL 的存储键名 + const GLOBAL_PROXY_URL_KEY: &'static str = "global_proxy_url"; + + /// 获取全局出站代理 URL + /// + /// 返回 None 表示未配置或已清除代理(直连) + /// 返回 Some(url) 表示已配置代理 + pub fn get_global_proxy_url(&self) -> Result, AppError> { + self.get_setting(Self::GLOBAL_PROXY_URL_KEY) + } + + /// 设置全局出站代理 URL + /// + /// - 传入非空字符串:启用代理 + /// - 传入空字符串或 None:清除代理设置(直连) + pub fn set_global_proxy_url(&self, url: Option<&str>) -> Result<(), AppError> { + match url { + Some(u) if !u.trim().is_empty() => { + self.set_setting(Self::GLOBAL_PROXY_URL_KEY, u.trim()) + } + _ => { + // 清除代理设置 + let conn = lock_conn!(self.conn); + conn.execute( + "DELETE FROM settings WHERE key = ?1", + params![Self::GLOBAL_PROXY_URL_KEY], + ) + .map_err(|e| AppError::Database(e.to_string()))?; + Ok(()) + } + } + } + // --- 代理接管状态管理(已废弃,使用 proxy_config.enabled 替代)--- /// 获取指定应用的代理接管状态 diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9fb6fc706..37e75b0e9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -644,6 +644,37 @@ pub fn run() { let skill_service = SkillService::new(); app.manage(commands::skill::SkillServiceState(Arc::new(skill_service))); + // 初始化全局出站代理 HTTP 客户端 + { + let db = &app.state::().db; + let proxy_url = db.get_global_proxy_url().ok().flatten(); + + if let Err(e) = crate::proxy::http_client::init(proxy_url.as_deref()) { + log::error!( + "[GlobalProxy] [GP-005] Failed to initialize with saved config: {e}" + ); + + // 清除无效的代理配置 + if proxy_url.is_some() { + log::warn!( + "[GlobalProxy] [GP-006] Clearing invalid proxy config from database" + ); + if let Err(clear_err) = db.set_global_proxy_url(None) { + log::error!( + "[GlobalProxy] [GP-007] Failed to clear invalid config: {clear_err}" + ); + } + } + + // 使用直连模式重新初始化 + if let Err(fallback_err) = crate::proxy::http_client::init(None) { + log::error!( + "[GlobalProxy] [GP-008] Failed to initialize direct connection: {fallback_err}" + ); + } + } + } + // 异常退出恢复 + 代理状态自动恢复 let app_handle = app.handle().clone(); tauri::async_runtime::spawn(async move { @@ -841,6 +872,12 @@ pub fn run() { commands::upsert_universal_provider, commands::delete_universal_provider, commands::sync_universal_provider, + // Global upstream proxy + commands::get_global_proxy_url, + commands::set_global_proxy_url, + commands::test_proxy_url, + commands::get_upstream_proxy_status, + commands::scan_local_proxies, ]); let app = builder diff --git a/src-tauri/src/proxy/forwarder.rs b/src-tauri/src/proxy/forwarder.rs index b3d47fcf3..2ad82d9b7 100644 --- a/src-tauri/src/proxy/forwarder.rs +++ b/src-tauri/src/proxy/forwarder.rs @@ -12,10 +12,9 @@ use super::{ ProxyError, }; use crate::{app_config::AppType, provider::Provider}; -use reqwest::{Client, Response}; +use reqwest::Response; use serde_json::Value; use std::sync::Arc; -use std::time::Duration; use tokio::sync::RwLock; /// Headers 黑名单 - 不透传到上游的 Headers @@ -81,8 +80,6 @@ pub struct ForwardError { } pub struct RequestForwarder { - client: Option, - client_init_error: Option, /// 共享的 ProviderRouter(持有熔断器状态) router: Arc, status: Arc>, @@ -93,6 +90,8 @@ pub struct RequestForwarder { app_handle: Option, /// 请求开始时的"当前供应商 ID"(用于判断是否需要同步 UI/托盘) current_provider_id_at_start: String, + /// 非流式请求超时(秒) + non_streaming_timeout: std::time::Duration, } impl RequestForwarder { @@ -108,51 +107,14 @@ impl RequestForwarder { _streaming_first_byte_timeout: u64, _streaming_idle_timeout: u64, ) -> Self { - // 全局超时设置为 1800 秒(30 分钟),确保业务层超时配置能正常工作 - // 参考 Claude Code Hub 的 undici 全局超时设计 - const GLOBAL_TIMEOUT_SECS: u64 = 1800; - - let timeout_secs = if non_streaming_timeout > 0 { - non_streaming_timeout - } else { - GLOBAL_TIMEOUT_SECS - }; - - // 注意:这里不能用 expect/unwrap。 - // release 配置为 panic=abort,一旦 build 失败会导致整个应用闪退。 - // 常见原因:用户环境变量里存在不合法/不支持的代理(HTTP(S)_PROXY/ALL_PROXY 等)。 - let (client, client_init_error) = match Client::builder() - .timeout(Duration::from_secs(timeout_secs)) - .build() - { - Ok(client) => (Some(client), None), - Err(e) => { - // 降级:忽略系统/环境代理,避免因代理配置问题导致整个应用崩溃 - match Client::builder() - .timeout(Duration::from_secs(timeout_secs)) - .no_proxy() - .build() - { - Ok(client) => (Some(client), Some(e.to_string())), - Err(fallback_err) => ( - None, - Some(format!( - "Failed to create HTTP client: {e}; no_proxy fallback failed: {fallback_err}" - )), - ), - } - } - }; - Self { - client, - client_init_error, router, status, current_providers, failover_manager, app_handle, current_provider_id_at_start, + non_streaming_timeout: std::time::Duration::from_secs(non_streaming_timeout), } } @@ -416,16 +378,17 @@ impl RequestForwarder { // 默认使用空白名单,过滤所有 _ 前缀字段 let filtered_body = filter_private_params_with_whitelist(request_body, &[]); - // 构建请求 - let client = self.client.as_ref().ok_or_else(|| { - ProxyError::ForwardFailed( - self.client_init_error - .clone() - .unwrap_or_else(|| "HTTP client is not initialized".to_string()), - ) - })?; + // 每次请求时获取最新的全局 HTTP 客户端(支持热更新代理配置) + let client = super::http_client::get(); let mut request = client.post(&url); + // 只有当 timeout > 0 时才设置请求超时 + // Duration::ZERO 在 reqwest 中表示"立刻超时"而不是"禁用超时" + // 故障转移关闭时会传入 0,此时应该使用 client 的默认超时(600秒) + if !self.non_streaming_timeout.is_zero() { + request = request.timeout(self.non_streaming_timeout); + } + // 过滤黑名单 Headers,保护隐私并避免冲突 for (key, value) in headers { if HEADER_BLACKLIST diff --git a/src-tauri/src/proxy/http_client.rs b/src-tauri/src/proxy/http_client.rs new file mode 100644 index 000000000..b4be31277 --- /dev/null +++ b/src-tauri/src/proxy/http_client.rs @@ -0,0 +1,301 @@ +//! 全局 HTTP 客户端模块 +//! +//! 提供支持全局代理配置的 HTTP 客户端。 +//! 所有需要发送 HTTP 请求的模块都应使用此模块提供的客户端。 + +use once_cell::sync::OnceCell; +use reqwest::Client; +use std::sync::RwLock; +use std::time::Duration; + +/// 全局 HTTP 客户端实例 +static GLOBAL_CLIENT: OnceCell> = OnceCell::new(); + +/// 当前代理 URL(用于日志和状态查询) +static CURRENT_PROXY_URL: OnceCell>> = OnceCell::new(); + +/// 初始化全局 HTTP 客户端 +/// +/// 应在应用启动时调用一次。 +/// +/// # Arguments +/// * `proxy_url` - 代理 URL,如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080` +/// 传入 None 或空字符串表示直连 +pub fn init(proxy_url: Option<&str>) -> Result<(), String> { + let effective_url = proxy_url.filter(|s| !s.trim().is_empty()); + let client = build_client(effective_url)?; + + // 尝试初始化全局客户端,如果已存在则记录警告并使用 apply_proxy 更新 + if GLOBAL_CLIENT.set(RwLock::new(client.clone())).is_err() { + log::warn!( + "[GlobalProxy] [GP-003] Already initialized, updating instead: {}", + effective_url + .map(mask_url) + .unwrap_or_else(|| "direct connection".to_string()) + ); + // 已初始化,改用 apply_proxy 更新 + return apply_proxy(proxy_url); + } + + // 初始化代理 URL 记录 + let _ = CURRENT_PROXY_URL.set(RwLock::new(effective_url.map(|s| s.to_string()))); + + log::info!( + "[GlobalProxy] Initialized: {}", + effective_url + .map(mask_url) + .unwrap_or_else(|| "direct connection".to_string()) + ); + + Ok(()) +} + +/// 验证代理配置(不应用) +/// +/// 只验证代理 URL 是否有效,不实际更新全局客户端。 +/// 用于在持久化之前验证配置的有效性。 +/// +/// # Arguments +/// * `proxy_url` - 代理 URL,None 或空字符串表示直连 +/// +/// # Returns +/// 验证成功返回 Ok(()),失败返回错误信息 +pub fn validate_proxy(proxy_url: Option<&str>) -> Result<(), String> { + let effective_url = proxy_url.filter(|s| !s.trim().is_empty()); + // 只调用 build_client 来验证,但不应用 + build_client(effective_url)?; + Ok(()) +} + +/// 应用代理配置(假设已验证) +/// +/// 直接应用代理配置到全局客户端,不做额外验证。 +/// 应在 validate_proxy 成功后调用。 +/// +/// # Arguments +/// * `proxy_url` - 代理 URL,None 或空字符串表示直连 +pub fn apply_proxy(proxy_url: Option<&str>) -> Result<(), String> { + let effective_url = proxy_url.filter(|s| !s.trim().is_empty()); + let new_client = build_client(effective_url)?; + + // 更新客户端 + if let Some(lock) = GLOBAL_CLIENT.get() { + let mut client = lock.write().map_err(|e| { + log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}"); + "Failed to update proxy: lock poisoned".to_string() + })?; + *client = new_client; + } else { + // 如果还没初始化,则初始化 + return init(proxy_url); + } + + // 更新代理 URL 记录 + if let Some(lock) = CURRENT_PROXY_URL.get() { + let mut url = lock.write().map_err(|e| { + log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}"); + "Failed to update proxy URL record: lock poisoned".to_string() + })?; + *url = effective_url.map(|s| s.to_string()); + } + + log::info!( + "[GlobalProxy] Applied: {}", + effective_url + .map(mask_url) + .unwrap_or_else(|| "direct connection".to_string()) + ); + + Ok(()) +} + +/// 更新代理配置(热更新) +/// +/// 可在运行时调用以更改代理设置,无需重启应用。 +/// 注意:此函数同时验证和应用,如果需要先验证后持久化再应用, +/// 请使用 validate_proxy + apply_proxy 组合。 +/// +/// # Arguments +/// * `proxy_url` - 新的代理 URL,None 或空字符串表示直连 +#[allow(dead_code)] +pub fn update_proxy(proxy_url: Option<&str>) -> Result<(), String> { + let effective_url = proxy_url.filter(|s| !s.trim().is_empty()); + let new_client = build_client(effective_url)?; + + // 更新客户端 + if let Some(lock) = GLOBAL_CLIENT.get() { + let mut client = lock.write().map_err(|e| { + log::error!("[GlobalProxy] [GP-001] Failed to acquire write lock: {e}"); + "Failed to update proxy: lock poisoned".to_string() + })?; + *client = new_client; + } else { + // 如果还没初始化,则初始化 + return init(proxy_url); + } + + // 更新代理 URL 记录 + if let Some(lock) = CURRENT_PROXY_URL.get() { + let mut url = lock.write().map_err(|e| { + log::error!("[GlobalProxy] [GP-002] Failed to acquire URL write lock: {e}"); + "Failed to update proxy URL record: lock poisoned".to_string() + })?; + *url = effective_url.map(|s| s.to_string()); + } + + log::info!( + "[GlobalProxy] Updated: {}", + effective_url + .map(mask_url) + .unwrap_or_else(|| "direct connection".to_string()) + ); + + Ok(()) +} + +/// 获取全局 HTTP 客户端 +/// +/// 返回配置了代理的客户端(如果已配置代理),否则返回直连客户端。 +pub fn get() -> Client { + GLOBAL_CLIENT + .get() + .and_then(|lock| lock.read().ok()) + .map(|c| c.clone()) + .unwrap_or_else(|| { + // 如果还没初始化,创建一个默认客户端(配置与 build_client 一致) + log::warn!("[GlobalProxy] [GP-004] Client not initialized, using fallback"); + Client::builder() + .timeout(Duration::from_secs(600)) + .connect_timeout(Duration::from_secs(30)) + .pool_max_idle_per_host(10) + .tcp_keepalive(Duration::from_secs(60)) + .no_proxy() + .build() + .unwrap_or_default() + }) +} + +/// 获取当前代理 URL +/// +/// 返回当前配置的代理 URL,None 表示直连。 +pub fn get_current_proxy_url() -> Option { + CURRENT_PROXY_URL + .get() + .and_then(|lock| lock.read().ok()) + .and_then(|url| url.clone()) +} + +/// 检查是否正在使用代理 +#[allow(dead_code)] +pub fn is_proxy_enabled() -> bool { + get_current_proxy_url().is_some() +} + +/// 构建 HTTP 客户端 +fn build_client(proxy_url: Option<&str>) -> Result { + let mut builder = Client::builder() + .timeout(Duration::from_secs(600)) + .connect_timeout(Duration::from_secs(30)) + .pool_max_idle_per_host(10) + .tcp_keepalive(Duration::from_secs(60)); + + // 有代理地址则使用代理,否则直连 + if let Some(url) = proxy_url { + // 先验证 URL 格式和 scheme + let parsed = url::Url::parse(url) + .map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?; + + let scheme = parsed.scheme(); + if !["http", "https", "socks5", "socks5h"].contains(&scheme) { + return Err(format!( + "Invalid proxy scheme '{}' in URL '{}'. Supported: http, https, socks5, socks5h", + scheme, + mask_url(url) + )); + } + + let proxy = reqwest::Proxy::all(url) + .map_err(|e| format!("Invalid proxy URL '{}': {}", mask_url(url), e))?; + builder = builder.proxy(proxy); + log::debug!("[GlobalProxy] Proxy configured: {}", mask_url(url)); + } else { + builder = builder.no_proxy(); + log::debug!("[GlobalProxy] Direct connection (no proxy)"); + } + + builder + .build() + .map_err(|e| format!("Failed to build HTTP client: {e}")) +} + +/// 隐藏 URL 中的敏感信息(用于日志) +pub fn mask_url(url: &str) -> String { + if let Ok(parsed) = url::Url::parse(url) { + // 隐藏用户名和密码,保留 scheme、host 和端口 + let host = parsed.host_str().unwrap_or("?"); + match parsed.port() { + Some(port) => format!("{}://{}:{}", parsed.scheme(), host, port), + None => format!("{}://{}", parsed.scheme(), host), + } + } else { + // URL 解析失败,返回部分内容 + if url.len() > 20 { + format!("{}...", &url[..20]) + } else { + url.to_string() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mask_url() { + assert_eq!(mask_url("http://127.0.0.1:7890"), "http://127.0.0.1:7890"); + assert_eq!( + mask_url("http://user:pass@127.0.0.1:7890"), + "http://127.0.0.1:7890" + ); + assert_eq!( + mask_url("socks5://admin:secret@proxy.example.com:1080"), + "socks5://proxy.example.com:1080" + ); + // 无端口的 URL 不应显示 ":?" + assert_eq!( + mask_url("http://proxy.example.com"), + "http://proxy.example.com" + ); + assert_eq!( + mask_url("https://user:pass@proxy.example.com"), + "https://proxy.example.com" + ); + } + + #[test] + fn test_build_client_direct() { + let result = build_client(None); + assert!(result.is_ok()); + } + + #[test] + fn test_build_client_with_http_proxy() { + let result = build_client(Some("http://127.0.0.1:7890")); + assert!(result.is_ok()); + } + + #[test] + fn test_build_client_with_socks5_proxy() { + let result = build_client(Some("socks5://127.0.0.1:1080")); + assert!(result.is_ok()); + } + + #[test] + fn test_build_client_invalid_url() { + // reqwest::Proxy::all 对某些无效 URL 不会立即报错 + // 使用明确无效的 scheme 来触发错误 + let result = build_client(Some("invalid-scheme://127.0.0.1:7890")); + assert!(result.is_err(), "Should reject invalid proxy scheme"); + } +} diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index bcd946a77..8d99ab24c 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -12,6 +12,7 @@ pub mod handler_config; pub mod handler_context; mod handlers; mod health; +pub mod http_client; pub mod log_codes; pub mod model_mapper; pub mod provider_router; diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index 4f5bf7488..e9aa8962e 100644 --- a/src-tauri/src/services/skill.rs +++ b/src-tauri/src/services/skill.rs @@ -7,7 +7,6 @@ use anyhow::{anyhow, Context, Result}; use chrono::{DateTime, Utc}; -use reqwest::Client; use serde::{Deserialize, Serialize}; use std::collections::{HashMap, HashSet}; use std::fs; @@ -143,9 +142,7 @@ pub struct SkillMetadata { // ========== SkillService ========== -pub struct SkillService { - http_client: Client, -} +pub struct SkillService; impl Default for SkillService { fn default() -> Self { @@ -155,13 +152,7 @@ impl Default for SkillService { impl SkillService { pub fn new() -> Self { - Self { - http_client: Client::builder() - .user_agent("cc-switch") - .timeout(std::time::Duration::from_secs(10)) - .build() - .unwrap_or_else(|_| Client::new()), - } + Self } // ========== 路径管理 ========== @@ -863,7 +854,8 @@ impl SkillService { /// 下载并解压 ZIP async fn download_and_extract(&self, url: &str, dest: &Path) -> Result<()> { - let response = self.http_client.get(url).send().await?; + let client = crate::proxy::http_client::get(); + let response = client.get(url).send().await?; if !response.status().is_success() { let status = response.status().as_u16().to_string(); return Err(anyhow::anyhow!(format_skill_error( diff --git a/src-tauri/src/services/speedtest.rs b/src-tauri/src/services/speedtest.rs index 57916e98d..2d03547d1 100644 --- a/src-tauri/src/services/speedtest.rs +++ b/src-tauri/src/services/speedtest.rs @@ -1,7 +1,7 @@ use futures::future::join_all; use reqwest::{Client, Url}; use serde::Serialize; -use std::time::{Duration, Instant}; +use std::time::Instant; use crate::error::AppError; @@ -65,17 +65,21 @@ impl SpeedtestService { } let timeout = Self::sanitize_timeout(timeout_secs); - let client = Self::build_client(timeout)?; + let (client, request_timeout) = Self::build_client(timeout)?; let tasks = valid_targets.into_iter().map(|(idx, trimmed, parsed_url)| { let client = client.clone(); async move { // 先进行一次热身请求,忽略结果,仅用于复用连接/绕过首包惩罚。 - let _ = client.get(parsed_url.clone()).send().await; + let _ = client + .get(parsed_url.clone()) + .timeout(request_timeout) + .send() + .await; // 第二次请求开始计时,并将其作为结果返回。 let start = Instant::now(); - let latency = match client.get(parsed_url).send().await { + let latency = match client.get(parsed_url).timeout(request_timeout).send().await { Ok(resp) => EndpointLatency { url: trimmed, latency: Some(start.elapsed().as_millis()), @@ -112,19 +116,11 @@ impl SpeedtestService { Ok(results.into_iter().flatten().collect::>()) } - fn build_client(timeout_secs: u64) -> Result { - Client::builder() - .timeout(Duration::from_secs(timeout_secs)) - .redirect(reqwest::redirect::Policy::limited(5)) - .user_agent("cc-switch-speedtest/1.0") - .build() - .map_err(|e| { - AppError::localized( - "speedtest.client_create_failed", - format!("创建 HTTP 客户端失败: {e}"), - format!("Failed to create HTTP client: {e}"), - ) - }) + fn build_client(timeout_secs: u64) -> Result<(Client, std::time::Duration), AppError> { + // 使用全局 HTTP 客户端(已包含代理配置) + // 返回 timeout Duration 供请求级别使用 + let timeout = std::time::Duration::from_secs(timeout_secs); + Ok((crate::proxy::http_client::get(), timeout)) } fn sanitize_timeout(timeout_secs: Option) -> u64 { diff --git a/src-tauri/src/services/stream_check.rs b/src-tauri/src/services/stream_check.rs index d8a5db6f7..f7521f7cf 100644 --- a/src-tauri/src/services/stream_check.rs +++ b/src-tauri/src/services/stream_check.rs @@ -7,7 +7,7 @@ use regex::Regex; use reqwest::Client; use serde::{Deserialize, Serialize}; use serde_json::json; -use std::time::{Duration, Instant}; +use std::time::Instant; use crate::app_config::AppType; use crate::error::AppError; @@ -136,23 +136,36 @@ impl StreamCheckService { .extract_auth(provider) .ok_or_else(|| AppError::Message("未找到 API Key".to_string()))?; - let client = Client::builder() - .timeout(Duration::from_secs(config.timeout_secs)) - .user_agent("cc-switch/1.0") - .build() - .map_err(|e| AppError::Message(format!("创建客户端失败: {e}")))?; + // 使用全局 HTTP 客户端(已包含代理配置) + let client = crate::proxy::http_client::get(); + let request_timeout = std::time::Duration::from_secs(config.timeout_secs); let model_to_test = Self::resolve_test_model(app_type, provider, config); let result = match app_type { AppType::Claude => { - Self::check_claude_stream(&client, &base_url, &auth, &model_to_test).await + Self::check_claude_stream( + &client, + &base_url, + &auth, + &model_to_test, + request_timeout, + ) + .await } AppType::Codex => { - Self::check_codex_stream(&client, &base_url, &auth, &model_to_test).await + Self::check_codex_stream(&client, &base_url, &auth, &model_to_test, request_timeout) + .await } AppType::Gemini => { - Self::check_gemini_stream(&client, &base_url, &auth, &model_to_test).await + Self::check_gemini_stream( + &client, + &base_url, + &auth, + &model_to_test, + request_timeout, + ) + .await } }; @@ -193,6 +206,7 @@ impl StreamCheckService { base_url: &str, auth: &AuthInfo, model: &str, + timeout: std::time::Duration, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); let url = if base.ends_with("/v1") { @@ -213,6 +227,7 @@ impl StreamCheckService { .header("x-api-key", &auth.api_key) .header("anthropic-version", "2023-06-01") .header("Content-Type", "application/json") + .timeout(timeout) .json(&body) .send() .await @@ -243,6 +258,7 @@ impl StreamCheckService { base_url: &str, auth: &AuthInfo, model: &str, + timeout: std::time::Duration, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); let url = if base.ends_with("/v1") { @@ -275,6 +291,7 @@ impl StreamCheckService { .post(&url) .header("Authorization", format!("Bearer {}", auth.api_key)) .header("Content-Type", "application/json") + .timeout(timeout) .json(&body) .send() .await @@ -304,6 +321,7 @@ impl StreamCheckService { base_url: &str, auth: &AuthInfo, model: &str, + timeout: std::time::Duration, ) -> Result<(u16, String), AppError> { let base = base_url.trim_end_matches('/'); let url = format!("{base}/v1/chat/completions"); @@ -320,6 +338,7 @@ impl StreamCheckService { .post(&url) .header("Authorization", format!("Bearer {}", auth.api_key)) .header("Content-Type", "application/json") + .timeout(timeout) .json(&body) .send() .await diff --git a/src-tauri/src/usage_script.rs b/src-tauri/src/usage_script.rs index 2fe515c28..3da7ac8ec 100644 --- a/src-tauri/src/usage_script.rs +++ b/src-tauri/src/usage_script.rs @@ -1,8 +1,6 @@ -use reqwest::Client; use rquickjs::{Context, Function, Runtime}; use serde_json::Value; use std::collections::HashMap; -use std::time::Duration; use url::{Host, Url}; use crate::error::AppError; @@ -215,18 +213,10 @@ struct RequestConfig { /// 发送 HTTP 请求 async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result { - // 约束超时范围,防止异常配置导致长时间阻塞 - let timeout = timeout_secs.clamp(2, 30); - let client = Client::builder() - .timeout(Duration::from_secs(timeout)) - .build() - .map_err(|e| { - AppError::localized( - "usage_script.client_create_failed", - format!("创建客户端失败: {e}"), - format!("Failed to create client: {e}"), - ) - })?; + // 使用全局 HTTP 客户端(已包含代理配置) + let client = crate::proxy::http_client::get(); + // 约束超时范围,防止异常配置导致长时间阻塞(最小 2 秒,最大 30 秒) + let request_timeout = std::time::Duration::from_secs(timeout_secs.clamp(2, 30)); // 严格校验 HTTP 方法,非法值不回退为 GET let method: reqwest::Method = config.method.parse().map_err(|_| { @@ -237,7 +227,9 @@ async fn send_http_request(config: &RequestConfig, timeout_secs: u64) -> Result< ) })?; - let mut req = client.request(method.clone(), &config.url); + let mut req = client + .request(method.clone(), &config.url) + .timeout(request_timeout); // 添加请求头 for (k, v) in &config.headers { diff --git a/src/App.tsx b/src/App.tsx index 3809319b1..452afed56 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -502,7 +502,9 @@ function App() { onDuplicate={handleDuplicateProvider} onConfigureUsage={setUsageProvider} onOpenWebsite={handleOpenWebsite} - onOpenTerminal={activeApp === "claude" ? handleOpenTerminal : undefined} + onOpenTerminal={ + activeApp === "claude" ? handleOpenTerminal : undefined + } onCreate={() => setIsAddOpen(true)} /> diff --git a/src/components/providers/ProviderCard.tsx b/src/components/providers/ProviderCard.tsx index 792960340..dcf9b6bb5 100644 --- a/src/components/providers/ProviderCard.tsx +++ b/src/components/providers/ProviderCard.tsx @@ -365,7 +365,9 @@ export function ProviderCard({ onTest={onTest ? () => onTest(provider) : undefined} onConfigureUsage={() => onConfigureUsage(provider)} onDelete={() => onDelete(provider)} - onOpenTerminal={onOpenTerminal ? () => onOpenTerminal(provider) : undefined} + onOpenTerminal={ + onOpenTerminal ? () => onOpenTerminal(provider) : undefined + } // 故障转移相关 isAutoFailoverEnabled={isAutoFailoverEnabled} isInFailoverQueue={isInFailoverQueue} diff --git a/src/components/proxy/ProxyPanel.tsx b/src/components/proxy/ProxyPanel.tsx index 5bee8590f..84e5858b0 100644 --- a/src/components/proxy/ProxyPanel.tsx +++ b/src/components/proxy/ProxyPanel.tsx @@ -106,13 +106,11 @@ export function ProxyPanel() { // 校验地址格式(简单的 IP 地址或 localhost 校验) const addressTrimmed = listenAddress.trim(); const ipv4Regex = /^(\d{1,3}\.){3}\d{1,3}$/; - // 规范化 localhost 为 127.0.0.1 - const normalizedAddress = - addressTrimmed === "localhost" ? "127.0.0.1" : addressTrimmed; const isValidAddress = - normalizedAddress === "0.0.0.0" || - (ipv4Regex.test(normalizedAddress) && - normalizedAddress.split(".").every((n) => { + addressTrimmed === "localhost" || + addressTrimmed === "0.0.0.0" || + (ipv4Regex.test(addressTrimmed) && + addressTrimmed.split(".").every((n) => { const num = parseInt(n); return num >= 0 && num <= 255; })); @@ -148,11 +146,9 @@ export function ProxyPanel() { try { await updateGlobalConfig.mutateAsync({ ...globalConfig, - listenAddress: normalizedAddress, + listenAddress: addressTrimmed, listenPort: port, }); - // 同步更新本地状态为规范化后的值 - setListenAddress(normalizedAddress); toast.success( t("proxy.settings.configSaved", { defaultValue: "代理配置已保存" }), { closeButton: true }, diff --git a/src/components/settings/GlobalProxySettings.tsx b/src/components/settings/GlobalProxySettings.tsx new file mode 100644 index 000000000..089abd104 --- /dev/null +++ b/src/components/settings/GlobalProxySettings.tsx @@ -0,0 +1,275 @@ +/** + * 全局出站代理设置组件 + * + * 提供配置全局代理的输入界面,支持用户名密码认证。 + */ + +import { useState, useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { Input } from "@/components/ui/input"; +import { Button } from "@/components/ui/button"; +import { Loader2, TestTube2, Search, Eye, EyeOff, X } from "lucide-react"; +import { + useGlobalProxyUrl, + useSetGlobalProxyUrl, + useTestProxy, + useScanProxies, + type DetectedProxy, +} from "@/hooks/useGlobalProxy"; + +/** 从完整 URL 提取认证信息 */ +function extractAuth(url: string): { + baseUrl: string; + username: string; + password: string; +} { + if (!url.trim()) return { baseUrl: "", username: "", password: "" }; + + try { + const parsed = new URL(url); + const username = decodeURIComponent(parsed.username || ""); + const password = decodeURIComponent(parsed.password || ""); + // 移除认证信息,获取基础 URL + parsed.username = ""; + parsed.password = ""; + return { baseUrl: parsed.toString(), username, password }; + } catch { + return { baseUrl: url, username: "", password: "" }; + } +} + +/** 将认证信息合并到 URL */ +function mergeAuth( + baseUrl: string, + username: string, + password: string, +): string { + if (!baseUrl.trim()) return ""; + if (!username.trim()) return baseUrl; + + try { + const parsed = new URL(baseUrl); + // URL 对象的 username/password setter 会自动进行 percent-encoding + // 不要使用 encodeURIComponent,否则会导致双重编码 + parsed.username = username.trim(); + if (password) { + parsed.password = password; + } + return parsed.toString(); + } catch { + // URL 解析失败,尝试手动插入(此时需要手动编码) + const match = baseUrl.match(/^(\w+:\/\/)(.+)$/); + if (match) { + const auth = password + ? `${encodeURIComponent(username.trim())}:${encodeURIComponent(password)}@` + : `${encodeURIComponent(username.trim())}@`; + return `${match[1]}${auth}${match[2]}`; + } + return baseUrl; + } +} + +export function GlobalProxySettings() { + const { t } = useTranslation(); + const { data: savedUrl, isLoading } = useGlobalProxyUrl(); + const setMutation = useSetGlobalProxyUrl(); + const testMutation = useTestProxy(); + const scanMutation = useScanProxies(); + + const [url, setUrl] = useState(""); + const [username, setUsername] = useState(""); + const [password, setPassword] = useState(""); + const [showPassword, setShowPassword] = useState(false); + const [dirty, setDirty] = useState(false); + const [detected, setDetected] = useState([]); + + // 计算完整 URL(含认证信息) + const fullUrl = useMemo( + () => mergeAuth(url, username, password), + [url, username, password], + ); + + // 同步远程配置 + useEffect(() => { + if (savedUrl !== undefined) { + const { baseUrl, username: u, password: p } = extractAuth(savedUrl || ""); + setUrl(baseUrl); + setUsername(u); + setPassword(p); + setDirty(false); + } + }, [savedUrl]); + + const handleSave = async () => { + await setMutation.mutateAsync(fullUrl); + setDirty(false); + }; + + const handleTest = async () => { + if (fullUrl) { + await testMutation.mutateAsync(fullUrl); + } + }; + + const handleScan = async () => { + const result = await scanMutation.mutateAsync(); + setDetected(result); + }; + + const handleSelect = (proxyUrl: string) => { + const { baseUrl, username: u, password: p } = extractAuth(proxyUrl); + setUrl(baseUrl); + setUsername(u); + setPassword(p); + setDirty(true); + setDetected([]); + }; + + const handleClear = () => { + setUrl(""); + setUsername(""); + setPassword(""); + setDirty(true); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && dirty && !setMutation.isPending) { + handleSave(); + } + }; + + // 只在首次加载且无数据时显示加载状态 + if (isLoading && savedUrl === undefined) { + return ( +
+ +
+ ); + } + + return ( +
+ {/* 描述 */} +

+ {t("settings.globalProxy.hint")} +

+ + {/* 代理地址输入框和按钮 */} +
+ { + setUrl(e.target.value); + setDirty(true); + }} + onKeyDown={handleKeyDown} + className="font-mono text-sm flex-1" + /> + + + + +
+ + {/* 认证信息:用户名 + 密码(可选) */} +
+ { + setUsername(e.target.value); + setDirty(true); + }} + onKeyDown={handleKeyDown} + className="font-mono text-sm flex-1" + /> +
+ { + setPassword(e.target.value); + setDirty(true); + }} + onKeyDown={handleKeyDown} + className="font-mono text-sm pr-10" + /> + +
+
+ + {/* 扫描结果 */} + {detected.length > 0 && ( +
+ {detected.map((p) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 5f5c725c2..6c67b4040 100644 --- a/src/components/settings/SettingsPage.tsx +++ b/src/components/settings/SettingsPage.tsx @@ -9,6 +9,7 @@ import { Database, Server, ChevronDown, + Globe, } from "lucide-react"; import * as AccordionPrimitive from "@radix-ui/react-accordion"; import { toast } from "sonner"; @@ -34,6 +35,7 @@ import { WindowSettings } from "@/components/settings/WindowSettings"; import { DirectorySettings } from "@/components/settings/DirectorySettings"; import { ImportExportSection } from "@/components/settings/ImportExportSection"; import { AboutSection } from "@/components/settings/AboutSection"; +import { GlobalProxySettings } from "@/components/settings/GlobalProxySettings"; import { ProxyPanel } from "@/components/proxy"; import { PricingConfigPanel } from "@/components/usage/PricingConfigPanel"; import { ModelTestConfigPanel } from "@/components/usage/ModelTestConfigPanel"; @@ -495,6 +497,28 @@ export function SettingsPage({ + + +
+ +
+

+ {t("settings.advanced.globalProxy.title")} +

+

+ {t("settings.advanced.globalProxy.description")} +

+
+
+
+ + + +
+ { + toast.success(t("settings.globalProxy.saved")); + queryClient.invalidateQueries({ queryKey: ["globalProxyUrl"] }); + queryClient.invalidateQueries({ queryKey: ["upstreamProxyStatus"] }); + }, + onError: (error: unknown) => { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Unknown error"; + toast.error(t("settings.globalProxy.saveFailed", { error: message })); + }, + }); +} + +/** + * 测试代理连接 + */ +export function useTestProxy() { + const { t } = useTranslation(); + + return useMutation({ + mutationFn: testProxyUrl, + onSuccess: (result: ProxyTestResult) => { + if (result.success) { + toast.success( + t("settings.globalProxy.testSuccess", { latency: result.latencyMs }), + ); + } else { + toast.error( + t("settings.globalProxy.testFailed", { error: result.error }), + ); + } + }, + onError: (error: Error) => { + toast.error(error.message); + }, + }); +} + +/** + * 获取当前出站代理状态 + */ +export function useUpstreamProxyStatus() { + return useQuery({ + queryKey: ["upstreamProxyStatus"], + queryFn: getUpstreamProxyStatus, + }); +} + +/** + * 扫描本地代理 + */ +export function useScanProxies() { + const { t } = useTranslation(); + + return useMutation({ + mutationFn: scanLocalProxies, + onError: (error: Error) => { + toast.error( + t("settings.globalProxy.scanFailed", { error: error.message }), + ); + }, + }); +} + +export type { DetectedProxy }; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 385f11408..8df92f7b0 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -183,6 +183,10 @@ "title": "Cost Pricing", "description": "Manage token pricing rules for each model" }, + "globalProxy": { + "title": "Global Outbound Proxy", + "description": "Configure proxy for CC Switch to access external APIs" + }, "data": { "title": "Data Management", "description": "Import/export configurations and backup/restore" @@ -271,7 +275,21 @@ "restartLater": "Restart Later", "restartFailed": "Application restart failed, please manually close and reopen.", "devModeRestartHint": "Dev Mode: Automatic restart not supported, please manually restart the application.", - "saving": "Saving..." + "saving": "Saving...", + "globalProxy": { + "label": "Global Proxy", + "hint": "Proxy all requests (API, Skills download, etc.). Leave empty for direct connection.", + "username": "Username (optional)", + "password": "Password (optional)", + "test": "Test Connection", + "scan": "Scan Local Proxies", + "clear": "Clear", + "scanFailed": "Scan failed: {{error}}", + "saved": "Proxy settings saved", + "saveFailed": "Save failed: {{error}}", + "testSuccess": "Connected! Latency {{latency}}ms", + "testFailed": "Connection failed: {{error}}" + } }, "apps": { "claude": "Claude Code", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index 504729737..fc269082e 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -183,6 +183,10 @@ "title": "コスト計算", "description": "各モデルのトークン料金ルールを管理" }, + "globalProxy": { + "title": "グローバル送信プロキシ", + "description": "CC Switch が外部 API にアクセスする際のプロキシを設定" + }, "data": { "title": "データ管理", "description": "設定のインポート/エクスポートとバックアップ/復元" @@ -271,7 +275,21 @@ "restartLater": "後で再起動", "restartFailed": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。", "devModeRestartHint": "開発モードでは自動再起動をサポートしていません。手動で再起動してください。", - "saving": "保存中..." + "saving": "保存中...", + "globalProxy": { + "label": "グローバルプロキシ", + "hint": "すべてのリクエスト(API、Skills ダウンロードなど)をプロキシ経由で送信します。空欄で直接接続。", + "username": "ユーザー名(任意)", + "password": "パスワード(任意)", + "test": "接続テスト", + "scan": "ローカルプロキシをスキャン", + "clear": "クリア", + "scanFailed": "スキャンに失敗しました: {{error}}", + "saved": "プロキシ設定を保存しました", + "saveFailed": "保存に失敗しました: {{error}}", + "testSuccess": "接続成功!遅延 {{latency}}ms", + "testFailed": "接続に失敗しました: {{error}}" + } }, "apps": { "claude": "Claude Code", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index a27c59985..de9d1f81a 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -183,6 +183,10 @@ "title": "成本定价", "description": "管理各模型 Token 计费规则" }, + "globalProxy": { + "title": "全局出站代理", + "description": "配置 CC Switch 访问外部 API 时使用的代理" + }, "data": { "title": "数据管理", "description": "导入导出配置与备份恢复" @@ -271,7 +275,21 @@ "restartLater": "稍后重启", "restartFailed": "应用重启失败,请手动关闭后重新打开。", "devModeRestartHint": "开发模式下不支持自动重启,请手动重新启动应用。", - "saving": "正在保存..." + "saving": "正在保存...", + "globalProxy": { + "label": "全局代理", + "hint": "代理所有请求(API、Skills 下载等)。留空表示直连。", + "username": "用户名(可选)", + "password": "密码(可选)", + "test": "测试连接", + "scan": "扫描本地代理", + "clear": "清除", + "scanFailed": "扫描失败:{{error}}", + "saved": "代理设置已保存", + "saveFailed": "保存失败:{{error}}", + "testSuccess": "连接成功!延迟 {{latency}}ms", + "testFailed": "连接失败:{{error}}" + } }, "apps": { "claude": "Claude Code", diff --git a/src/lib/api/globalProxy.ts b/src/lib/api/globalProxy.ts new file mode 100644 index 000000000..7d3bf2253 --- /dev/null +++ b/src/lib/api/globalProxy.ts @@ -0,0 +1,85 @@ +/** + * 全局出站代理 API + * + * 提供获取、设置和测试全局代理的功能。 + */ + +import { invoke } from "@tauri-apps/api/core"; + +/** + * 代理测试结果 + */ +export interface ProxyTestResult { + success: boolean; + latencyMs: number; + error: string | null; +} + +/** + * 出站代理状态 + */ +export interface UpstreamProxyStatus { + enabled: boolean; + proxyUrl: string | null; +} + +/** + * 检测到的代理 + */ +export interface DetectedProxy { + url: string; + proxyType: string; + port: number; +} + +/** + * 获取全局代理 URL + * + * @returns 代理 URL,null 表示未配置(直连) + */ +export async function getGlobalProxyUrl(): Promise { + return invoke("get_global_proxy_url"); +} + +/** + * 设置全局代理 URL + * + * @param url - 代理 URL(如 http://127.0.0.1:7890 或 socks5://127.0.0.1:1080) + * 空字符串表示清除代理(直连) + */ +export async function setGlobalProxyUrl(url: string): Promise { + try { + return await invoke("set_global_proxy_url", { url }); + } catch (error) { + // Tauri invoke 错误可能是字符串 + throw new Error(typeof error === "string" ? error : String(error)); + } +} + +/** + * 测试代理连接 + * + * @param url - 要测试的代理 URL + * @returns 测试结果,包含是否成功、延迟和错误信息 + */ +export async function testProxyUrl(url: string): Promise { + return invoke("test_proxy_url", { url }); +} + +/** + * 获取当前出站代理状态 + * + * @returns 代理状态,包含是否启用和代理 URL + */ +export async function getUpstreamProxyStatus(): Promise { + return invoke("get_upstream_proxy_status"); +} + +/** + * 扫描本地代理 + * + * @returns 检测到的代理列表 + */ +export async function scanLocalProxies(): Promise { + return invoke("scan_local_proxies"); +}