From cba8e8fdb3a0599f88593e3796b0d1be9cae236b Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 12 Jan 2026 01:11:43 +0800 Subject: [PATCH] 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 --- src-tauri/src/commands/global_proxy.rs | 71 +++++- src-tauri/src/lib.rs | 1 + src-tauri/src/proxy/http_client.rs | 15 +- src-tauri/src/proxy/mod.rs | 2 +- src-tauri/src/services/provider/live.rs | 2 +- src-tauri/src/services/skill.rs | 13 +- .../settings/GlobalProxySettings.tsx | 223 ++++++++++-------- src/components/settings/SettingsPage.tsx | 24 +- src/hooks/useGlobalProxy.ts | 119 ++++++---- src/i18n/locales/en.json | 8 +- src/i18n/locales/ja.json | 17 +- src/i18n/locales/zh.json | 8 +- src/lib/api/globalProxy.ts | 25 +- 13 files changed, 353 insertions(+), 175 deletions(-) diff --git a/src-tauri/src/commands/global_proxy.rs b/src-tauri/src/commands/global_proxy.rs index 6a48de3e2..b9f9f678a 100644 --- a/src-tauri/src/commands/global_proxy.rs +++ b/src-tauri/src/commands/global_proxy.rs @@ -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>) -> Result, String> { - db.get_global_proxy_url().map_err(|e| e.to_string()) +pub fn get_global_proxy_url(state: tauri::State<'_, AppState>) -> Result, 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>) -> Result>, - 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 { 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 { } 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, } + +/// 检测到的代理信息 +#[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 { + 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 +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 69c69444e..647c78dc9 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -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 diff --git a/src-tauri/src/proxy/http_client.rs b/src-tauri/src/proxy/http_client.rs index 943d8000b..3c4a353a5 100644 --- a/src-tauri/src/proxy/http_client.rs +++ b/src-tauri/src/proxy/http_client.rs @@ -20,7 +20,7 @@ static CURRENT_PROXY_URL: OnceCell>> = 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 { } /// 检查是否正在使用代理 +#[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 { // 有代理地址则使用代理,否则直连 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 { 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 解析失败,返回部分内容 diff --git a/src-tauri/src/proxy/mod.rs b/src-tauri/src/proxy/mod.rs index 9d49a717b..8d99ab24c 100644 --- a/src-tauri/src/proxy/mod.rs +++ b/src-tauri/src/proxy/mod.rs @@ -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; diff --git a/src-tauri/src/services/provider/live.rs b/src-tauri/src/services/provider/live.rs index 0192f6ecd..9854f2243 100644 --- a/src-tauri/src/services/provider/live.rs +++ b/src-tauri/src/services/provider/live.rs @@ -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 } } diff --git a/src-tauri/src/services/skill.rs b/src-tauri/src/services/skill.rs index f6743e778..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,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( diff --git a/src/components/settings/GlobalProxySettings.tsx b/src/components/settings/GlobalProxySettings.tsx index ef01966ab..6d0bd0bb1 100644 --- a/src/components/settings/GlobalProxySettings.tsx +++ b/src/components/settings/GlobalProxySettings.tsx @@ -8,107 +8,144 @@ import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; -import { Label } from "@/components/ui/label"; -import { Loader2, TestTube2, Globe } from "lucide-react"; +import { Loader2, TestTube2, Search } from "lucide-react"; import { - useGlobalProxyUrl, - useSetGlobalProxyUrl, - useTestProxy, + useGlobalProxyUrl, + useSetGlobalProxyUrl, + useTestProxy, + useScanProxies, + type DetectedProxy, } from "@/hooks/useGlobalProxy"; export function GlobalProxySettings() { - const { t } = useTranslation(); - const { data: savedUrl, isLoading } = useGlobalProxyUrl(); - const setMutation = useSetGlobalProxyUrl(); - const testMutation = useTestProxy(); + const { t } = useTranslation(); + const { data: savedUrl, isLoading } = useGlobalProxyUrl(); + const setMutation = useSetGlobalProxyUrl(); + const testMutation = useTestProxy(); + const scanMutation = useScanProxies(); - const [url, setUrl] = useState(""); - const [dirty, setDirty] = useState(false); + const [url, setUrl] = useState(""); + const [dirty, setDirty] = useState(false); + const [detected, setDetected] = useState([]); - // 同步远程配置 - useEffect(() => { - if (savedUrl !== undefined) { - setUrl(savedUrl || ""); - setDirty(false); - } - }, [savedUrl]); - - const handleSave = async () => { - await setMutation.mutateAsync(url.trim()); - setDirty(false); - }; - - const handleTest = async () => { - if (url.trim()) { - await testMutation.mutateAsync(url.trim()); - } - }; - - const handleKeyDown = (e: React.KeyboardEvent) => { - if (e.key === "Enter" && dirty && !setMutation.isPending) { - handleSave(); - } - }; - - if (isLoading) { - return ( -
- -
- ); + // 同步远程配置 + useEffect(() => { + if (savedUrl !== undefined) { + setUrl(savedUrl || ""); + setDirty(false); } + }, [savedUrl]); + const handleSave = async () => { + await setMutation.mutateAsync(url.trim()); + setDirty(false); + }; + + const handleTest = async () => { + if (url.trim()) { + await testMutation.mutateAsync(url.trim()); + } + }; + + const handleScan = async () => { + const result = await scanMutation.mutateAsync(); + setDetected(result); + }; + + const handleSelect = (proxyUrl: string) => { + setUrl(proxyUrl); + setDirty(true); + setDetected([]); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === "Enter" && dirty && !setMutation.isPending) { + handleSave(); + } + }; + + // 只在首次加载且无数据时显示加载状态 + if (isLoading && savedUrl === undefined) { return ( -
- {/* 标题 */} -
- - -
- - {/* 描述 */} -

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

- - {/* 输入框和按钮 */} -
- { - setUrl(e.target.value); - setDirty(true); - }} - onKeyDown={handleKeyDown} - className="font-mono text-sm flex-1" - /> - - -
-
+
+ +
); + } + + return ( +
+ {/* 描述 */} +

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

+ + {/* 输入框和按钮 */} +
+ { + setUrl(e.target.value); + setDirty(true); + }} + onKeyDown={handleKeyDown} + className="font-mono text-sm flex-1" + /> + + + +
+ + {/* 扫描结果 */} + {detected.length > 0 && ( +
+ {detected.map((p) => ( + + ))} +
+ )} +
+ ); } diff --git a/src/components/settings/SettingsPage.tsx b/src/components/settings/SettingsPage.tsx index 67ac3a065..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"; @@ -239,7 +240,6 @@ export function SettingsPage({ settings={settings} onChange={handleAutoSave} /> - ) : null} @@ -497,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: Error) => { - toast.error( - t("settings.globalProxy.saveFailed", { error: error.message }) - ); - }, - }); + return useMutation({ + mutationFn: setGlobalProxyUrl, + onSuccess: () => { + 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(); + 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); - }, - }); + 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, - }); + 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 e4b7cd98a..21906bd58 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" @@ -276,6 +280,8 @@ "label": "Global Proxy", "hint": "Proxy all requests (API, Skills download, etc.). Leave empty for direct connection. Supports HTTP and SOCKS5.", "test": "Test Connection", + "scan": "Scan Local Proxies", + "scanFailed": "Scan failed: {{error}}", "saved": "Proxy settings saved", "saveFailed": "Save failed: {{error}}", "testSuccess": "Connected! Latency {{latency}}ms", @@ -1254,4 +1260,4 @@ "configJsonPreview": "Config JSON Preview", "configJsonPreviewHint": "The following configurations will be synced to each app (only the displayed fields will be overwritten, other custom settings will be preserved)" } -} \ No newline at end of file +} diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index badc04b91..b63810c0f 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,18 @@ "restartLater": "後で再起動", "restartFailed": "アプリの再起動に失敗しました。手動で閉じて再度開いてください。", "devModeRestartHint": "開発モードでは自動再起動をサポートしていません。手動で再起動してください。", - "saving": "保存中..." + "saving": "保存中...", + "globalProxy": { + "label": "グローバルプロキシ", + "hint": "すべてのリクエスト(API、Skills ダウンロードなど)をプロキシ経由で送信します。空欄で直接接続。HTTP と SOCKS5 をサポート。", + "test": "接続テスト", + "scan": "ローカルプロキシをスキャン", + "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 dfb2c1018..c5e86a5b8 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": "导入导出配置与备份恢复" @@ -276,6 +280,8 @@ "label": "全局代理", "hint": "代理所有请求(API、Skills 下载等)。留空表示直连。支持 HTTP 和 SOCKS5 代理。", "test": "测试连接", + "scan": "扫描本地代理", + "scanFailed": "扫描失败:{{error}}", "saved": "代理设置已保存", "saveFailed": "保存失败:{{error}}", "testSuccess": "连接成功!延迟 {{latency}}ms", @@ -1254,4 +1260,4 @@ "configJsonPreview": "配置 JSON 预览", "configJsonPreviewHint": "以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)" } -} \ No newline at end of file +} diff --git a/src/lib/api/globalProxy.ts b/src/lib/api/globalProxy.ts index f9f14a543..7d3bf2253 100644 --- a/src/lib/api/globalProxy.ts +++ b/src/lib/api/globalProxy.ts @@ -23,6 +23,15 @@ export interface UpstreamProxyStatus { proxyUrl: string | null; } +/** + * 检测到的代理 + */ +export interface DetectedProxy { + url: string; + proxyType: string; + port: number; +} + /** * 获取全局代理 URL * @@ -39,7 +48,12 @@ export async function getGlobalProxyUrl(): Promise { * 空字符串表示清除代理(直连) */ export async function setGlobalProxyUrl(url: string): Promise { - return invoke("set_global_proxy_url", { url }); + try { + return await invoke("set_global_proxy_url", { url }); + } catch (error) { + // Tauri invoke 错误可能是字符串 + throw new Error(typeof error === "string" ? error : String(error)); + } } /** @@ -60,3 +74,12 @@ export async function testProxyUrl(url: string): Promise { export async function getUpstreamProxyStatus(): Promise { return invoke("get_upstream_proxy_status"); } + +/** + * 扫描本地代理 + * + * @returns 检测到的代理列表 + */ +export async function scanLocalProxies(): Promise { + return invoke("scan_local_proxies"); +}