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(
+130 -93
View File
@@ -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<DetectedProxy[]>([]);
// 同步远程配置
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 (
<div className="flex items-center justify-center p-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
// 同步远程配置
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 (
<div className="space-y-3">
{/* 标题 */}
<div className="flex items-center gap-2">
<Globe className="h-4 w-4 text-muted-foreground" />
<Label className="text-sm font-medium">
{t("settings.globalProxy.label")}
</Label>
</div>
{/* 描述 */}
<p className="text-xs text-muted-foreground">
{t("settings.globalProxy.hint")}
</p>
{/* 输入框和按钮 */}
<div className="flex gap-2">
<Input
placeholder="http://127.0.0.1:7890 / socks5://127.0.0.1:1080"
value={url}
onChange={(e) => {
setUrl(e.target.value);
setDirty(true);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm flex-1"
/>
<Button
variant="outline"
size="icon"
disabled={!url.trim() || testMutation.isPending}
onClick={handleTest}
title={t("settings.globalProxy.test")}
>
{testMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
)}
</Button>
<Button
onClick={handleSave}
disabled={!dirty || setMutation.isPending}
size="sm"
>
{setMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
</div>
<div className="flex items-center justify-center p-4">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="space-y-3">
{/* 描述 */}
<p className="text-sm text-muted-foreground">
{t("settings.globalProxy.hint")}
</p>
{/* 输入框和按钮 */}
<div className="flex gap-2">
<Input
placeholder="http://127.0.0.1:7890 / socks5://127.0.0.1:1080"
value={url}
onChange={(e) => {
setUrl(e.target.value);
setDirty(true);
}}
onKeyDown={handleKeyDown}
className="font-mono text-sm flex-1"
/>
<Button
variant="outline"
size="icon"
disabled={scanMutation.isPending}
onClick={handleScan}
title={t("settings.globalProxy.scan")}
>
{scanMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Search className="h-4 w-4" />
)}
</Button>
<Button
variant="outline"
size="icon"
disabled={!url.trim() || testMutation.isPending}
onClick={handleTest}
title={t("settings.globalProxy.test")}
>
{testMutation.isPending ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<TestTube2 className="h-4 w-4" />
)}
</Button>
<Button
onClick={handleSave}
disabled={!dirty || setMutation.isPending}
size="sm"
>
{setMutation.isPending && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{t("common.save")}
</Button>
</div>
{/* 扫描结果 */}
{detected.length > 0 && (
<div className="flex flex-wrap gap-2">
{detected.map((p) => (
<Button
key={p.url}
variant="secondary"
size="sm"
onClick={() => handleSelect(p.url)}
className="font-mono text-xs"
>
{p.url}
</Button>
))}
</div>
)}
</div>
);
}
+23 -1
View File
@@ -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}
/>
<GlobalProxySettings />
</motion.div>
) : null}
</TabsContent>
@@ -497,6 +497,28 @@ export function SettingsPage({
</AccordionContent>
</AccordionItem>
<AccordionItem
value="globalProxy"
className="rounded-xl glass-card overflow-hidden"
>
<AccordionTrigger className="px-6 py-4 hover:no-underline hover:bg-muted/50 data-[state=open]:bg-muted/50">
<div className="flex items-center gap-3">
<Globe className="h-5 w-5 text-cyan-500" />
<div className="text-left">
<h3 className="text-base font-semibold">
{t("settings.advanced.globalProxy.title")}
</h3>
<p className="text-sm text-muted-foreground font-normal">
{t("settings.advanced.globalProxy.description")}
</p>
</div>
</div>
</AccordionTrigger>
<AccordionContent className="px-6 pb-6 pt-4 border-t border-border/50">
<GlobalProxySettings />
</AccordionContent>
</AccordionItem>
<AccordionItem
value="data"
className="rounded-xl glass-card overflow-hidden"
+72 -47
View File
@@ -8,77 +8,102 @@ import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import {
getGlobalProxyUrl,
setGlobalProxyUrl,
testProxyUrl,
getUpstreamProxyStatus,
type ProxyTestResult,
type UpstreamProxyStatus,
getGlobalProxyUrl,
setGlobalProxyUrl,
testProxyUrl,
getUpstreamProxyStatus,
scanLocalProxies,
type ProxyTestResult,
type UpstreamProxyStatus,
type DetectedProxy,
} from "@/lib/api/globalProxy";
/**
* 获取全局代理 URL
*/
export function useGlobalProxyUrl() {
return useQuery({
queryKey: ["globalProxyUrl"],
queryFn: getGlobalProxyUrl,
});
return useQuery({
queryKey: ["globalProxyUrl"],
queryFn: getGlobalProxyUrl,
staleTime: 30 * 1000, // 30秒内不重新获取,避免展开时闪烁
});
}
/**
* 设置全局代理 URL
*/
export function useSetGlobalProxyUrl() {
const queryClient = useQueryClient();
const { t } = useTranslation();
const queryClient = useQueryClient();
const { t } = useTranslation();
return useMutation({
mutationFn: setGlobalProxyUrl,
onSuccess: () => {
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<UpstreamProxyStatus>({
queryKey: ["upstreamProxyStatus"],
queryFn: getUpstreamProxyStatus,
});
return useQuery<UpstreamProxyStatus>({
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 };
+7 -1
View File
@@ -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)"
}
}
}
+16 -1
View File
@@ -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",
+7 -1
View File
@@ -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": "以下是将要同步到各应用的配置内容(仅覆盖显示的字段,保留其他自定义配置)"
}
}
}
+24 -1
View File
@@ -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<string | null> {
* 空字符串表示清除代理(直连)
*/
export async function setGlobalProxyUrl(url: string): Promise<void> {
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<ProxyTestResult> {
export async function getUpstreamProxyStatus(): Promise<UpstreamProxyStatus> {
return invoke<UpstreamProxyStatus>("get_upstream_proxy_status");
}
/**
* 扫描本地代理
*
* @returns 检测到的代理列表
*/
export async function scanLocalProxies(): Promise<DetectedProxy[]> {
return invoke<DetectedProxy[]>("scan_local_proxies");
}