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) } } #[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"); } } } } /// Compares numeric version components after stripping `v` and prerelease 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 } /// Maps UI language to one of the three NSIS translations, defaulting to English. #[cfg(target_os = "windows")] fn nsis_language_id(app_language: &str) -> &'static str { match app_language { "zh" | "zhtw" => "2052", // SimpChinese "ru" => "1049", // Russian _ => "1033", // English } } impl SilentUpdater { /// Installs a newer cached update before normal startup, if the user confirms. 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 ({}), asking user to install", cached_version, current_version ); // Preserve the cache when skipped so the next launch asks again. if !Self::ask_user_to_install(app_handle, cached_version).await { logging!(info, Type::System, "User skipped update install, starting normally"); return false; } 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; } }; // Refresh metadata without re-downloading. Windows receives `/LANG` to suppress the // NSIS language dialog; see `packages/windows/installer.nsi`. let updater_builder = app_handle.updater_builder(); #[cfg(target_os = "windows")] let updater_builder = { let verge_lang = Config::verge().await.latest_arc().language.clone(); let lang_id = nsis_language_id(&clash_verge_i18n::current_language(verge_lang.as_deref())); updater_builder.installer_arg(format!("/LANG={lang_id}")) }; let update = match updater_builder.build() { 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; } }; // A changed server version invalidates the cached bytes. if update.version != *cached_version { logging!( info, Type::System, "Server version ({}) != cached version ({}), cache is stale, cleaning up", update.version, cached_version ); Self::delete_cache(); return false; } let version = update.version.clone(); logging!(info, Type::System, "Installing cached update v{version} at startup..."); Self::show_update_splash(app_handle, &version); // `install()` may hang (#2558); on Windows NSIS can take over without returning. 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}, will retry next launch" ); false } Ok(Err(e)) => { logging!( warn, Type::System, "Startup install task panicked: {e}, will retry next launch" ); false } Err(_) => { logging!( warn, Type::System, "Startup install timed out (30s), will retry next launch" ); false } }; if !success { Self::close_update_splash(app_handle); } success } } impl SilentUpdater { async fn ask_user_to_install(app_handle: &tauri::AppHandle, version: &str) -> bool { use tauri_plugin_dialog::{DialogExt as _, MessageDialogButtons, MessageDialogKind}; let title = clash_verge_i18n::t!("notifications.updateReady.title"); let body = clash_verge_i18n::t!("notifications.updateReady.body").replace("{version}", version); let install_now = clash_verge_i18n::t!("notifications.updateReady.installNow").into_owned(); let later = clash_verge_i18n::t!("notifications.updateReady.later").into_owned(); let (tx, rx) = tokio::sync::oneshot::channel(); app_handle .dialog() .message(body) .title(title) .buttons(MessageDialogButtons::OkCancelCustom(install_now, later)) .kind(MessageDialogKind::Info) .show(move |confirmed| { let _ = tx.send(confirmed); }); rx.await.unwrap_or(false) } } impl SilentUpdater { /// Uses injected HTML so the splash has no bundled-file dependency. 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}
`; "# ); // The new webview may not accept evaluation 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"); } 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"); } } } impl SilentUpdater { async fn check_and_download(&self, app_handle: &tauri::AppHandle) -> Result<()> { 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; } } }