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
This commit is contained in:
YoVinchen
2026-01-12 01:11:43 +08:00
parent b18be24384
commit cba8e8fdb3
13 changed files with 353 additions and 175 deletions
+58 -13
View File
@@ -2,18 +2,18 @@
//!
//! 提供获取、设置和测试全局代理的 Tauri 命令。
use crate::database::Database;
use crate::proxy::http_client;
use crate::store::AppState;
use serde::Serialize;
use std::sync::Arc;
use std::time::Instant;
use std::net::{Ipv4Addr, SocketAddrV4, TcpStream};
use std::time::{Duration, Instant};
/// 获取全局代理 URL
///
/// 返回当前配置的代理 URL,null 表示直连。
#[tauri::command]
pub fn get_global_proxy_url(db: tauri::State<'_, Arc<Database>>) -> Result<Option<String>, String> {
db.get_global_proxy_url().map_err(|e| e.to_string())
pub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result<Option<String>, String> {
state.db.get_global_proxy_url().map_err(|e| e.to_string())
}
/// 设置全局代理 URL
@@ -21,10 +21,7 @@ pub fn get_global_proxy_url(db: tauri::State<'_, Arc<Database>>) -> Result<Optio
/// - 传入非空字符串:启用代理
/// - 传入空字符串:清除代理(直连)
#[tauri::command]
pub fn set_global_proxy_url(
db: tauri::State<'_, Arc<Database>>,
url: String,
) -> Result<(), String> {
pub fn set_global_proxy_url(state: tauri::State<'_, AppState>, url: String) -> Result<(), String> {
let url_opt = if url.trim().is_empty() {
None
} else {
@@ -32,7 +29,10 @@ pub fn set_global_proxy_url(
};
// 1. 保存到数据库
db.set_global_proxy_url(url_opt).map_err(|e| e.to_string())?;
state
.db
.set_global_proxy_url(url_opt)
.map_err(|e| e.to_string())?;
// 2. 热更新全局 HTTP 客户端
http_client::update_proxy(url_opt)?;
@@ -69,14 +69,14 @@ pub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {
let start = Instant::now();
// 构建带代理的临时客户端
let proxy = reqwest::Proxy::all(&url).map_err(|e| format!("Invalid proxy URL: {}", e))?;
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))?;
.map_err(|e| format!("Failed to build client: {e}"))?;
// 测试连接到 api.anthropic.com
// 使用 HEAD 请求,即使返回 401 也说明网络通了
@@ -99,7 +99,7 @@ pub async fn test_proxy_url(url: String) -> Result<ProxyTestResult, String> {
}
Err(e) => {
let latency = start.elapsed().as_millis() as u64;
log::debug!("[GlobalProxy] Test failed: {} -> {} ({}ms)", url, e, latency);
log::debug!("[GlobalProxy] Test failed: {url} -> {e} ({latency}ms)");
Ok(ProxyTestResult {
success: false,
latency_ms: latency,
@@ -130,3 +130,48 @@ pub struct UpstreamProxyStatus {
/// 代理 URL
pub proxy_url: Option<String>,
}
/// 检测到的代理信息
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct DetectedProxy {
/// 代理 URL
pub url: String,
/// 代理类型 (http/socks5)
pub proxy_type: String,
/// 端口
pub port: u16,
}
/// 常见代理端口配置
const PROXY_PORTS: &[(u16, &str)] = &[
(7890, "http"), // Clash
(7891, "socks5"), // Clash SOCKS
(1080, "socks5"), // 通用 SOCKS5
(8080, "http"), // 通用 HTTP
(8888, "http"), // Charles/Fiddler
(3128, "http"), // Squid
(10808, "socks5"), // V2Ray
(10809, "http"), // V2Ray HTTP
];
/// 扫描本地代理
///
/// 检测常见端口是否有代理服务在运行。
#[tauri::command]
pub fn scan_local_proxies() -> Vec<DetectedProxy> {
let mut found = Vec::new();
for &(port, proxy_type) 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!("{proxy_type}://127.0.0.1:{port}"),
proxy_type: proxy_type.to_string(),
port,
});
}
}
found
}
+1
View File
@@ -856,6 +856,7 @@ pub fn run() {
commands::set_global_proxy_url,
commands::test_proxy_url,
commands::get_upstream_proxy_status,
commands::scan_local_proxies,
]);
let app = builder
+9 -6
View File
@@ -20,7 +20,7 @@ static CURRENT_PROXY_URL: OnceCell<RwLock<Option<String>>> = OnceCell::new();
///
/// # Arguments
/// * `proxy_url` - 代理 URL,如 `http://127.0.0.1:7890` 或 `socks5://127.0.0.1:1080`
/// 传入 None 或空字符串表示直连
/// 传入 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)?;
@@ -100,6 +100,7 @@ pub fn get_current_proxy_url() -> Option<String> {
}
/// 检查是否正在使用代理
#[allow(dead_code)]
pub fn is_proxy_enabled() -> bool {
get_current_proxy_url().is_some()
}
@@ -114,9 +115,8 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
// 有代理地址则使用代理,否则直连
if let Some(url) = proxy_url {
let proxy = reqwest::Proxy::all(url).map_err(|e| {
format!("Invalid proxy URL '{}': {}", mask_url(url), e)
})?;
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 {
@@ -126,7 +126,7 @@ fn build_client(proxy_url: Option<&str>) -> Result<Client, String> {
builder
.build()
.map_err(|e| format!("Failed to build HTTP client: {}", e))
.map_err(|e| format!("Failed to build HTTP client: {e}"))
}
/// 隐藏 URL 中的敏感信息(用于日志)
@@ -137,7 +137,10 @@ fn mask_url(url: &str) -> String {
"{}://{}:{}",
parsed.scheme(),
parsed.host_str().unwrap_or("?"),
parsed.port().map(|p| p.to_string()).unwrap_or_else(|| "?".to_string())
parsed
.port()
.map(|p| p.to_string())
.unwrap_or_else(|| "?".to_string())
)
} else {
// URL 解析失败,返回部分内容
+1 -1
View File
@@ -6,13 +6,13 @@ pub mod body_filter;
pub mod circuit_breaker;
pub mod error;
pub mod error_mapper;
pub mod http_client;
pub(crate) mod failover_switch;
mod forwarder;
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;
+1 -1
View File
@@ -152,7 +152,7 @@ pub fn sync_current_to_live(state: &AppState) -> Result<(), AppError> {
// Skill sync
for app_type in [AppType::Claude, AppType::Codex, AppType::Gemini] {
if let Err(e) = crate::services::skill::SkillService::sync_to_app(&state.db, &app_type) {
log::warn!("同步 Skill 到 {:?} 失败: {}", app_type, e);
log::warn!("同步 Skill 到 {app_type:?} 失败: {e}");
// Continue syncing other apps, don't abort
}
}
+4 -9
View File
@@ -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,10 +152,7 @@ impl Default for SkillService {
impl SkillService {
pub fn new() -> Self {
Self {
// 使用全局 HTTP 客户端(已包含代理配置)
http_client: crate::proxy::http_client::get(),
}
Self
}
// ========== 路径管理 ==========
@@ -860,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(