use crate::{config::Config, singleton, utils::dirs}; use anyhow::Result; use chrono::Utc; use clash_verge_logging::{Type, logging}; use parking_lot::RwLock; use serde::{Deserialize, Serialize}; use std::{ path::PathBuf, sync::atomic::{AtomicBool, Ordering}, }; use tauri_plugin_updater::{Update, UpdaterExt as _}; pub struct SilentUpdater { update_ready: AtomicBool, pending_bytes: RwLock>>, pending_update: RwLock>, pending_version: RwLock>, } singleton!(SilentUpdater, SILENT_UPDATER); impl SilentUpdater { const fn new() -> Self { Self { update_ready: AtomicBool::new(false), pending_bytes: RwLock::new(None), pending_update: RwLock::new(None), pending_version: RwLock::new(None), } } pub fn is_update_ready(&self) -> bool { self.update_ready.load(Ordering::Acquire) } } // ─── Disk Cache ─────────────────────────────────────────────────────────────── #[derive(Serialize, Deserialize)] struct UpdateCacheMeta { version: String, downloaded_at: String, } impl SilentUpdater { fn cache_dir() -> Result { Ok(dirs::app_home_dir()?.join("update_cache")) } fn write_cache(bytes: &[u8], version: &str) -> Result<()> { let cache_dir = Self::cache_dir()?; std::fs::create_dir_all(&cache_dir)?; let bin_path = cache_dir.join("pending_update.bin"); std::fs::write(&bin_path, bytes)?; let meta = UpdateCacheMeta { version: version.to_string(), downloaded_at: Utc::now().to_rfc3339(), }; let meta_path = cache_dir.join("pending_update.json"); std::fs::write(&meta_path, serde_json::to_string_pretty(&meta)?)?; logging!( info, Type::System, "Update cache written: version={}, size={} bytes", version, bytes.len() ); Ok(()) } fn read_cache_bytes() -> Result> { let bin_path = Self::cache_dir()?.join("pending_update.bin"); Ok(std::fs::read(bin_path)?) } fn read_cache_meta() -> Result { let meta_path = Self::cache_dir()?.join("pending_update.json"); let content = std::fs::read_to_string(meta_path)?; Ok(serde_json::from_str(&content)?) } fn delete_cache() { if let Ok(cache_dir) = Self::cache_dir() && cache_dir.exists() { if let Err(e) = std::fs::remove_dir_all(&cache_dir) { logging!(warn, Type::System, "Failed to delete update cache: {e}"); } else { logging!(info, Type::System, "Update cache deleted"); } } } } // ─── Version Comparison ─────────────────────────────────────────────────────── /// Returns true if version `a` <= version `b` using semver-like comparison. /// Strips leading 'v', splits on '.', handles pre-release suffixes. fn version_lte(a: &str, b: &str) -> bool { let parse = |v: &str| -> Vec { v.trim_start_matches('v') .split('.') .filter_map(|part| { let numeric = part.split('-').next().unwrap_or("0"); numeric.parse::().ok() }) .collect() }; let a_parts = parse(a); let b_parts = parse(b); let len = a_parts.len().max(b_parts.len()); for i in 0..len { let av = a_parts.get(i).copied().unwrap_or(0); let bv = b_parts.get(i).copied().unwrap_or(0); if av < bv { return true; } if av > bv { return false; } } true // equal } // ─── Startup Install & Cache Management ───────────────────────────────────── impl SilentUpdater { /// Called at app startup. If a cached update exists and is newer than the current version, /// attempt to install it immediately (before the main app initializes). /// Returns true if install was triggered (app should relaunch), false otherwise. pub async fn try_install_on_startup(&self, app_handle: &tauri::AppHandle) -> bool { let current_version = env!("CARGO_PKG_VERSION"); let meta = match Self::read_cache_meta() { Ok(meta) => meta, Err(_) => return false, // No cache, nothing to do }; let cached_version = &meta.version; if version_lte(cached_version, current_version) { logging!( info, Type::System, "Update cache version ({}) <= current ({}), cleaning up", cached_version, current_version ); Self::delete_cache(); return false; } logging!( info, Type::System, "Update cache version ({}) > current ({}), attempting startup install", cached_version, current_version ); // Read cached bytes let bytes = match Self::read_cache_bytes() { Ok(b) => b, Err(e) => { logging!( warn, Type::System, "Failed to read cached update bytes: {e}, cleaning up" ); Self::delete_cache(); return false; } }; // Need a fresh Update object from the server to call install() // Network should be available at startup (user just booted) let update = match app_handle.updater() { Ok(updater) => match updater.check().await { Ok(Some(u)) => u, Ok(None) => { logging!( info, Type::System, "No update available from server, cache may be stale, cleaning up" ); Self::delete_cache(); return false; } Err(e) => { logging!( warn, Type::System, "Failed to check for update at startup: {e}, will retry next launch" ); return false; // Keep cache for next attempt } }, Err(e) => { logging!( warn, Type::System, "Failed to create updater: {e}, will retry next launch" ); return false; } }; let version = update.version.clone(); logging!(info, Type::System, "Installing cached update v{version} at startup..."); // Show splash window so user knows the app is updating, not frozen Self::show_update_splash(app_handle, &version); // install() is sync and may hang (known bug #2558), so run with a timeout. // On Windows, NSIS takes over the process so install() may never return — that's OK. let install_result = tokio::task::spawn_blocking({ let bytes = bytes.clone(); let update = update.clone(); move || update.install(&bytes) }); let success = match tokio::time::timeout(std::time::Duration::from_secs(30), install_result).await { Ok(Ok(Ok(()))) => { logging!(info, Type::System, "Update v{version} install triggered at startup"); Self::delete_cache(); true } Ok(Ok(Err(e))) => { logging!(warn, Type::System, "Startup install failed: {e}, cleaning up"); Self::delete_cache(); false } Ok(Err(e)) => { logging!(warn, Type::System, "Startup install task panicked: {e}, cleaning up"); Self::delete_cache(); false } Err(_) => { logging!(warn, Type::System, "Startup install timed out (30s), cleaning up"); Self::delete_cache(); false } }; // Close splash window if install failed and app continues normally if !success { Self::close_update_splash(app_handle); } success } } // ─── Update Splash Window ──────────────────────────────────────────────────── impl SilentUpdater { /// Show a small centered splash window indicating update is being installed. /// Injects HTML via eval() after window creation so it doesn't depend on any /// external file in the bundle. fn show_update_splash(app_handle: &tauri::AppHandle, version: &str) { use tauri::{WebviewUrl, WebviewWindowBuilder}; let window = match WebviewWindowBuilder::new(app_handle, "update-splash", WebviewUrl::App("index.html".into())) .title("Clash Verge - Updating") .inner_size(300.0, 180.0) .resizable(false) .maximizable(false) .minimizable(false) .closable(false) .decorations(false) .center() .always_on_top(true) .visible(true) .build() { Ok(w) => w, Err(e) => { logging!(warn, Type::System, "Failed to create update splash: {e}"); return; } }; let js = format!( r#" document.documentElement.innerHTML = `
Installing Update...
v{version}
`; "# ); // Retry eval a few times — the webview may not be ready immediately std::thread::spawn(move || { for i in 0..10 { std::thread::sleep(std::time::Duration::from_millis(100 * (i + 1))); if window.eval(&js).is_ok() { return; } } }); logging!(info, Type::System, "Update splash window shown"); } /// Close the update splash window (e.g. after install failure). fn close_update_splash(app_handle: &tauri::AppHandle) { use tauri::Manager as _; if let Some(window) = app_handle.get_webview_window("update-splash") { let _ = window.close(); logging!(info, Type::System, "Update splash window closed"); } } } // ─── Background Check and Download ─────────────────────────────────────────── impl SilentUpdater { async fn check_and_download(&self, app_handle: &tauri::AppHandle) -> Result<()> { let is_portable = *dirs::PORTABLE_FLAG.get().unwrap_or(&false); if is_portable { logging!(debug, Type::System, "Silent update skipped: portable build"); return Ok(()); } let auto_check = Config::verge().await.latest_arc().auto_check_update.unwrap_or(true); if !auto_check { logging!(debug, Type::System, "Silent update skipped: auto_check_update is false"); return Ok(()); } if self.is_update_ready() { logging!(debug, Type::System, "Silent update skipped: update already pending"); return Ok(()); } logging!(info, Type::System, "Silent updater: checking for updates..."); let updater = app_handle.updater()?; let update = match updater.check().await { Ok(Some(update)) => update, Ok(None) => { logging!(info, Type::System, "Silent updater: no update available"); return Ok(()); } Err(e) => { logging!(warn, Type::System, "Silent updater: check failed: {e}"); return Err(e.into()); } }; let version = update.version.clone(); logging!(info, Type::System, "Silent updater: update available: v{version}"); if let Some(body) = &update.body && body.to_lowercase().contains("break change") { logging!( info, Type::System, "Silent updater: breaking change detected in v{version}, notifying frontend" ); super::handle::Handle::notice_message( "info", format!("New version v{version} contains breaking changes. Please update manually."), ); return Ok(()); } logging!(info, Type::System, "Silent updater: downloading v{version}..."); let bytes = update .download( |chunk_len, content_len| { logging!( debug, Type::System, "Silent updater download progress: chunk={chunk_len}, total={content_len:?}" ); }, || { logging!(info, Type::System, "Silent updater: download complete"); }, ) .await?; if let Err(e) = Self::write_cache(&bytes, &version) { logging!(warn, Type::System, "Silent updater: failed to write cache: {e}"); } *self.pending_bytes.write() = Some(bytes); *self.pending_update.write() = Some(update); *self.pending_version.write() = Some(version.clone()); self.update_ready.store(true, Ordering::Release); logging!( info, Type::System, "Silent updater: v{version} ready for startup install on next launch" ); Ok(()) } pub async fn start_background_check(&self, app_handle: tauri::AppHandle) { logging!(info, Type::System, "Silent updater: background task started"); tokio::time::sleep(std::time::Duration::from_secs(10)).await; loop { if let Err(e) = self.check_and_download(&app_handle).await { logging!(warn, Type::System, "Silent updater: cycle error: {e}"); } tokio::time::sleep(std::time::Duration::from_secs(24 * 60 * 60)).await; } } } #[cfg(test)] #[allow(clippy::unwrap_used)] mod tests { use super::*; // ─── version_lte tests ────────────────────────────────────────────────── #[test] fn test_version_equal() { assert!(version_lte("2.4.7", "2.4.7")); } #[test] fn test_version_less() { assert!(version_lte("2.4.7", "2.4.8")); assert!(version_lte("2.4.7", "2.5.0")); assert!(version_lte("2.4.7", "3.0.0")); } #[test] fn test_version_greater() { assert!(!version_lte("2.4.8", "2.4.7")); assert!(!version_lte("2.5.0", "2.4.7")); assert!(!version_lte("3.0.0", "2.4.7")); } #[test] fn test_version_with_v_prefix() { assert!(version_lte("v2.4.7", "2.4.8")); assert!(version_lte("2.4.7", "v2.4.8")); assert!(version_lte("v2.4.7", "v2.4.8")); } #[test] fn test_version_with_prerelease() { // "2.4.8-alpha" → numeric part is still "2.4.8" assert!(version_lte("2.4.7", "2.4.8-alpha")); assert!(version_lte("2.4.8-alpha", "2.4.8")); // Both have same numeric part, so equal → true assert!(version_lte("2.4.8-alpha", "2.4.8-beta")); } #[test] fn test_version_different_lengths() { assert!(version_lte("2.4", "2.4.1")); assert!(!version_lte("2.4.1", "2.4")); assert!(version_lte("2.4.0", "2.4")); } // ─── Cache metadata tests ─────────────────────────────────────────────── #[test] fn test_cache_meta_serialize_roundtrip() { let meta = UpdateCacheMeta { version: "2.5.0".to_string(), downloaded_at: "2026-03-31T00:00:00Z".to_string(), }; let json = serde_json::to_string(&meta).unwrap(); let parsed: UpdateCacheMeta = serde_json::from_str(&json).unwrap(); assert_eq!(parsed.version, "2.5.0"); assert_eq!(parsed.downloaded_at, "2026-03-31T00:00:00Z"); } #[test] fn test_cache_meta_invalid_json() { let result = serde_json::from_str::("not valid json"); assert!(result.is_err()); } #[test] fn test_cache_meta_missing_field() { let result = serde_json::from_str::(r#"{"version":"2.5.0"}"#); assert!(result.is_err()); // missing downloaded_at } }