From 1291c38d58ba216bafb86e9f1f221c0e3796fc15 Mon Sep 17 00:00:00 2001 From: wonfen Date: Sun, 23 Feb 2025 10:53:09 +0800 Subject: [PATCH] feat: Enhance configuration validation and error handling - Improve config validation process with detailed logging and error tracking - Add more robust error handling in profile updates and config patches - Implement comprehensive config validation using clash core subprocess --- src-tauri/Cargo.toml | 2 +- src-tauri/src/cmds.rs | 104 ++++++++++++++++++++++--- src-tauri/src/core/core.rs | 116 ++++++++++++++++++++++++---- src-tauri/src/core/timer.rs | 13 +++- src-tauri/src/feat.rs | 27 +++++-- src/components/base/base-notice.tsx | 40 ++++++++-- src/locales/zh.json | 4 +- src/pages/_layout.tsx | 9 +++ src/pages/profiles.tsx | 6 +- 9 files changed, 279 insertions(+), 42 deletions(-) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f09b8c1d..52f43f0e 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -31,7 +31,7 @@ serde_json = "1.0" serde_yaml = "0.9" once_cell = "1.19" port_scanner = "0.1.5" -delay_timer = "0.11" +delay_timer = "0.11.6" parking_lot = "0.12" percent-encoding = "2.3.1" window-shadows = { version = "0.2.2" } diff --git a/src-tauri/src/cmds.rs b/src-tauri/src/cmds.rs index 0c87e1d8..7aaf95d0 100644 --- a/src-tauri/src/cmds.rs +++ b/src-tauri/src/cmds.rs @@ -13,6 +13,8 @@ use sysproxy::{Autoproxy, Sysproxy}; type CmdResult = Result; use reqwest_dav::list_cmd::ListFile; use tauri::Manager; +use tauri_plugin_shell::ShellExt; +use std::fs; #[tauri::command] pub fn copy_clash_env() -> CmdResult { @@ -66,29 +68,64 @@ pub async fn delete_profile(index: String) -> CmdResult { Ok(()) } -/// 修改profiles的 +/// 修改profiles的配置 #[tauri::command] -pub async fn patch_profiles_config(profiles: IProfiles) -> CmdResult { +pub async fn patch_profiles_config( + profiles: IProfiles +) -> CmdResult { + println!("[cmd配置patch] 开始修改配置文件"); + + // 保存当前配置,以便在验证失败时恢复 + let current_profile = Config::profiles().latest().current.clone(); + println!("[cmd配置patch] 当前配置: {:?}", current_profile); + + // 更新profiles配置 + println!("[cmd配置patch] 正在更新配置草稿"); wrap_err!({ Config::profiles().draft().patch_config(profiles) })?; + + // 更新配置并进行验证 match CoreManager::global().update_config().await { Ok(_) => { + println!("[cmd配置patch] 配置更新成功"); handle::Handle::refresh_clash(); let _ = tray::Tray::global().update_tooltip(); Config::profiles().apply(); wrap_err!(Config::profiles().data().save_file())?; + + // 发送成功通知 + handle::Handle::notice_message("operation_success", "配置patch成功"); Ok(()) } Err(err) => { + println!("[cmd配置patch] 更新配置失败: {}", err); Config::profiles().discard(); - log::error!(target: "app", "{err}"); - Err(format!("{err}")) + println!("[cmd配置patch] 错误详情: {}", err); + + // 如果验证失败,恢复到之前的配置 + if let Some(prev_profile) = current_profile { + println!("[cmd配置patch] 尝试恢复到之前的配置: {}", prev_profile); + let restore_profiles = IProfiles { + current: Some(prev_profile), + items: None, + }; + // 静默恢复,不触发验证 + wrap_err!({ Config::profiles().draft().patch_config(restore_profiles) })?; + Config::profiles().apply(); + wrap_err!(Config::profiles().data().save_file())?; + println!("[cmd配置patch] 成功恢复到之前的配置"); + } + + Err(err.to_string()) } } } /// 根据profile name修改profiles #[tauri::command] -pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> CmdResult { +pub async fn patch_profiles_config_by_profile_index( + _app_handle: tauri::AppHandle, + profile_index: String +) -> CmdResult { let profiles = IProfiles{current: Some(profile_index), items: None}; patch_profiles_config(profiles).await } @@ -97,7 +134,7 @@ pub async fn patch_profiles_config_by_profile_index(profile_index: String) -> Cm #[tauri::command] pub fn patch_profile(index: String, profile: PrfItem) -> CmdResult { wrap_err!(Config::profiles().data().patch_item(index, profile))?; - wrap_err!(timer::Timer::global().refresh()) + Ok(()) } #[tauri::command] @@ -125,17 +162,60 @@ pub fn read_profile_file(index: String) -> CmdResult { let data = wrap_err!(item.read_file())?; Ok(data) } - +/// 保存profiles的配置 #[tauri::command] -pub fn save_profile_file(index: String, file_data: Option) -> CmdResult { +pub async fn save_profile_file(index: String, file_data: Option) -> CmdResult { if file_data.is_none() { return Ok(()); } - let profiles = Config::profiles(); - let profiles = profiles.latest(); - let item = wrap_err!(profiles.get_item(&index))?; - wrap_err!(item.save_file(file_data.unwrap())) + // 在异步操作前完成所有文件操作 + let (file_path, original_content) = { + let profiles = Config::profiles(); + let profiles_guard = profiles.latest(); + let item = wrap_err!(profiles_guard.get_item(&index))?; + let content = wrap_err!(item.read_file())?; + let path = item.file.clone().ok_or("file field is null")?; + let profiles_dir = wrap_err!(dirs::app_profiles_dir())?; + (profiles_dir.join(path), content) + }; + + // 保存新的配置文件 + wrap_err!(fs::write(&file_path, file_data.clone().unwrap()))?; + + // 直接验证保存的配置文件 + let clash_core = Config::verge().latest().clash_core.clone().unwrap_or("verge-mihomo".into()); + let test_dir = wrap_err!(dirs::app_home_dir())?.join("test"); + let test_dir = test_dir.to_string_lossy(); + let file_path_str = file_path.to_string_lossy(); + + println!("[cmd配置save] 开始验证配置文件: {}", file_path_str); + + let app_handle = handle::Handle::global().app_handle().unwrap(); + let output = wrap_err!(app_handle + .shell() + .sidecar(clash_core) + .map_err(|e| e.to_string())? + .args(["-t", "-d", test_dir.as_ref(), "-f", file_path_str.as_ref()]) + .output() + .await)?; + + let stderr = String::from_utf8_lossy(&output.stderr); + let error_keywords = ["FATA", "fatal", "Parse config error", "level=fatal"]; + let has_error = !output.status.success() || error_keywords.iter().any(|&kw| stderr.contains(kw)); + + if has_error { + println!("[cmd配置save] 编辑验证失败 {}", stderr); + // 恢复原始配置文件 + wrap_err!(fs::write(&file_path, original_content))?; + // 发送错误通知 + handle::Handle::notice_message("config_validate::error", &*stderr); + return Err(format!("cmd配置save失败 {}", stderr)); + } + + println!("[cmd配置save] 验证成功"); + handle::Handle::notice_message("operation_success", "配置更新成功"); + Ok(()) } #[tauri::command] diff --git a/src-tauri/src/core/core.rs b/src-tauri/src/core/core.rs index 07ffca8d..10f35fe6 100644 --- a/src-tauri/src/core/core.rs +++ b/src-tauri/src/core/core.rs @@ -145,29 +145,119 @@ impl CoreManager { } } - /// 更新proxies那些 - /// 如果涉及端口和外部控制则需要重启 + /// 使用子进程验证配置 + pub async fn validate_config(&self) -> Result<(bool, String)> { + println!("[core配置验证] 开始验证配置"); + + let config_path = Config::generate_file(ConfigType::Check)?; + let config_path = dirs::path_to_str(&config_path)?; + println!("[core配置验证] 配置文件路径: {}", config_path); + + let clash_core = { Config::verge().latest().clash_core.clone() }; + let clash_core = clash_core.unwrap_or("verge-mihomo".into()); + println!("[core配置验证] 使用内核: {}", clash_core); + + let app_handle = handle::Handle::global().app_handle().unwrap(); + let test_dir = dirs::app_home_dir()?.join("test"); + let test_dir = dirs::path_to_str(&test_dir)?; + println!("[core配置验证] 测试目录: {}", test_dir); + + // 使用子进程运行clash验证配置 + println!("[core配置验证] 运行子进程验证配置"); + let output = app_handle + .shell() + .sidecar(clash_core)? + .args(["-t", "-d", test_dir, "-f", config_path]) + .output() + .await?; + + let stderr = String::from_utf8_lossy(&output.stderr); + let stdout = String::from_utf8_lossy(&output.stdout); + + // 检查进程退出状态和错误输出 + let error_keywords = ["FATA", "fatal", "Parse config error", "level=fatal"]; + let has_error = !output.status.success() || error_keywords.iter().any(|&kw| stderr.contains(kw)); + + println!("[core配置验证] 退出状态: {:?}", output.status); + if !stderr.is_empty() { + println!("[core配置验证] 错误输出: {}", stderr); + } + if !stdout.is_empty() { + println!("[core配置验证] 标准输出: {}", stdout); + } + + if has_error { + let error_msg = if stderr.is_empty() { + if let Some(code) = output.status.code() { + handle::Handle::notice_message("config_validate::error", &code.to_string()); + String::new() + } else { + handle::Handle::notice_message("config_validate::process_terminated", ""); + String::new() + } + } else { + handle::Handle::notice_message("config_validate::stderr_error", &*stderr); + String::new() + }; + Ok((false, error_msg)) + } else { + handle::Handle::notice_message("config_validate::success", ""); + Ok((true, String::new())) + } + } + + /// 更新proxies等配置 pub async fn update_config(&self) -> Result<()> { - log::debug!(target: "app", "try to update clash config"); - // 更新订阅 + println!("[core配置更新] 开始更新配置"); + + // 1. 先生成新的配置内容 + println!("[core配置更新] 生成新的配置内容"); Config::generate().await?; + + // 2. 生成临时文件并进行验证 + println!("[core配置更新] 生成临时配置文件用于验证"); + let temp_config = Config::generate_file(ConfigType::Check)?; + let temp_config = dirs::path_to_str(&temp_config)?; + println!("[core配置更新] 临时配置文件路径: {}", temp_config); - // 检查订阅是否正常 - self.check_config().await?; + // 3. 验证配置 + let (is_valid, error_msg) = match self.validate_config().await { + Ok((valid, msg)) => (valid, msg), + Err(e) => { + println!("[core配置更新] 验证过程发生错误: {}", e); + Config::runtime().discard(); // 验证失败时丢弃新配置 + return Err(e); + } + }; - // 更新运行时订阅 - let path = Config::generate_file(ConfigType::Run)?; - let path = dirs::path_to_str(&path)?; + if !is_valid { + println!("[core配置更新] 配置验证未通过,保持当前配置不变"); + Config::runtime().discard(); // 验证失败时丢弃新配置 + return Err(anyhow::anyhow!(error_msg)); + } - // 发送请求 发送5次 + // 4. 验证通过后,生成正式的运行时配置 + println!("[core配置更新] 验证通过,生成运行时配置"); + let run_path = Config::generate_file(ConfigType::Run)?; + let run_path = dirs::path_to_str(&run_path)?; + + // 5. 应用新配置 + println!("[core配置更新] 应用新配置"); for i in 0..10 { - match clash_api::put_configs(path).await { - Ok(_) => break, + match clash_api::put_configs(run_path).await { + Ok(_) => { + println!("[core配置更新] 配置应用成功"); + Config::runtime().apply(); // 应用成功时保存新配置 + break; + } Err(err) => { if i < 9 { + println!("[core配置更新] 第{}次重试应用配置", i + 1); log::info!(target: "app", "{err}"); } else { - bail!(err); + println!("[core配置更新] 配置应用失败: {}", err); + Config::runtime().discard(); // 应用失败时丢弃新配置 + return Err(err.into()); } } } diff --git a/src-tauri/src/core/timer.rs b/src-tauri/src/core/timer.rs index dd45da7f..2ce302cb 100644 --- a/src-tauri/src/core/timer.rs +++ b/src-tauri/src/core/timer.rs @@ -1,5 +1,6 @@ use crate::config::Config; use crate::feat; +use crate::core::CoreManager; use anyhow::{Context, Result}; use delay_timer::prelude::{DelayTimer, DelayTimerBuilder, TaskBuilder}; use once_cell::sync::OnceCell; @@ -172,7 +173,17 @@ impl Timer { /// the task runner async fn async_task(uid: String) { log::info!(target: "app", "running timer task `{uid}`"); - crate::log_err!(feat::update_profile(uid, None).await); + + // 使用更轻量级的更新方式 + if let Err(e) = feat::update_profile(uid.clone(), None).await { + log::error!(target: "app", "timer task update error: {}", e); + return; + } + + // 只有更新成功后才刷新配置 + if let Err(e) = CoreManager::global().update_config().await { + log::error!(target: "app", "timer task refresh error: {}", e); + } } } diff --git a/src-tauri/src/feat.rs b/src-tauri/src/feat.rs index f924b08f..fb82f968 100644 --- a/src-tauri/src/feat.rs +++ b/src-tauri/src/feat.rs @@ -133,12 +133,14 @@ pub fn toggle_system_proxy() { // 切换代理文件 pub fn toggle_proxy_profile(profile_index: String) { tauri::async_runtime::spawn(async move { - match cmds::patch_profiles_config_by_profile_index(profile_index).await { + let app_handle = handle::Handle::global().app_handle().unwrap(); + match cmds::patch_profiles_config_by_profile_index(app_handle, profile_index).await { Ok(_) => { let _ = tray::Tray::global().update_menu(); - handle::Handle::refresh_verge(); - }, - Err(err) => log::error!(target: "app", "{err}"), + } + Err(err) => { + log::error!(target: "app", "{err}"); + } } }); } @@ -379,6 +381,8 @@ pub async fn patch_verge(patch: IVerge) -> Result<()> { /// 更新某个profile /// 如果更新当前订阅就激活订阅 pub async fn update_profile(uid: String, option: Option) -> Result<()> { + println!("[订阅更新] 开始更新订阅 {}", uid); + let url_opt = { let profiles = Config::profiles(); let profiles = profiles.latest(); @@ -386,33 +390,44 @@ pub async fn update_profile(uid: String, option: Option) -> Result<() let is_remote = item.itype.as_ref().map_or(false, |s| s == "remote"); if !is_remote { - None // 直接更新 + println!("[订阅更新] {} 不是远程订阅,跳过更新", uid); + None // 非远程订阅直接更新 } else if item.url.is_none() { + println!("[订阅更新] {} 缺少URL,无法更新", uid); bail!("failed to get the profile item url"); } else { + println!("[订阅更新] {} 是远程订阅,URL: {}", uid, item.url.clone().unwrap()); Some((item.url.clone().unwrap(), item.option.clone())) } }; 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?; + + println!("[订阅更新] 更新订阅配置"); let profiles = Config::profiles(); let mut profiles = profiles.latest(); profiles.update_item(uid.clone(), item)?; - Some(uid) == profiles.get_current() + let is_current = Some(uid.clone()) == profiles.get_current(); + println!("[订阅更新] 是否为当前使用的订阅: {}", is_current); + is_current } None => true, }; if should_update { + println!("[订阅更新] 更新内核配置"); match CoreManager::global().update_config().await { Ok(_) => { + println!("[订阅更新] 更新成功"); handle::Handle::refresh_clash(); } Err(err) => { + println!("[订阅更新] 更新失败: {}", err); handle::Handle::notice_message("set_config::error", format!("{err}")); log::error!(target: "app", "{err}"); } diff --git a/src/components/base/base-notice.tsx b/src/components/base/base-notice.tsx index dfc96e0d..8e12e251 100644 --- a/src/components/base/base-notice.tsx +++ b/src/components/base/base-notice.tsx @@ -43,7 +43,7 @@ const NoticeInner = (props: InnerProps) => { appWindow.theme().then((m) => m && setIsDark(m === "dark")); const unlisten = appWindow.onThemeChanged((e) => - setIsDark(e.payload === "dark") + setIsDark(e.payload === "dark"), ); return () => { @@ -105,25 +105,55 @@ let parent: HTMLDivElement = null!; // @ts-ignore export const Notice: NoticeInstance = (props) => { + const { type, message, duration } = props; + + // 验证必要的参数 + if (!message) { + return; + } + if (!parent) { parent = document.createElement("div"); + parent.setAttribute("id", "notice-container"); // 添加 id 便于调试 document.body.appendChild(parent); } const container = document.createElement("div"); parent.appendChild(container); + const root = createRoot(container); const onUnmount = () => { root.unmount(); - if (parent) setTimeout(() => parent.removeChild(container), 500); + if (parent && container.parentNode === parent) { + setTimeout(() => { + parent.removeChild(container); + }, 500); + } }; - root.render(); + root.render( + , + ); }; (["info", "error", "success"] as const).forEach((type) => { - Notice[type] = (message, duration) => { - setTimeout(() => Notice({ type, message, duration }), 0); + Notice[type] = (message: ReactNode, duration?: number) => { + // 确保消息不为空 + if (!message) { + return; + } + + // 直接调用,不使用 setTimeout + Notice({ + type, + message, + duration: duration || 1500, // 确保有默认值 + }); }; }); diff --git a/src/locales/zh.json b/src/locales/zh.json index b6478ca1..11b012fc 100644 --- a/src/locales/zh.json +++ b/src/locales/zh.json @@ -435,5 +435,7 @@ "Direct Mode": "直连模式", "Enable Tray Speed": "启用托盘速率", "Lite Mode": "轻量模式", - "Lite Mode Info": "关闭GUI界面,仅保留内核运行" + "Lite Mode Info": "关闭GUI界面,仅保留内核运行", + "Config Validation Failed": "订阅配置校验失败,请检查配置文件", + "Config Validation Process Terminated": "验证进程被终止" } diff --git a/src/pages/_layout.tsx b/src/pages/_layout.tsx index 0c23787e..ece3aeeb 100644 --- a/src/pages/_layout.tsx +++ b/src/pages/_layout.tsx @@ -81,6 +81,15 @@ const Layout = () => { case "set_config::error": Notice.error(msg); break; + case "config_validate::error": + Notice.error(t("Config Validation Failed")); + break; + case "config_validate::process_terminated": + Notice.error(t("Config Validation Process Terminated")); + break; + case "config_validate::stderr_error": + Notice.error(msg); + break; default: break; } diff --git a/src/pages/profiles.tsx b/src/pages/profiles.tsx index 6fcb883e..4fcf3cd9 100644 --- a/src/pages/profiles.tsx +++ b/src/pages/profiles.tsx @@ -27,7 +27,7 @@ import { useTranslation } from "react-i18next"; import { importProfile, enhanceProfiles, - restartCore, + //restartCore, getRuntimeLogs, deleteProfile, updateProfile, @@ -400,8 +400,8 @@ const ProfilePage = () => { onSave={async (prev, curr) => { if (prev !== curr && profiles.current === item.uid) { await onEnhance(false); - await restartCore(); - Notice.success(t("Clash Core Restarted"), 1000); + // await restartCore(); + // Notice.success(t("Clash Core Restarted"), 1000); } }} onDelete={() => onDelete(item.uid)}