From 05fba11baa6ae9ef8494f83ebc785a4f89779ac8 Mon Sep 17 00:00:00 2001 From: wonfen Date: Fri, 3 Apr 2026 05:45:55 +0800 Subject: [PATCH] feat: auto-download updates in background and install on next launch (cherry picked from commit 2f7c1b85f25e80b86233798a75e133b72a8101bb) --- src-tauri/src/core/mod.rs | 3 +- src-tauri/src/core/updater.rs | 520 +++++++++++++++++++++++++++++ src-tauri/src/utils/resolve/mod.rs | 27 ++ src-tauri/tauri.conf.json | 2 +- 4 files changed, 550 insertions(+), 2 deletions(-) create mode 100644 src-tauri/src/core/updater.rs diff --git a/src-tauri/src/core/mod.rs b/src-tauri/src/core/mod.rs index 319857bc1..d2bc839ff 100644 --- a/src-tauri/src/core/mod.rs +++ b/src-tauri/src/core/mod.rs @@ -9,7 +9,8 @@ pub mod service; pub mod sysopt; pub mod timer; pub mod tray; +pub mod updater; pub mod validate; pub mod win_uwp; -pub use self::{manager::CoreManager, timer::Timer}; +pub use self::{manager::CoreManager, timer::Timer, updater::SilentUpdater}; diff --git a/src-tauri/src/core/updater.rs b/src-tauri/src/core/updater.rs new file mode 100644 index 000000000..10446d3c5 --- /dev/null +++ b/src-tauri/src/core/updater.rs @@ -0,0 +1,520 @@ +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 + } +} diff --git a/src-tauri/src/utils/resolve/mod.rs b/src-tauri/src/utils/resolve/mod.rs index db0f22c92..b115628ff 100644 --- a/src-tauri/src/utils/resolve/mod.rs +++ b/src-tauri/src/utils/resolve/mod.rs @@ -74,6 +74,7 @@ pub fn resolve_setup_async() { init_hotkey(), init_auto_lightweight_boot(), init_auto_backup(), + init_silent_updater(), ); refresh_tray_menu().await; @@ -132,6 +133,32 @@ pub(super) async fn init_auto_backup() { logging_error!(Type::Setup, AutoBackupManager::global().init().await); } +async fn init_silent_updater() { + use crate::core::SilentUpdater; + use crate::core::handle::Handle; + + logging!(info, Type::Setup, "Initializing silent updater..."); + + let app_handle = Handle::app_handle(); + + // Check for cached update and attempt install before main app initialization. + // If install succeeds: + // - Windows: NSIS takes over and the process exits automatically + // - macOS/Linux: binary is replaced, we restart the app + if SilentUpdater::global().try_install_on_startup(app_handle).await { + logging!(info, Type::Setup, "Update installed at startup, restarting..."); + app_handle.restart(); + } + + // No pending install — start background check/download loop + let app_handle = app_handle.clone(); + tokio::spawn(async move { + SilentUpdater::global().start_background_check(app_handle).await; + }); + + logging!(info, Type::Setup, "Silent updater initialized"); +} + pub fn init_signal() { logging!(info, Type::Setup, "Initializing signal handlers..."); clash_verge_signal::register(feat::quit); diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 20719c5b0..c33ddcd2f 100755 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -37,7 +37,7 @@ "https://github.com/clash-verge-rev/clash-verge-rev/releases/download/updater/update.json" ], "windows": { - "installMode": "basicUi" + "installMode": "passive" } }, "deep-link": {