diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 0bc8bbf..f363e4f 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -17,7 +17,7 @@ futures-util = "0.3" getrandom = "0.3" json5 = "0.4" notify = "7" -reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "socks", "stream"] } rusqlite = { version = "0.32.1", features = ["bundled"] } semver = "1" serde = { version = "1", features = ["derive"] } diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 8824e57..828e1f2 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -1848,7 +1848,8 @@ async fn check_pi_provider_update(app: tauri::AppHandle) -> Result().snapshot()?.proxy_url; + let latest_version = fetch_pi_provider_latest_version(&proxy_url).await?; let update_available = pi_provider_update_available(installed_version_value, &latest_version)?; Ok(PiProviderUpdateStatus { installed_version, @@ -1874,8 +1875,9 @@ async fn install_pi_provider( let model = resolve_pi_default_model(&config, &model).await?; let port = config.port; let api_key = effective_agent_api_key(&config).to_string(); + let proxy_url = config.proxy_url.clone(); let result = tauri::async_runtime::spawn_blocking(move || { - install_pi_provider_inner(&home, &executable, port, &api_key, &model) + install_pi_provider_inner(&home, &executable, port, &api_key, &model, &proxy_url) }) .await .map_err(|error| format!("安装 Pi CLIProxyAPI provider 任务失败: {error}"))??; @@ -1900,8 +1902,9 @@ async fn update_pi_provider( let model = resolve_pi_default_model(&config, &model).await?; let port = config.port; let api_key = effective_agent_api_key(&config).to_string(); + let proxy_url = config.proxy_url.clone(); let result = tauri::async_runtime::spawn_blocking(move || { - update_pi_provider_inner(&home, &executable, port, &api_key, &model) + update_pi_provider_inner(&home, &executable, port, &api_key, &model, &proxy_url) }) .await .map_err(|error| format!("更新 Pi 插件任务失败: {error}"))??; @@ -2018,13 +2021,16 @@ async fn update_codex_model_catalog( async fn update_codex_model_catalog_inner( app: &tauri::AppHandle, ) -> Result { - let client = reqwest::Client::builder() - .redirect(github_https_redirect_policy()) - .connect_timeout(Duration::from_secs(8)) - .read_timeout(Duration::from_secs(15)) - .timeout(Duration::from_secs(25)) - .build() - .map_err(|error| format!("创建 Codex 模型目录更新客户端失败: {error}"))?; + let proxy_url = app.state::().snapshot()?.proxy_url; + let client = build_http_client_with_proxy( + reqwest::Client::builder() + .redirect(github_https_redirect_policy()) + .connect_timeout(Duration::from_secs(8)) + .read_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(25)), + &proxy_url, + "创建 Codex 模型目录更新客户端失败", + )?; let response = client .get(CODEX_MODEL_CATALOG_URL) .header(reqwest::header::ACCEPT, "application/json") @@ -3374,12 +3380,14 @@ fn parse_pi_provider_latest_version(payload: &serde_json::Value) -> Result Result { - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(5)) - .timeout(Duration::from_secs(12)) - .build() - .map_err(|error| format!("创建 Pi provider 更新检测客户端失败: {error}"))?; +async fn fetch_pi_provider_latest_version(proxy_url: &str) -> Result { + let client = build_http_client_with_proxy( + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(5)) + .timeout(Duration::from_secs(12)), + proxy_url, + "创建 Pi provider 更新检测客户端失败", + )?; let response = client .get(PI_CLIPROXYAPI_NPM_LATEST_URL) .header(reqwest::header::ACCEPT, "application/json") @@ -3623,6 +3631,7 @@ fn install_pi_provider_inner( port: u16, api_key: &str, default_model: &str, + proxy_url: &str, ) -> Result { if port == 0 { return Err("内核端口无效".to_string()); @@ -3633,7 +3642,7 @@ fn install_pi_provider_inner( let settings_path = pi_provider_settings_path(home); let mut changed_files = Vec::new(); if !pi_provider_package_installed(home)? { - install_pi_package(executable, home)?; + install_pi_package(executable, home, proxy_url)?; changed_files.push(path_to_string(&settings_path)); } @@ -3705,11 +3714,12 @@ fn update_pi_provider_inner( port: u16, api_key: &str, default_model: &str, + proxy_url: &str, ) -> Result { if !pi_provider_package_installed(home)? { return Err("Pi CLIProxyAPI provider 插件尚未安装".to_string()); } - update_pi_package(executable, home)?; + update_pi_package(executable, home, proxy_url)?; let mut result = repair_pi_provider_inner(home, port, api_key, default_model)?; result.outcome = "updated".to_string(); Ok(result) @@ -3738,7 +3748,7 @@ fn uninstall_pi_provider_inner( )) } -fn install_pi_package(executable: &Path, home: &Path) -> Result<(), String> { +fn install_pi_package(executable: &Path, home: &Path, proxy_url: &str) -> Result<(), String> { #[cfg(target_os = "windows")] let mut command = windows_command_for_executable(executable, false); #[cfg(not(target_os = "windows"))] @@ -3750,6 +3760,7 @@ fn install_pi_package(executable: &Path, home: &Path) -> Result<(), String> { .current_dir(home) .stdin(Stdio::null()); configure_background_command(&mut command); + configure_networked_command(&mut command, proxy_url); let output = command .output() .map_err(|error| format!("执行 Pi 插件安装失败: {error}"))?; @@ -3769,7 +3780,7 @@ fn install_pi_package(executable: &Path, home: &Path) -> Result<(), String> { )) } -fn update_pi_package(executable: &Path, home: &Path) -> Result<(), String> { +fn update_pi_package(executable: &Path, home: &Path, proxy_url: &str) -> Result<(), String> { #[cfg(target_os = "windows")] let mut command = windows_command_for_executable(executable, false); #[cfg(not(target_os = "windows"))] @@ -3782,6 +3793,7 @@ fn update_pi_package(executable: &Path, home: &Path) -> Result<(), String> { .current_dir(home) .stdin(Stdio::null()); configure_background_command(&mut command); + configure_networked_command(&mut command, proxy_url); let output = command .output() .map_err(|error| format!("执行 Pi 插件更新失败: {error}"))?; @@ -10481,6 +10493,7 @@ fn provider_health_content_type_is_streaming(content_type: &str) -> bool { #[tauri::command] async fn provider_health_probe( + gui_config_state: tauri::State<'_, GuiConfigState>, request: ProviderHealthProbeRequest, ) -> Result { let url = reqwest::Url::parse(request.url.trim()) @@ -10499,11 +10512,19 @@ async fn provider_health_probe( } let timeout = Duration::from_millis(request.timeout_ms.unwrap_or(15_000).clamp(1_000, 120_000)); - let client = reqwest::Client::builder() - .connect_timeout(Duration::from_secs(10)) - .timeout(timeout) - .build() - .map_err(|error| format!("创建健康检测客户端失败: {error}"))?; + let config = gui_config_state.snapshot()?; + let proxy_url = url + .host_str() + .filter(|host| !is_loopback_host(host)) + .map(|_| config.proxy_url.as_str()) + .unwrap_or_default(); + let client = build_http_client_with_proxy( + reqwest::Client::builder() + .connect_timeout(Duration::from_secs(10)) + .timeout(timeout), + proxy_url, + "创建健康检测客户端失败", + )?; let mut headers = reqwest::header::HeaderMap::new(); for (name, value) in request.header { let name = reqwest::header::HeaderName::from_bytes(name.as_bytes()) @@ -10753,14 +10774,18 @@ fn open_external_url(app: tauri::AppHandle, url: String) -> Result<(), String> { #[tauri::command] async fn check_app_update( state: tauri::State<'_, AppUpdateState>, + gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result { - let client = reqwest::Client::builder() - .redirect(release_https_redirect_policy()) - .connect_timeout(Duration::from_secs(8)) - .read_timeout(Duration::from_secs(15)) - .timeout(Duration::from_secs(20)) - .build() - .map_err(|error| format!("创建版本检查客户端失败: {error}"))?; + let proxy_url = gui_config_state.snapshot()?.proxy_url; + let client = build_http_client_with_proxy( + reqwest::Client::builder() + .redirect(release_https_redirect_policy()) + .connect_timeout(Duration::from_secs(8)) + .read_timeout(Duration::from_secs(15)) + .timeout(Duration::from_secs(20)), + &proxy_url, + "创建版本检查客户端失败", + )?; let manifest = fetch_portable_update_manifest(&client).await?; let latest_version = normalize_version(&manifest.version); let current_version = normalize_version(env!("CARGO_PKG_VERSION")); @@ -11170,17 +11195,20 @@ fn cancel_app_update(state: tauri::State<'_, AppUpdateState>) -> Result<(), Stri async fn start_app_update( app: tauri::AppHandle, state: tauri::State<'_, AppUpdateState>, + gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result<(), String> { if !cfg!(windows) { return Err("应用内自动升级当前仅支持 Windows 便携版".to_string()); } + let proxy_url = gui_config_state.snapshot()?.proxy_url; let token = CancellationToken::new(); let pending = state.start(token.clone())?; let task = state.snapshot(); let _ = app.emit(APP_UPDATE_PROGRESS_EVENT, task); let update_app = app.clone(); tauri::async_runtime::spawn(async move { - let outcome = download_and_stage_portable_app_update(&update_app, &pending, &token).await; + let outcome = + download_and_stage_portable_app_update(&update_app, &pending, &token, &proxy_url).await; if let Err(error) = outcome { let state = update_app.state::(); let cancelled = token.is_cancelled(); @@ -11202,10 +11230,11 @@ async fn download_and_stage_portable_app_update( app: &tauri::AppHandle, pending: &PendingAppUpdate, token: &CancellationToken, + proxy_url: &str, ) -> Result<(), String> { #[cfg(not(windows))] { - let _ = (app, pending, token); + let _ = (app, pending, token, proxy_url); Err("应用内自动升级当前仅支持 Windows 便携版".to_string()) } @@ -11225,7 +11254,7 @@ async fn download_and_stage_portable_app_update( .map_err(|error| format!("创建应用更新临时目录失败: {error}"))?; let archive_path = work_dir.join("update.zip"); let result = async { - download_portable_update_archive(app, pending, token, &archive_path).await?; + download_portable_update_archive(app, pending, token, &archive_path, proxy_url).await?; if token.is_cancelled() { return Err("应用更新下载已取消".to_string()); } @@ -11329,14 +11358,17 @@ async fn download_portable_update_archive( pending: &PendingAppUpdate, token: &CancellationToken, destination: &Path, + proxy_url: &str, ) -> Result<(), String> { - let client = reqwest::Client::builder() - .redirect(release_https_redirect_policy()) - .connect_timeout(Duration::from_secs(15)) - .read_timeout(Duration::from_secs(30)) - .timeout(Duration::from_secs(15 * 60)) - .build() - .map_err(|error| format!("创建应用更新下载客户端失败: {error}"))?; + let client = build_http_client_with_proxy( + reqwest::Client::builder() + .redirect(release_https_redirect_policy()) + .connect_timeout(Duration::from_secs(15)) + .read_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(15 * 60)), + proxy_url, + "创建应用更新下载客户端失败", + )?; let urls = std::iter::once(&pending.asset.url) .chain(pending.asset.fallback_urls.iter()) .collect::>(); @@ -11666,9 +11698,12 @@ fn preflight_portable_update_directory(app_dir: &Path) -> Result<(), String> { } #[tauri::command] -async fn check_latest_core() -> Result { +async fn check_latest_core( + gui_config_state: tauri::State<'_, GuiConfigState>, +) -> Result { let platform = current_core_platform()?; - let client = http_client()?; + let proxy_url = gui_config_state.snapshot()?.proxy_url; + let client = http_client(&proxy_url)?; let release = fetch_release(&client, None).await?; let asset = select_release_asset(&release, &platform)?; @@ -11741,11 +11776,14 @@ fn get_core_install_task(state: tauri::State<'_, CoreDownloadState>) -> CoreInst async fn install_core_version( window: tauri::Window, state: tauri::State<'_, CoreDownloadState>, + gui_config_state: tauri::State<'_, GuiConfigState>, version: Option, ) -> Result { + let proxy_url = gui_config_state.snapshot()?.proxy_url; let token = CancellationToken::new(); state.start(token.clone(), version.clone())?; - let result = install_core_version_inner(&window, state.inner(), token, version).await; + let result = + install_core_version_inner(&window, state.inner(), token, version, &proxy_url).await; if result.is_err() { let _ = cleanup_core_work_dirs(); } @@ -11828,9 +11866,10 @@ async fn install_core_version_inner( state: &CoreDownloadState, token: CancellationToken, version: Option, + proxy_url: &str, ) -> Result { let platform = current_core_platform()?; - let client = http_client()?; + let client = http_client(proxy_url)?; state.progress(window, "检查版本", 0, None, true); let release = fetch_release_cancelable(&client, version.as_deref(), &token).await?; let asset = select_release_asset(&release, &platform)?; @@ -12232,14 +12271,40 @@ async fn fetch_release_cancelable( } } -fn http_client() -> Result { - reqwest::Client::builder() - .redirect(release_https_redirect_policy()) - .connect_timeout(Duration::from_secs(15)) - .read_timeout(Duration::from_secs(30)) - .timeout(Duration::from_secs(600)) +pub(crate) fn apply_configured_proxy( + builder: reqwest::ClientBuilder, + proxy_url: &str, +) -> Result { + let proxy_url = proxy_url.trim(); + if proxy_url.is_empty() { + return Ok(builder); + } + let proxy = + reqwest::Proxy::all(proxy_url).map_err(|error| format!("代理 URL 无效: {error}"))?; + Ok(builder.proxy(proxy)) +} + +fn build_http_client_with_proxy( + builder: reqwest::ClientBuilder, + proxy_url: &str, + error_prefix: &str, +) -> Result { + apply_configured_proxy(builder, proxy_url) + .map_err(|error| format!("{error_prefix}: {error}"))? .build() - .map_err(|err| format!("创建 HTTP 客户端失败: {err}")) + .map_err(|error| format!("{error_prefix}: {error}")) +} + +fn http_client(proxy_url: &str) -> Result { + build_http_client_with_proxy( + reqwest::Client::builder() + .redirect(release_https_redirect_policy()) + .connect_timeout(Duration::from_secs(15)) + .read_timeout(Duration::from_secs(30)) + .timeout(Duration::from_secs(600)), + proxy_url, + "创建 HTTP 客户端失败", + ) } fn select_release_asset<'a>( @@ -12579,6 +12644,24 @@ fn configure_background_command(command: &mut Command) { let _ = command; } +fn configure_networked_command(command: &mut Command, proxy_url: &str) { + let proxy_url = proxy_url.trim(); + if proxy_url.is_empty() { + return; + } + + for variable in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "ALL_PROXY", + "http_proxy", + "https_proxy", + "all_proxy", + ] { + command.env(variable, proxy_url); + } +} + fn merge_core_config_for_start( install_dir: &Path, gui_config: &GuiConfigFile, @@ -23571,6 +23654,16 @@ custom_option = "keep-original" assert!(normalize_optional_config_string("bad\nvalue".to_string(), "代理 URL").is_err()); } + #[test] + fn configured_proxy_supports_http_and_socks5_urls() { + for proxy_url in ["http://127.0.0.1:8080", "socks5://127.0.0.1:7890"] { + apply_configured_proxy(reqwest::Client::builder(), proxy_url) + .unwrap() + .build() + .unwrap(); + } + } + #[test] fn core_config_validates_keys_and_routing_strategy() { assert!(validate_core_api_key("sk-valid_123").is_ok()); diff --git a/src-tauri/src/usage.rs b/src-tauri/src/usage.rs index f345d0d..de6a86a 100644 --- a/src-tauri/src/usage.rs +++ b/src-tauri/src/usage.rs @@ -1,6 +1,6 @@ use super::{ - current_core_status, management_authorization, management_endpoint, management_http_client, - CoreProcessState, GuiConfigFile, GuiConfigState, + apply_configured_proxy, current_core_status, management_authorization, management_endpoint, + management_http_client, CoreProcessState, GuiConfigFile, GuiConfigState, }; use chrono::{DateTime, Local}; use rusqlite::{ @@ -1595,28 +1595,23 @@ pub(crate) async fn sync_usage_model_prices( gui_config_state: tauri::State<'_, GuiConfigState>, ) -> Result { let config = gui_config_state.snapshot()?; - let mut client_builder = reqwest::Client::builder() + let client_builder = reqwest::Client::builder() .connect_timeout(Duration::from_secs(10)) .timeout(Duration::from_secs(30)); let proxy_url = config.proxy_url.trim(); - let proxy_valid = if proxy_url.is_empty() { - true - } else if let Ok(proxy) = reqwest::Proxy::all(proxy_url) { - client_builder = client_builder.proxy(proxy); - true + let client = if proxy_url.is_empty() { + client_builder.build().ok() } else { - false + apply_configured_proxy(client_builder, proxy_url) + .ok() + .and_then(|builder| builder.build().ok()) }; - let remote_content = if proxy_valid { - match client_builder.build() { - Ok(client) => match client.get(MODEL_PRICE_SYNC_URL).send().await { - Ok(response) if response.status().is_success() => response.text().await.ok(), - _ => None, - }, - Err(_) => None, - } - } else { - None + let remote_content = match client { + Some(client) => match client.get(MODEL_PRICE_SYNC_URL).send().await { + Ok(response) if response.status().is_success() => response.text().await.ok(), + _ => None, + }, + None => None, }; let now = Local::now().timestamp_millis(); let (remote_prices, used_builtin) = match remote_content diff --git a/src/i18n/ja.ts b/src/i18n/ja.ts index 3d69e62..cca3a25 100644 --- a/src/i18n/ja.ts +++ b/src/i18n/ja.ts @@ -340,7 +340,7 @@ export const jaOverrides = { 'config.network.allowLanHint': 'LAN 内の他のデバイスからこのプロキシへのアクセスを許可します', 'config.network.proxyUrl': 'プロキシ URL', 'config.network.proxyPlaceholder': '例:socks5://127.0.0.1:7890', - 'config.network.proxyHint': 'グローバル上流プロキシ。空欄の場合は使用しません', + 'config.network.proxyHint': 'コアとアプリ本体の外部サービスに使用するグローバルプロキシ。空欄の場合は使用しません', 'config.network.sessionAffinity': 'セッションアフィニティ', 'config.network.sessionAffinityHint': '同じセッションでは以前選択した認証情報を優先します', 'config.network.sessionTtl': 'セッションアフィニティ TTL', diff --git a/src/i18n/resources.ts b/src/i18n/resources.ts index 81d4d2a..f6a8f09 100644 --- a/src/i18n/resources.ts +++ b/src/i18n/resources.ts @@ -350,7 +350,7 @@ export const zhCN = { 'config.network.allowLanHint': '启用后将监听0.0.0.0', 'config.network.proxyUrl': '代理 URL', 'config.network.proxyPlaceholder': '例如 socks5://127.0.0.1:7890', - 'config.network.proxyHint': '全局上游代理;留空表示不使用', + 'config.network.proxyHint': '用于内核和软件本体访问外部服务的全局代理;留空表示不使用', 'config.network.sessionAffinity': '会话粘性路由', 'config.network.sessionAffinityHint': '同一会话优先复用已选择的凭据', 'config.network.sessionTtl': '会话粘性 TTL', @@ -1311,7 +1311,7 @@ export const en: Record = { 'config.network.allowLanHint': 'Allow other devices on the LAN to access this proxy', 'config.network.proxyUrl': 'Proxy URL', 'config.network.proxyPlaceholder': 'For example, socks5://127.0.0.1:7890', - 'config.network.proxyHint': 'Global upstream proxy; leave blank to disable', + 'config.network.proxyHint': 'Global proxy for the core and app external services; leave blank to disable', 'config.network.sessionAffinity': 'Session Affinity', 'config.network.sessionAffinityHint': 'Prefer the previously selected credential for the same session', 'config.network.sessionTtl': 'Session Affinity TTL',