Compare commits

...

5 Commits

Author SHA1 Message Date
renovate[bot]
02bdc1eee7
chore(deps): update github/gh-aw-actions action to v0.65.7 2026-04-03 05:46:41 +00:00
Tunglies
36624aff49
fix(logs): preserve log data and eliminate blank flash on page navigation
Preserve SWR cache in onConnected to avoid replacing accumulated logs
with kernel buffer on reconnect. Add KeepAlive for the logs page so
its DOM stays mounted across route changes, removing the visible blank
window when navigating back.
2026-04-03 13:13:57 +08:00
wonfen
51578c03b0
fix: URL test url 2026-04-03 12:13:04 +08:00
wonfen
b7ae5f0ac9
fix: handle edge cases and add missing i18n 2026-04-03 06:26:24 +08:00
wonfen
05fba11baa
feat: auto-download updates in background and install on next launch
(cherry picked from commit 2f7c1b85f25e80b86233798a75e133b72a8101bb)
2026-04-03 05:54:18 +08:00
26 changed files with 712 additions and 16 deletions

View File

@ -65,7 +65,7 @@ jobs:
title: ${{ steps.sanitized.outputs.title }}
steps:
- name: Setup Scripts
uses: github/gh-aw-actions/setup@63aa903fe409698e15e5718ad89366a72bfe6a89 # v0.65.5
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7
with:
destination: ${{ runner.temp }}/gh-aw/actions
- name: Generate agentic run info
@ -277,7 +277,7 @@ jobs:
output_types: ${{ steps.collect_output.outputs.output_types }}
steps:
- name: Setup Scripts
uses: github/gh-aw-actions/setup@63aa903fe409698e15e5718ad89366a72bfe6a89 # v0.65.5
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7
with:
destination: ${{ runner.temp }}/gh-aw/actions
- name: Set runtime paths
@ -800,7 +800,7 @@ jobs:
total_count: ${{ steps.missing_tool.outputs.total_count }}
steps:
- name: Setup Scripts
uses: github/gh-aw-actions/setup@63aa903fe409698e15e5718ad89366a72bfe6a89 # v0.65.5
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7
with:
destination: ${{ runner.temp }}/gh-aw/actions
- name: Download agent output artifact
@ -895,7 +895,7 @@ jobs:
detection_success: ${{ steps.detection_conclusion.outputs.success }}
steps:
- name: Setup Scripts
uses: github/gh-aw-actions/setup@63aa903fe409698e15e5718ad89366a72bfe6a89 # v0.65.5
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7
with:
destination: ${{ runner.temp }}/gh-aw/actions
- name: Download agent output artifact
@ -1056,7 +1056,7 @@ jobs:
process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
steps:
- name: Setup Scripts
uses: github/gh-aw-actions/setup@63aa903fe409698e15e5718ad89366a72bfe6a89 # v0.65.5
uses: github/gh-aw-actions/setup@742ca9c12baa13667ac53db8eb95f48414f60792 # v0.65.7
with:
destination: ${{ runner.temp }}/gh-aw/actions
- name: Download agent output artifact

View File

@ -26,6 +26,11 @@ notifications:
appHidden:
title: تم إخفاء التطبيق
body: Clash Verge يعمل في الخلفية.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: يتطلب تثبيت خدمة Clash Verge صلاحيات المسؤول.
adminUninstallPrompt: يتطلب إلغاء تثبيت خدمة Clash Verge صلاحيات المسؤول.

View File

@ -26,6 +26,11 @@ notifications:
appHidden:
title: Anwendung ausgeblendet
body: Clash Verge läuft im Hintergrund.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: Für die Installation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.
adminUninstallPrompt: Für die Deinstallation des Clash-Verge-Dienstes sind Administratorrechte erforderlich.

View File

@ -26,6 +26,11 @@ notifications:
appHidden:
title: Application Hidden
body: Clash Verge is running in the background.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: Installing the Clash Verge service requires administrator privileges.
adminUninstallPrompt: Uninstalling the Clash Verge service requires administrator privileges.

View File

@ -26,6 +26,11 @@ notifications:
appHidden:
title: Aplicación oculta
body: Clash Verge se está ejecutando en segundo plano.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: Instalar el servicio de Clash Verge requiere privilegios de administrador.
adminUninstallPrompt: Desinstalar el servicio de Clash Verge requiere privilegios de administrador.

View File

@ -26,6 +26,11 @@ notifications:
appHidden:
title: برنامه پنهان شد
body: Clash Verge در پس‌زمینه در حال اجراست.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: نصب سرویس Clash Verge به دسترسی مدیر نیاز دارد.
adminUninstallPrompt: حذف سرویس Clash Verge به دسترسی مدیر نیاز دارد.

View File

@ -26,6 +26,11 @@ notifications:
appHidden:
title: Aplikasi Disembunyikan
body: Clash Verge berjalan di latar belakang.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: Menginstal layanan Clash Verge memerlukan hak administrator.
adminUninstallPrompt: Menghapus instalasi layanan Clash Verge memerlukan hak administrator.

View File

@ -26,6 +26,11 @@ notifications:
appHidden:
title: アプリが非表示
body: Clash Verge はバックグラウンドで実行中です。
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: Clash Verge サービスのインストールには管理者権限が必要です。
adminUninstallPrompt: Clash Verge サービスのアンインストールには管理者権限が必要です。

View File

@ -26,6 +26,11 @@ notifications:
appHidden:
title: 앱이 숨겨짐
body: Clash Verge가 백그라운드에서 실행 중입니다.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: Clash Verge 서비스 설치에는 관리자 권한이 필요합니다.
adminUninstallPrompt: Clash Verge 서비스 제거에는 관리자 권한이 필요합니다.

View File

@ -26,6 +26,11 @@ notifications:
appHidden:
title: Приложение скрыто
body: Clash Verge работает в фоновом режиме.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: Для установки службы Clash Verge требуются права администратора.
adminUninstallPrompt: Для удаления службы Clash Verge требуются права администратора.

View File

@ -26,6 +26,11 @@ notifications:
appHidden:
title: Uygulama Gizlendi
body: Clash Verge arka planda çalışıyor.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: Clash Verge hizmetini kurmak için yönetici ayrıcalıkları gerekir.
adminUninstallPrompt: Clash Verge hizmetini kaldırmak için yönetici ayrıcalıkları gerekir.

View File

@ -26,6 +26,11 @@ notifications:
appHidden:
title: Кушымта яшерелде
body: Clash Verge фон режимында эшли.
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: Clash Verge хезмәтен урнаштыру өчен администратор хокуклары кирәк.
adminUninstallPrompt: Clash Verge хезмәтен бетерү өчен администратор хокуклары кирәк.

View File

@ -26,6 +26,11 @@ notifications:
appHidden:
title: 应用已隐藏
body: Clash Verge 正在后台运行。
updateReady:
title: Clash Verge 更新
body: 新版本 (v{version}) 已下载完成,是否立即安装?
installNow: 立即安装
later: 稍后
service:
adminInstallPrompt: 安装 Clash Verge 服务需要管理员权限
adminUninstallPrompt: 卸载 Clash Verge 服务需要管理员权限

View File

@ -26,6 +26,11 @@ notifications:
appHidden:
title: 應用已隱藏
body: Clash Verge 正在背景執行。
updateReady:
title: Clash Verge Update
body: A new version (v{version}) has been downloaded and is ready to install.
installNow: Install Now
later: Later
service:
adminInstallPrompt: 安裝 Clash Verge 服務需要管理員權限
adminUninstallPrompt: 卸载 Clash Verge 服務需要管理員權限

View File

@ -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};

View File

@ -0,0 +1,579 @@
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<Option<Vec<u8>>>,
pending_update: RwLock<Option<Update>>,
pending_version: RwLock<Option<String>>,
}
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<PathBuf> {
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<Vec<u8>> {
let bin_path = Self::cache_dir()?.join("pending_update.bin");
Ok(std::fs::read(bin_path)?)
}
fn read_cache_meta() -> Result<UpdateCacheMeta> {
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<u64> {
v.trim_start_matches('v')
.split('.')
.filter_map(|part| {
let numeric = part.split('-').next().unwrap_or("0");
numeric.parse::<u64>().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 ({}), asking user to install",
cached_version,
current_version
);
// Ask user for confirmation — they can skip and use the app normally.
// The cache is preserved so next launch will ask again.
if !Self::ask_user_to_install(app_handle, cached_version).await {
logging!(info, Type::System, "User skipped update install, starting normally");
return false;
}
// 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().
// This is a lightweight HTTP request (< 1s), not a re-download.
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;
}
};
// Verify the server's version matches the cached version.
// If server now has a newer version, our cached bytes are stale.
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...");
// 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}, 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
}
};
// Close splash window if install failed and app continues normally
if !success {
Self::close_update_splash(app_handle);
}
success
}
}
// ─── User Confirmation Dialog ────────────────────────────────────────────────
impl SilentUpdater {
/// Show a native dialog asking the user to install or skip the update.
/// Returns true if user chose to install, false if they chose to skip.
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").to_string();
let body = clash_verge_i18n::t!("notifications.updateReady.body").replace("{version}", version);
let install_now = clash_verge_i18n::t!("notifications.updateReady.installNow").to_string();
let later = clash_verge_i18n::t!("notifications.updateReady.later").to_string();
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)
}
}
// ─── 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 = `
<head><meta charset="utf-8"/><style>
*{{margin:0;padding:0;box-sizing:border-box}}
html,body{{height:100%;overflow:hidden;user-select:none;-webkit-user-select:none;
font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif}}
body{{display:flex;flex-direction:column;align-items:center;justify-content:center;
background:#1e1e2e;color:#cdd6f4}}
@media(prefers-color-scheme:light){{
body{{background:#eff1f5;color:#4c4f69}}
.bar{{background:#dce0e8}}.fill{{background:#1e66f5}}.sub{{color:#6c6f85}}
}}
.icon{{width:48px;height:48px;margin-bottom:16px;animation:pulse 2s ease-in-out infinite}}
.title{{font-size:16px;font-weight:600;margin-bottom:6px}}
.sub{{font-size:13px;color:#a6adc8;margin-bottom:20px}}
.bar{{width:200px;height:4px;background:#313244;border-radius:2px;overflow:hidden}}
.fill{{height:100%;width:30%;background:#89b4fa;border-radius:2px;animation:ind 1.5s ease-in-out infinite}}
@keyframes ind{{0%{{width:0;margin-left:0}}50%{{width:40%;margin-left:30%}}100%{{width:0;margin-left:100%}}}}
@keyframes pulse{{0%,100%{{opacity:1}}50%{{opacity:.6}}}}
</style></head>
<body>
<svg class="icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/>
<polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/>
</svg>
<div class="title">Installing Update...</div>
<div class="sub">v{version}</div>
<div class="bar"><div class="fill"></div></div>
</body>`;
"#
);
// 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::<UpdateCacheMeta>("not valid json");
assert!(result.is_err());
}
#[test]
fn test_cache_meta_missing_required_field() {
let result = serde_json::from_str::<UpdateCacheMeta>(r#"{"version":"2.5.0"}"#);
assert!(result.is_err()); // missing downloaded_at
}
}

View File

@ -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);

View File

@ -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": {

View File

@ -614,7 +614,7 @@ export const GroupsEditorViewer = (props: Props) => {
/>
<TextField
autoComplete="new-password"
placeholder="http://cp.cloudflare.com"
placeholder="http://cp.cloudflare.com/generate_204"
size="small"
sx={{ width: 'calc(100% - 150px)' }}
{...field}

View File

@ -66,7 +66,8 @@ export const ProxyHead = ({
const { verge } = useVerge()
const defaultLatencyUrl =
verge?.default_latency_test?.trim() || 'http://cp.cloudflare.com'
verge?.default_latency_test?.trim() ||
'http://cp.cloudflare.com/generate_204'
useEffect(() => {
delayManager.setUrl(groupName, testUrl?.trim() || url || defaultLatencyUrl)

View File

@ -383,7 +383,7 @@ export const MiscViewer = forwardRef<DialogRef>((props, ref) => {
spellCheck="false"
sx={{ width: 250, marginLeft: 'auto' }}
value={values.defaultLatencyTest}
placeholder="http://cp.cloudflare.com"
placeholder="http://cp.cloudflare.com/generate_204"
onChange={(e) =>
setValues((v) => ({ ...v, defaultLatencyTest: e.target.value }))
}

View File

@ -101,7 +101,12 @@ export const useLogData = () => {
async onConnected() {
const logs = await getClashLogs()
if (isMounted()) {
next(null, clampLogs(filterLogsByLevel(logs, allowedTypes)))
next(null, (current) => {
if (!current || current.length === 0) {
return clampLogs(filterLogsByLevel(logs, allowedTypes))
}
return current
})
}
},
cleanup: clearFlushTimer,

View File

@ -26,7 +26,7 @@ import relativeTime from 'dayjs/plugin/relativeTime'
import type { CSSProperties } from 'react'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { Outlet, useNavigate } from 'react-router'
import { Outlet, useLocation, useNavigate } from 'react-router'
import { SWRConfig } from 'swr'
import iconDark from '@/assets/image/icon_dark.svg?react'
@ -53,6 +53,7 @@ import {
} from './_layout/hooks'
import { handleNoticeMessage } from './_layout/utils'
import { navItems } from './_routers'
import LogsPage from './logs'
import 'dayjs/locale/ru'
import 'dayjs/locale/zh-cn'
@ -120,6 +121,10 @@ const Layout = () => {
const navCollapsed = verge?.collapse_navbar ?? false
const { switchLanguage } = useI18n()
const navigate = useNavigate()
const { pathname } = useLocation()
const isLogsPage = pathname === '/logs'
const logsPageMountedRef = useRef(false)
if (isLogsPage) logsPageMountedRef.current = true
const themeReady = useMemo(() => Boolean(theme), [theme])
const [menuUnlocked, setMenuUnlocked] = useState(false)
@ -477,6 +482,20 @@ const Layout = () => {
<BaseErrorBoundary>
<Outlet />
</BaseErrorBoundary>
{logsPageMountedRef.current && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
display: isLogsPage ? undefined : 'none',
}}
>
<LogsPage />
</div>
)}
</div>
</div>
</div>

View File

@ -20,7 +20,6 @@ import UnlockSvg from '@/assets/image/itemicon/unlock.svg?react'
import Layout from './_layout'
import ConnectionsPage from './connections'
import HomePage from './home'
import LogsPage from './logs'
import ProfilesPage from './profiles'
import ProxiesPage from './proxies'
import RulesPage from './rules'
@ -62,7 +61,7 @@ export const navItems = [
label: 'layout.components.navigation.tabs.logs',
path: '/logs',
icon: [<SubjectRoundedIcon key="mui" />, <LogsSvg key="svg" />],
Component: LogsPage,
Component: () => null /* KeepAlive: real LogsPage rendered in Layout */,
},
{
label: 'layout.components.navigation.tabs.unlock',

View File

@ -340,7 +340,7 @@ export async function cmdGetProxyDelay(
url?: string,
) {
// 确保URL不为空
const testUrl = url || 'http://cp.cloudflare.com'
const testUrl = url || 'http://cp.cloudflare.com/generate_204'
try {
// 不再在前端编码代理名称,由后端统一处理编码

View File

@ -120,7 +120,7 @@ class DelayManager {
`[DelayManager] 获取测试URL组: ${group}, URL: ${url || '未设置'}`,
)
// 如果未设置URL返回默认URL
return url || 'http://cp.cloudflare.com'
return url || 'http://cp.cloudflare.com/generate_204'
}
setListener(