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