Files
clash-verge-rev/src-tauri/src/utils/init.rs
T
Tunglies 2f370d6490 refactor(log): in-place writer lifecycle and span-based core observability (#7889)
* refactor(log): migrate logging to tracing-estuary pipeline

Swap flexi_logger + clash-verge-logger wiring for the tracing-estuary
crate: buffered file sink with explicit panic/exit drains, live level
switching, and tokio-trace/chrome-trace behind crate features.

AsyncLogger moves in-house (VecDeque ring); file format and rotation
stay unchanged.

* fix(log): reset sidecar writer in place and re-pin service-ipc v2.6.7

Rebuilding the sidecar FileLogWriter on config changes leaked flexi's
unstoppable flusher thread plus an fd each time; the writer is now flushed
and reset in place, matching what the service crate does.
get_clash_logs_by_service drops the CompactString conversion layer now that
service-ipc v2.6.7 returns plain Vec<String>, and AsyncLogger is renamed
LogRing to match.

* refactor(log): span-based observability and log noise cleanup

Instrument spans carry the structure flat lines never could: the core-start
spine (mode, readiness/owner generations, pid, config file), proxy apply and
clear routes with guard generations, service IPC sessions with refusal codes
and outcomes, profile-switch terminal outcomes, subscription fetch strategies,
core upgrades with per-proxy version probes, backup flows, timer tasks, config
generation uids, and the startup setup flow; running-mode changes emit one
guarded transition event each.
~90 hand-written noise lines are gone or demoted (entry+success pairs, polled
getter echoes, phase brackets, window sub-steps, platform install chatter);
errors keep their anyhow chain, operation name, and item id, and swallowed
retry results surface per attempt.
2026-09-08 05:51:04 +08:00

648 lines
21 KiB
Rust

use crate::{
config::{Config, IClashTemp, IProfiles, IVerge},
constants,
core::handle,
logging,
process::AsyncHandler,
utils::{
dirs::{self, PathBufExec as _},
help,
},
};
use anyhow::Result;
use chrono::{Local, TimeZone as _};
use clash_verge_logging::Type;
#[cfg(any(target_os = "macos", target_os = "windows"))]
use std::path::Path;
use std::{path::PathBuf, str::FromStr as _};
use tauri_plugin_shell::ShellExt as _;
use tokio::fs;
use tokio::fs::DirEntry;
#[cfg(target_os = "windows")]
async fn delete_snapshot_logs(log_dir: &Path) -> Result<()> {
let temp_dirs = [
log_dir.join("temp"),
log_dir.join("service").join("temp"),
log_dir.join("sidecar").join("temp"),
];
for temp_dir in temp_dirs.iter().filter(|d| d.exists()) {
let mut entries = fs::read_dir(temp_dir).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("log") {
let _ = path.remove_if_exists().await;
logging!(debug, Type::Setup, "delete snapshot log file: {}", path.display());
}
}
}
Ok(())
}
// TODO flexi_logger 提供了最大保留天数,或许我们应该用内置删除log文件
pub async fn delete_log() -> Result<()> {
let log_dir = dirs::app_logs_dir()?;
let service_log_dir = dirs::service_log_dir()?;
if !log_dir.exists() && !service_log_dir.exists() {
return Ok(());
}
#[cfg(target_os = "windows")]
if log_dir.exists() {
delete_snapshot_logs(&log_dir).await?;
}
let auto_log_clean = {
let verge = Config::verge().await;
let verge = verge.data_arc();
verge.auto_log_clean.unwrap_or(0)
};
let day = match auto_log_clean {
1 => 1,
2 => 7,
3 => 30,
4 => 90,
_ => return Ok(()),
};
logging!(debug, Type::Setup, "try to delete log files, day: {}", day);
let parse_time_str = |s: &str| {
let sa: Vec<&str> = s.split('-').collect();
if sa.len() != 4 {
return Err(anyhow::anyhow!("invalid time str"));
}
let year = i32::from_str(sa[0])?;
let month = u32::from_str(sa[1])?;
let day = u32::from_str(sa[2])?;
let time = chrono::NaiveDate::from_ymd_opt(year, month, day)
.ok_or_else(|| anyhow::anyhow!("invalid time str"))?
.and_hms_opt(0, 0, 0)
.ok_or_else(|| anyhow::anyhow!("invalid time str"))?;
Ok(time)
};
let process_file = async move |file: DirEntry| -> Result<()> {
let file_name = file.file_name();
let file_name = file_name.to_str().unwrap_or_default();
if file_name.ends_with(".log") {
let now = Local::now();
let created_time = parse_time_str(&file_name[0..file_name.len() - 4])?;
let file_time = Local
.from_local_datetime(&created_time)
.single()
.ok_or_else(|| anyhow::anyhow!("invalid local datetime"))?;
let duration = now.signed_duration_since(file_time);
if duration.num_days() > day {
let _ = file.path().remove_if_exists().await;
logging!(debug, Type::Setup, "delete log file: {}", file_name);
}
}
Ok(())
};
if log_dir.exists() {
let mut log_read_dir = fs::read_dir(&log_dir).await?;
while let Some(entry) = log_read_dir.next_entry().await? {
std::mem::drop(process_file(entry).await);
}
}
if service_log_dir.exists() {
let mut service_log_read_dir = fs::read_dir(service_log_dir).await?;
while let Some(entry) = service_log_read_dir.next_entry().await? {
std::mem::drop(process_file(entry).await);
}
}
Ok(())
}
#[cfg(target_os = "macos")]
async fn is_logs_dir_writable(log_dir: &Path) -> bool {
if !log_dir.is_dir() {
logging!(warn, Type::Setup, "macOS logs path is not a directory: {:?}", log_dir);
return false;
}
let probe_path = log_dir.join(format!(
".clash-verge-write-test-{}-{}",
std::process::id(),
Local::now().timestamp_nanos_opt().unwrap_or_default()
));
match fs::OpenOptions::new()
.create_new(true)
.write(true)
.open(&probe_path)
.await
{
Ok(_) => {
if let Err(e) = fs::remove_file(&probe_path).await {
logging!(
warn,
Type::Setup,
"failed to remove macOS logs write probe {:?}: {}",
probe_path,
e
);
}
true
}
Err(e) => {
logging!(
warn,
Type::Setup,
"macOS logs directory is not writable {:?}: {}",
log_dir,
e
);
false
}
}
}
#[cfg(target_os = "macos")]
fn available_legacy_path(parent: &Path, prefix: &str) -> Result<PathBuf> {
let timestamp = Local::now().format("%Y%m%d%H%M%S");
let base_name = format!("{prefix}-{timestamp}");
let candidate = parent.join(&base_name);
if !candidate.exists() {
return Ok(candidate);
}
for index in 1..100 {
let candidate = parent.join(format!("{base_name}-{index}"));
if !candidate.exists() {
return Ok(candidate);
}
}
Err(anyhow::anyhow!(
"failed to allocate legacy path under {:?} with prefix {}",
parent,
prefix
))
}
#[cfg(target_os = "macos")]
async fn migrate_legacy_macos_service_logs(log_dir: &Path) -> Result<()> {
let legacy_service_dir = log_dir.join("service");
if !legacy_service_dir.exists() {
return Ok(());
}
let service_logs_root = dirs::service_logs_root_dir()?;
fs::create_dir_all(&service_logs_root)
.await
.map_err(|e| anyhow::anyhow!("Failed to create service logs root {:?}: {}", service_logs_root, e))?;
let archived_service_dir = available_legacy_path(&service_logs_root, "service.legacy")?;
fs::rename(&legacy_service_dir, &archived_service_dir)
.await
.map_err(|e| {
anyhow::anyhow!(
"Failed to archive legacy macOS service logs {:?} to {:?}: {}",
legacy_service_dir,
archived_service_dir,
e
)
})?;
logging!(
info,
Type::Setup,
"Archived legacy macOS service logs: {:?} -> {:?}",
legacy_service_dir,
archived_service_dir
);
Ok(())
}
/// Raise existing sub-floor update intervals, once per install.
///
/// One-off repair, not a standing rule — the UI warns about shorter intervals but still saves
/// them, so the marker is written even when nothing needed raising.
pub async fn migrate_short_update_intervals() -> Result<()> {
let marker = dirs::update_interval_migrated_path()?;
if fs::try_exists(&marker).await? {
return Ok(());
}
// Same order as every other writer holding it (lock, then draft permit): save_file runs
// inside the closure, ahead of the optimistic check, so a racing restore can't be undone.
let _profile_write = crate::config::profiles::PROFILE_WRITE_LOCK.lock().await;
let min = constants::profile::MIN_UPDATE_INTERVAL;
let raised = Config::profiles()
.await
.with_data_modify(|mut profiles: IProfiles| async move {
// `IProfiles::new` normalises items to Some when it read the file, and returns a
// bare default() when it failed. A failed load also raises zero — without this the
// marker would burn the one shot on profiles nobody managed to load.
if profiles.items.is_none() {
anyhow::bail!("profiles.yaml was not loaded; refusing to record the migration as done");
}
let raised = profiles.raise_short_update_intervals(min);
if raised > 0 {
profiles.save_file().await?;
}
Ok((profiles, raised))
})
.await?;
if raised > 0 {
logging!(
info,
Type::Setup,
"已将 {} 个订阅的自动更新间隔提升到 {} 分钟",
raised,
min
);
}
fs::write(&marker, b"").await?;
Ok(())
}
#[cfg(target_os = "macos")]
async fn migrate_legacy_macos_logs() -> Result<()> {
let log_dir = dirs::app_logs_dir()?;
if !log_dir.exists() {
return Ok(());
}
if is_logs_dir_writable(&log_dir).await {
if let Err(e) = migrate_legacy_macos_service_logs(&log_dir).await {
logging!(warn, Type::Setup, "Failed to migrate legacy macOS service logs: {e:#}");
}
return Ok(());
}
let app_home = dirs::app_home_dir()?;
let archived_log_dir = available_legacy_path(&app_home, "logs.legacy-root")?;
fs::rename(&log_dir, &archived_log_dir).await.map_err(|e| {
anyhow::anyhow!(
"Failed to archive unwritable macOS logs directory {:?} to {:?}: {}",
log_dir,
archived_log_dir,
e
)
})?;
logging!(
warn,
Type::Setup,
"Archived unwritable macOS logs directory: {:?} -> {:?}",
log_dir,
archived_log_dir
);
fs::create_dir_all(&log_dir)
.await
.map_err(|e| anyhow::anyhow!("Failed to recreate macOS logs directory {:?}: {}", log_dir, e))?;
logging!(info, Type::Setup, "Recreated macOS logs directory: {:?}", log_dir);
Ok(())
}
pub(super) async fn init_dns_config() -> Result<()> {
use serde_yaml_ng::Value;
let dns_config = serde_yaml_ng::Mapping::from_iter([
("enable".into(), Value::Bool(true)),
// Must match the frontend default used for authoritative `dns.ipv6`.
("ipv6".into(), Value::Bool(true)),
("listen".into(), Value::String(":53".into())),
("enhanced-mode".into(), Value::String("fake-ip".into())),
("fake-ip-range".into(), Value::String("198.18.0.1/16".into())),
("fake-ip-range6".into(), Value::String("fdfe:dcba:9876::1/64".into())),
("fake-ip-filter-mode".into(), Value::String("blacklist".into())),
("prefer-h3".into(), Value::Bool(false)),
("respect-rules".into(), Value::Bool(false)),
("use-hosts".into(), Value::Bool(false)),
("use-system-hosts".into(), Value::Bool(false)),
(
"fake-ip-filter".into(),
Value::Sequence(vec![
Value::String("*.lan".into()),
Value::String("*.local".into()),
Value::String("*.arpa".into()),
Value::String("time.*.com".into()),
Value::String("ntp.*.com".into()),
Value::String("time.*.com".into()),
Value::String("+.market.xiaomi.com".into()),
Value::String("localhost.ptlogin2.qq.com".into()),
Value::String("*.msftncsi.com".into()),
Value::String("www.msftconnecttest.com".into()),
]),
),
(
"default-nameserver".into(),
Value::Sequence(vec![
Value::String("system".into()),
Value::String("223.6.6.6".into()),
Value::String("8.8.8.8".into()),
Value::String("2400:3200::1".into()),
Value::String("2001:4860:4860::8888".into()),
]),
),
(
"nameserver".into(),
Value::Sequence(vec![
Value::String("8.8.8.8".into()),
Value::String("https://doh.pub/dns-query".into()),
Value::String("https://dns.alidns.com/dns-query".into()),
]),
),
("fallback".into(), Value::Sequence(vec![])),
(
"nameserver-policy".into(),
Value::Mapping(serde_yaml_ng::Mapping::new()),
),
(
"proxy-server-nameserver".into(),
Value::Sequence(vec![
Value::String("https://doh.pub/dns-query".into()),
Value::String("https://dns.alidns.com/dns-query".into()),
Value::String("tls://223.5.5.5".into()),
]),
),
("direct-nameserver".into(), Value::Sequence(vec![])),
("direct-nameserver-follow-policy".into(), Value::Bool(false)),
(
"fallback-filter".into(),
Value::Mapping(serde_yaml_ng::Mapping::from_iter([
("geoip".into(), Value::Bool(true)),
("geoip-code".into(), Value::String("CN".into())),
(
"ipcidr".into(),
Value::Sequence(vec![
Value::String("240.0.0.0/4".into()),
Value::String("0.0.0.0/32".into()),
]),
),
(
"domain".into(),
Value::Sequence(vec![
Value::String("+.google.com".into()),
Value::String("+.facebook.com".into()),
Value::String("+.youtube.com".into()),
]),
),
])),
),
]);
let default_dns_config = serde_yaml_ng::Mapping::from_iter([
("dns".into(), Value::Mapping(dns_config)),
("hosts".into(), Value::Mapping(serde_yaml_ng::Mapping::new())),
]);
let app_dir = dirs::app_home_dir()?;
let dns_path = app_dir.join(constants::files::DNS_CONFIG);
if !dns_path.exists() {
logging!(info, Type::Setup, "Creating default DNS config file");
help::save_yaml(&dns_path, &default_dns_config, Some("# Clash Verge DNS Config")).await?;
}
Ok(())
}
async fn ensure_directories() -> Result<()> {
let directories = [
("app_home", dirs::app_home_dir()?),
("app_profiles", dirs::app_profiles_dir()?),
("app_logs", dirs::app_logs_dir()?),
("service_logs", dirs::service_log_dir()?),
];
for (name, dir) in directories {
if !dir.exists() {
fs::create_dir_all(&dir)
.await
.map_err(|e| anyhow::anyhow!("Failed to create {} directory {:?}: {}", name, dir, e))?;
logging!(info, Type::Setup, "Created {} directory: {:?}", name, dir);
}
}
Ok(())
}
async fn initialize_config_files() -> Result<()> {
if let Ok(path) = dirs::clash_path()
&& !path.exists()
{
let template = IClashTemp::template().0;
help::save_yaml(&path, &template, Some("# Clash Verge"))
.await
.map_err(|e| anyhow::anyhow!("Failed to create clash config: {}", e))?;
logging!(info, Type::Setup, "Created clash config at {:?}", path);
}
if let Ok(path) = dirs::verge_path()
&& !path.exists()
{
let template = IVerge::template();
help::save_yaml(&path, &template, Some("# Clash Verge"))
.await
.map_err(|e| anyhow::anyhow!("Failed to create verge config: {}", e))?;
logging!(info, Type::Setup, "Created verge config at {:?}", path);
}
if let Ok(path) = dirs::profiles_path()
&& !path.exists()
{
let template = IProfiles::default();
help::save_yaml(&path, &template, Some("# Clash Verge"))
.await
.map_err(|e| anyhow::anyhow!("Failed to create profiles config: {}", e))?;
logging!(info, Type::Setup, "Created profiles config at {:?}", path);
}
IVerge::validate_and_fix_config()
.await
.map_err(|e| anyhow::anyhow!("Failed to validate verge config: {}", e))?;
Ok(())
}
/// Initializes configuration required before Tauri setup.
pub async fn init_config() -> Result<()> {
#[cfg(target_os = "macos")]
migrate_legacy_macos_logs().await?;
ensure_directories().await?;
initialize_config_files().await?;
AsyncHandler::spawn(|| async {
if let Err(e) = delete_log().await {
logging!(warn, Type::Setup, "Failed to clean old logs: {e:#}");
}
logging!(debug, Type::Setup, "后台日志清理任务完成");
});
Ok(())
}
/// Initializes application resources after Tauri setup.
pub async fn init_resources() -> Result<()> {
let app_dir = dirs::app_home_dir()?;
let res_dir = dirs::app_resources_dir()?;
if !app_dir.exists() {
std::mem::drop(fs::create_dir_all(&app_dir).await);
}
if !res_dir.exists() {
std::mem::drop(fs::create_dir_all(&res_dir).await);
}
let file_list = ["Country.mmdb", "geoip.dat", "geosite.dat"];
for file in file_list.iter() {
let src_path = res_dir.join(file);
let dest_path = app_dir.join(file);
if src_path.exists() && !dest_path.exists() {
handle_copy(&src_path, &dest_path, file).await;
continue;
}
let src_modified = fs::metadata(&src_path).await.and_then(|m| m.modified());
let dest_modified = fs::metadata(&dest_path).await.and_then(|m| m.modified());
match (src_modified, dest_modified) {
(Ok(src_modified), Ok(dest_modified)) => {
if src_modified > dest_modified {
handle_copy(&src_path, &dest_path, file).await;
}
}
_ => {
logging!(debug, Type::Setup, "failed to get modified '{}'", file);
handle_copy(&src_path, &dest_path, file).await;
}
};
}
Ok(())
}
#[cfg(target_os = "windows")]
pub fn init_scheme() -> Result<()> {
use tauri::utils::platform::current_exe;
use winreg::{RegKey, enums::HKEY_CURRENT_USER};
let app_exe = current_exe()?;
let app_exe = dunce::canonicalize(app_exe)?;
let app_exe = app_exe.to_string_lossy().into_owned();
let hkcu = RegKey::predef(HKEY_CURRENT_USER);
let (clash, _) = hkcu.create_subkey("Software\\Classes\\Clash")?;
clash.set_value("", &"Clash Verge")?;
clash.set_value("URL Protocol", &"Clash Verge URL Scheme Protocol")?;
let (default_icon, _) = hkcu.create_subkey("Software\\Classes\\Clash\\DefaultIcon")?;
default_icon.set_value("", &app_exe)?;
let (command, _) = hkcu.create_subkey("Software\\Classes\\Clash\\Shell\\Open\\Command")?;
command.set_value("", &format!("{app_exe} \"%1\""))?;
Ok(())
}
#[cfg(target_os = "linux")]
pub fn init_scheme() -> Result<()> {
const DESKTOP_FILE: &str = "clash-verge.desktop";
for scheme in DEEP_LINK_SCHEMES {
let handler = format!("x-scheme-handler/{scheme}");
let output = std::process::Command::new("xdg-mime")
.arg("default")
.arg(DESKTOP_FILE)
.arg(&handler)
.output()?;
if !output.status.success() {
return Err(anyhow::anyhow!(
"failed to set {handler}, {}",
String::from_utf8_lossy(&output.stderr)
));
}
}
crate::utils::linux::mime::ensure_mimeapps_entries(DESKTOP_FILE, DEEP_LINK_SCHEMES)?;
Ok(())
}
#[cfg(target_os = "macos")]
pub const fn init_scheme() -> Result<()> {
Ok(())
}
#[cfg(target_os = "linux")]
const DEEP_LINK_SCHEMES: &[&str] = &["clash", "clash-verge"];
pub async fn startup_script() -> Result<()> {
let app_handle = handle::Handle::app_handle();
let script_path = {
let verge = Config::verge().await;
let verge = verge.data_arc();
verge.startup_script.clone().unwrap_or_else(|| "".into())
};
if script_path.is_empty() {
return Ok(());
}
let shell_type = if script_path.ends_with(".sh") {
"bash"
} else if script_path.ends_with(".ps1") || script_path.ends_with(".bat") {
"powershell"
} else {
return Err(anyhow::anyhow!("unsupported script extension: {}", script_path));
};
let script_dir = PathBuf::from(script_path.as_str());
if !script_dir.exists() {
return Err(anyhow::anyhow!("script not found: {}", script_path));
}
let parent_dir = script_dir.parent();
let working_dir = parent_dir.unwrap_or_else(|| script_dir.as_ref());
app_handle
.shell()
.command(shell_type)
.current_dir(working_dir)
.args([script_path.as_str()])
.output()
.await?;
Ok(())
}
async fn handle_copy(src: &PathBuf, dest: &PathBuf, file: &str) {
match fs::copy(src, dest).await {
Ok(_) => {
logging!(debug, Type::Setup, "resources copied '{}'", file);
}
Err(err) => {
logging!(
error,
Type::Setup,
"failed to copy resources '{}' to '{:?}', {}",
file,
dest,
err
);
}
};
}