From 8d62c0d521de5822564fb20d97188acb34b26631 Mon Sep 17 00:00:00 2001 From: wonfen Date: Thu, 24 Apr 2025 22:51:45 +0800 Subject: [PATCH] feat: auto-fallback to Clash proxy on scheduled subscription updates; refactor fallback logic and add request timeout --- UPDATELOG.md | 3 + src-tauri/src/config/prfitem.rs | 16 ++++- src-tauri/src/feat/profile.rs | 76 ++++++++++++++++++++--- src/components/profile/profile-item.tsx | 32 ++-------- src/components/profile/profile-viewer.tsx | 19 ++++++ src/pages/_layout.tsx | 12 ++++ src/pages/profiles.tsx | 2 + src/services/types.d.ts | 1 + 8 files changed, 123 insertions(+), 38 deletions(-) diff --git a/UPDATELOG.md b/UPDATELOG.md index 506a6530..11518d6f 100644 --- a/UPDATELOG.md +++ b/UPDATELOG.md @@ -26,12 +26,15 @@ - 移除 Webdav 跨平台备份恢复限制 - 增强 Webdav 备份目录检查和文件上传重试机制 - 系统代理守卫可检查意外设置变更并恢复 + - 定时自动订阅更新也能自动回退使用代理 + - 订阅请求超时机制,防止订阅更新的时候卡死 #### 优化了: - 系统代理 Bypass 设置 - Windows 下使用 Startup 文件夹的方式实现开机自启,解决管理员模式下开机自启的各种问题 - 增加 IP 信息请求重试机制,减少 Network Error 发生的情况 - 切换到规则页面时自动刷新规则数据 + - 重构更新失败回退机制,使用后端完成更新失败后回退到使用 Clash 代理再次尝试更新 ## v2.2.3 diff --git a/src-tauri/src/config/prfitem.rs b/src-tauri/src/config/prfitem.rs index 101d17d2..372cf433 100644 --- a/src-tauri/src/config/prfitem.rs +++ b/src-tauri/src/config/prfitem.rs @@ -89,6 +89,12 @@ pub struct PrfOption { #[serde(skip_serializing_if = "Option::is_none")] pub update_interval: Option, + /// for `remote` profile + /// HTTP request timeout in seconds + /// default is 60 seconds + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_seconds: Option, + /// for `remote` profile /// disable certificate validation /// default is `false` @@ -122,6 +128,7 @@ impl PrfOption { a.rules = b.rules.or(a.rules); a.proxies = b.proxies.or(a.proxies); a.groups = b.groups.or(a.groups); + a.timeout_seconds = b.timeout_seconds.or(a.timeout_seconds); Some(a) } t => t.0.or(t.1), @@ -240,12 +247,19 @@ impl PrfItem { opt_ref.is_some_and(|o| o.danger_accept_invalid_certs.unwrap_or(false)); let user_agent = opt_ref.and_then(|o| o.user_agent.clone()); let update_interval = opt_ref.and_then(|o| o.update_interval); + let timeout = opt_ref.and_then(|o| o.timeout_seconds).unwrap_or(60); let mut merge = opt_ref.and_then(|o| o.merge.clone()); let mut script = opt_ref.and_then(|o| o.script.clone()); let mut rules = opt_ref.and_then(|o| o.rules.clone()); let mut proxies = opt_ref.and_then(|o| o.proxies.clone()); let mut groups = opt_ref.and_then(|o| o.groups.clone()); - let mut builder = reqwest::ClientBuilder::new().use_rustls_tls().no_proxy(); + + // 设置超时时间:连接超时10秒,请求总超时使用配置时间(默认60秒) + let mut builder = reqwest::ClientBuilder::new() + .use_rustls_tls() + .no_proxy() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(timeout)); // 使用软件自己的代理 if self_proxy { diff --git a/src-tauri/src/feat/profile.rs b/src-tauri/src/feat/profile.rs index f03e33fb..0c2d01b5 100644 --- a/src-tauri/src/feat/profile.rs +++ b/src-tauri/src/feat/profile.rs @@ -51,17 +51,73 @@ pub async fn update_profile(uid: String, option: Option) -> Result<() let should_update = match url_opt { Some((url, opt)) => { println!("[订阅更新] 开始下载新的订阅内容"); - let merged_opt = PrfOption::merge(opt, option); - let item = PrfItem::from_url(&url, None, None, merged_opt).await?; + let merged_opt = PrfOption::merge(opt.clone(), option.clone()); - println!("[订阅更新] 更新订阅配置"); - let profiles = Config::profiles(); - let mut profiles = profiles.latest(); - profiles.update_item(uid.clone(), item)?; + // 尝试使用正常设置更新 + match PrfItem::from_url(&url, None, None, merged_opt.clone()).await { + Ok(item) => { + println!("[订阅更新] 更新订阅配置成功"); + let profiles = Config::profiles(); + let mut profiles = profiles.latest(); + profiles.update_item(uid.clone(), item)?; - let is_current = Some(uid.clone()) == profiles.get_current(); - println!("[订阅更新] 是否为当前使用的订阅: {}", is_current); - is_current + let is_current = Some(uid.clone()) == profiles.get_current(); + println!("[订阅更新] 是否为当前使用的订阅: {}", is_current); + is_current + } + Err(err) => { + // 首次更新失败,尝试使用Clash代理 + println!("[订阅更新] 正常更新失败: {},尝试使用Clash代理更新", err); + + // 发送通知 + handle::Handle::notice_message("update_retry_with_clash", uid.clone()); + + // 保存原始代理设置 + let original_with_proxy = merged_opt.as_ref().and_then(|o| o.with_proxy); + let original_self_proxy = merged_opt.as_ref().and_then(|o| o.self_proxy); + + // 创建使用Clash代理的选项 + let mut fallback_opt = merged_opt.unwrap_or_default(); + fallback_opt.with_proxy = Some(false); + fallback_opt.self_proxy = Some(true); + + // 使用Clash代理重试 + match PrfItem::from_url(&url, None, None, Some(fallback_opt)).await { + Ok(mut item) => { + println!("[订阅更新] 使用Clash代理更新成功"); + + // 恢复原始代理设置到item + if let Some(option) = item.option.as_mut() { + option.with_proxy = original_with_proxy; + option.self_proxy = original_self_proxy; + } + + // 更新到配置 + let profiles = Config::profiles(); + let mut profiles = profiles.latest(); + profiles.update_item(uid.clone(), item.clone())?; + + // 获取配置名称用于通知 + let profile_name = item.name.clone().unwrap_or_else(|| uid.clone()); + + // 发送通知告知用户自动更新使用了回退机制 + handle::Handle::notice_message("update_with_clash_proxy", profile_name); + + let is_current = Some(uid.clone()) == profiles.get_current(); + println!("[订阅更新] 是否为当前使用的订阅: {}", is_current); + is_current + } + Err(retry_err) => { + println!("[订阅更新] 使用Clash代理更新仍然失败: {}", retry_err); + handle::Handle::notice_message( + "update_failed_even_with_clash", + format!("{}", retry_err), + ); + return Err(retry_err); + } + } + } + } } None => true, }; @@ -75,7 +131,7 @@ pub async fn update_profile(uid: String, option: Option) -> Result<() } Err(err) => { println!("[订阅更新] 更新失败: {}", err); - handle::Handle::notice_message("set_config::error", format!("{err}")); + handle::Handle::notice_message("update_failed", format!("{err}")); log::error!(target: "app", "{err}"); } } diff --git a/src/components/profile/profile-item.tsx b/src/components/profile/profile-item.tsx index c824902b..d9258ec9 100644 --- a/src/components/profile/profile-item.tsx +++ b/src/components/profile/profile-item.tsx @@ -183,12 +183,6 @@ export const ProfileItem = (props: Props) => { setAnchorEl(null); setLoadingCache((cache) => ({ ...cache, [itemData.uid]: true })); - // 存储原始设置以便回退后恢复 - const originalOptions = { - with_proxy: itemData.option?.with_proxy, - self_proxy: itemData.option?.self_proxy - }; - // 根据类型设置初始更新选项 const option: Partial = {}; if (type === 0) { @@ -205,31 +199,15 @@ export const ProfileItem = (props: Props) => { } try { - // 尝试正常更新 + // 调用后端更新(后端会自动处理回退逻辑) await updateProfile(itemData.uid, option); + + // 更新成功,刷新列表 Notice.success(t("Update subscription successfully")); mutate("getProfiles"); } catch (err: any) { - // 更新失败,尝试使用自身代理 - const errmsg = err?.message || err.toString(); - Notice.info(t("Update failed, retrying with Clash proxy...")); - - try { - await updateProfile(itemData.uid, { - with_proxy: false, - self_proxy: true - }); - - Notice.success(t("Update with Clash proxy successfully")); - - await updateProfile(itemData.uid, originalOptions); - mutate("getProfiles"); - } catch (retryErr: any) { - const retryErrmsg = retryErr?.message || retryErr.toString(); - Notice.error( - `${t("Update failed even with Clash proxy")}: ${retryErrmsg.replace(/error sending request for url (\S+?): /, "")}`, - ); - } + // 更新完全失败(包括后端的回退尝试) + // 不需要做处理,后端会通过事件通知系统发送错误 } finally { setLoadingCache((cache) => ({ ...cache, [itemData.uid]: false })); } diff --git a/src/components/profile/profile-viewer.tsx b/src/components/profile/profile-viewer.tsx index 7fbc870a..7f934fc9 100644 --- a/src/components/profile/profile-viewer.tsx +++ b/src/components/profile/profile-viewer.tsx @@ -262,6 +262,25 @@ export const ProfileViewer = forwardRef( /> )} /> + + ( + {t("seconds")} + ), + }} + /> + )} + /> )} diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index c6b5ea11..ade06a47 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -60,6 +60,18 @@ const handleNoticeMessage = ( case "set_config::error": Notice.error(msg); break; + case "update_with_clash_proxy": + Notice.success(`${t("Update with Clash proxy successfully")} ${msg}`); + break; + case "update_retry_with_clash": + Notice.info(t("Update failed, retrying with Clash proxy...")); + break; + case "update_failed_even_with_clash": + Notice.error(`${t("Update failed even with Clash proxy")}: ${msg}`); + break; + case "update_failed": + Notice.error(msg); + break; case "config_validate::boot_error": Notice.error(`${t("Boot Config Validation Failed")} ${msg}`); break; diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 9b4f3d4e..96303476 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -263,6 +263,8 @@ const ProfilePage = () => { try { await updateProfile(uid); throttleMutate(); + } catch (err: any) { + console.error(`更新订阅 ${uid} 失败:`, err); } finally { setLoadingCache((cache) => ({ ...cache, [uid]: false })); } diff --git a/src/services/types.d.ts b/src/services/types.d.ts index a9a499c9..5b9999e4 100644 --- a/src/services/types.d.ts +++ b/src/services/types.d.ts @@ -207,6 +207,7 @@ interface IProfileOption { with_proxy?: boolean; self_proxy?: boolean; update_interval?: number; + timeout_seconds?: number; danger_accept_invalid_certs?: boolean; merge?: string; script?: string;