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
This commit is contained in:
wonfen 2025-02-23 10:53:09 +08:00
parent 16caccde51
commit 1291c38d58
9 changed files with 279 additions and 42 deletions

View File

@ -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" }

View File

@ -13,6 +13,8 @@ use sysproxy::{Autoproxy, Sysproxy};
type CmdResult<T = ()> = Result<T, String>;
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<String> {
let data = wrap_err!(item.read_file())?;
Ok(data)
}
/// 保存profiles的配置
#[tauri::command]
pub fn save_profile_file(index: String, file_data: Option<String>) -> CmdResult {
pub async fn save_profile_file(index: String, file_data: Option<String>) -> 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]

View File

@ -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?;
// 检查订阅是否正常
self.check_config().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);
// 更新运行时订阅
let path = Config::generate_file(ConfigType::Run)?;
let path = dirs::path_to_str(&path)?;
// 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);
}
};
// 发送请求 发送5次
if !is_valid {
println!("[core配置更新] 配置验证未通过,保持当前配置不变");
Config::runtime().discard(); // 验证失败时丢弃新配置
return Err(anyhow::anyhow!(error_msg));
}
// 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());
}
}
}

View File

@ -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);
}
}
}

View File

@ -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<PrfOption>) -> 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<PrfOption>) -> 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}");
}

View File

@ -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(<NoticeInner {...props} onClose={onUnmount} />);
root.render(
<NoticeInner
type={type}
message={message}
duration={duration || 1500}
onClose={onUnmount}
/>,
);
};
(["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, // 确保有默认值
});
};
});

View File

@ -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": "验证进程被终止"
}

View File

@ -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;
}

View File

@ -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)}