fix(updater): install the already downloaded update instead of fetching it again

This commit is contained in:
wonfen
2026-10-06 14:21:09 +08:00
parent 6ec35c8195
commit c341b9b238
8 changed files with 162 additions and 111 deletions
Generated
+1
View File
@@ -1113,6 +1113,7 @@ dependencies = [
"getrandom 0.4.3",
"libc",
"log",
"minisign-verify",
"nanoid",
"network-interface",
"objc2",
+1
View File
@@ -6,6 +6,7 @@
- 修复端口设置混淆保存值与运行值、输入无效时缺少提示的问题
- 修复从开始菜单再次打开应用后,窗口反复弹出并提示启动失败的问题
- 修复多个代理集合包含同名节点时,代理组中的节点变灰并显示 ambiguous、无法选择的问题
- 修复手动更新时重复下载后台已下载的安装包、启动时选「稍后」后每次启动都重新下载的问题
**🖥️ Windows**
+1
View File
@@ -86,6 +86,7 @@ flate2 = "1.1.10"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
base64 = "0.23.1"
minisign-verify = "0.2.5"
sha2 = "0.11"
getrandom = "0.4.3"
futures = "0.3.34"
+9 -1
View File
@@ -1,5 +1,5 @@
use super::CmdResult;
use crate::{cmd::StringifyErr as _, feat, utils::dirs};
use crate::{cmd::StringifyErr as _, core::SilentUpdater, feat, utils::dirs};
use smartstring::alias::String;
use tauri::{AppHandle, Manager as _};
@@ -44,6 +44,14 @@ pub async fn restart_app() -> CmdResult<()> {
Ok(())
}
#[tauri::command]
pub async fn install_cached_update(app_handle: AppHandle, version: String) -> CmdResult<bool> {
SilentUpdater::global()
.install_cached(&app_handle, &version)
.await
.stringify_err()
}
#[tauri::command]
pub fn get_app_dir() -> CmdResult<String> {
let app_home_dir = dirs::app_home_dir().stringify_err()?.to_string_lossy().into();
+141 -108
View File
@@ -2,7 +2,6 @@ use crate::{config::Config, singleton, utils::dirs};
use anyhow::{Context as _, Result};
use chrono::Utc;
use clash_verge_logging::{Type, logging};
use parking_lot::RwLock;
use serde::{Deserialize, Serialize};
use std::{
path::PathBuf,
@@ -12,9 +11,6 @@ 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);
@@ -23,9 +19,6 @@ 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),
}
}
@@ -99,6 +92,72 @@ impl SilentUpdater {
}
}
}
/// Returns the cached installer for `update`, deleting a cache that does not match it.
fn verified_cache(app_handle: &tauri::AppHandle, update: &Update) -> Option<Vec<u8>> {
let meta = Self::read_cache_meta().ok()?;
let bytes = if meta.version == update.version {
Self::read_cache_bytes().and_then(|bytes| {
verify_signature(app_handle, &bytes, &update.signature, &update.version)?;
Ok(bytes)
})
} else {
Err(anyhow::anyhow!("server offers v{}", update.version))
};
match bytes {
Ok(bytes) => Some(bytes),
Err(e) => {
logging!(
info,
Type::System,
"Update cache v{} is unusable: {e:#}, cleaning up",
meta.version
);
Self::delete_cache();
None
}
}
}
}
/// The cache directory is user-writable and feeds an elevated installer, so it is re-verified
/// with the same rules `Update::download` applies.
fn verify_signature(app_handle: &tauri::AppHandle, bytes: &[u8], signature: &str, version: &str) -> Result<()> {
use base64::Engine as _;
let decode = |value: &str| -> Result<String> {
Ok(String::from_utf8(
base64::engine::general_purpose::STANDARD.decode(value)?,
)?)
};
let config: tauri_plugin_updater::Config = serde_json::from_value(
app_handle
.config()
.plugins
.0
.get("updater")
.cloned()
.context("updater is not configured")?,
)?;
let public_key = minisign_verify::PublicKey::decode(&decode(&config.pubkey)?)?;
let signature = minisign_verify::Signature::decode(&decode(signature)?)?;
public_key
.verify(bytes, &signature, true)
.context("signature mismatch")?;
match signature
.trusted_comment()
.split('\t')
.find_map(|field| field.strip_prefix("version:"))
{
Some(signed) => anyhow::ensure!(
signed.trim_start_matches('v') == version,
"signed for v{signed}, not v{version}"
),
None => anyhow::ensure!(!config.require_signed_version, "signature carries no version"),
}
Ok(())
}
pub fn is_build_to_stable(current: &str, remote: &str) -> bool {
@@ -148,6 +207,23 @@ fn nsis_language_id(app_language: &str) -> &'static str {
}
impl SilentUpdater {
/// Checks the server; Windows receives `/LANG` to suppress the NSIS language dialog,
/// see `packages/windows/installer.nsi`.
async fn check(app_handle: &tauri::AppHandle) -> Result<Option<Update>> {
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}"))
};
Ok(updater_builder.build()?.check().await?)
}
async fn install(update: Update, bytes: Vec<u8>) -> Result<()> {
Ok(tokio::task::spawn_blocking(move || update.install(&bytes)).await??)
}
/// 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");
@@ -185,71 +261,30 @@ impl SilentUpdater {
return false;
}
let bytes = match Self::read_cache_bytes() {
Ok(b) => b,
Err(e) => {
let update = match Self::check(app_handle).await {
Ok(Some(u)) => u,
Ok(None) => {
logging!(
warn,
info,
Type::System,
"Failed to read cached update bytes: {e:#}, cleaning up"
"No update available from server, cache may be stale, 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"
"Failed to check for update at startup: {e:#}, will retry next launch"
);
return false;
return false; // Keep cache for next attempt
}
};
// 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();
let Some(bytes) = Self::verified_cache(app_handle, &update) else {
return false;
}
};
let version = update.version.clone();
logging!(info, Type::System, "Installing cached update v{version} at startup...");
@@ -257,49 +292,49 @@ impl SilentUpdater {
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(()))) => {
let install = tokio::time::timeout(std::time::Duration::from_secs(30), Self::install(update, bytes));
match install
.await
.unwrap_or_else(|_| Err(anyhow::anyhow!("install timed out (30s)")))
{
Ok(()) => {
logging!(info, Type::System, "Update v{version} install triggered at startup");
Self::delete_cache();
true
}
Ok(Ok(Err(e))) => {
Err(e) => {
logging!(
warn,
Type::System,
"Startup install failed: {e}, will retry next launch"
"Startup install failed: {e:#}, will retry next launch"
);
Self::close_update_splash(app_handle);
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
}
}
/// Installs the background-downloaded `version`; `false` sends the caller to a fresh download.
pub async fn install_cached(&self, app_handle: &tauri::AppHandle, version: &str) -> Result<bool> {
if Self::read_cache_meta().map_or(true, |meta| meta.version != version) {
return Ok(false);
}
let update = match Self::check(app_handle).await {
Ok(Some(update)) if update.version == version => update,
Ok(_) => return Ok(false),
Err(e) => {
logging!(warn, Type::System, "Cached update check failed: {e:#}");
return Ok(false);
}
};
let Some(bytes) = Self::verified_cache(app_handle, &update) else {
return Ok(false);
};
if !success {
Self::close_update_splash(app_handle);
}
success
logging!(info, Type::System, "Installing cached update v{version}...");
Self::install(update, bytes).await?;
Self::delete_cache();
Ok(true)
}
}
@@ -454,29 +489,27 @@ impl SilentUpdater {
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:#}");
if Self::verified_cache(app_handle, &update).is_some() {
logging!(info, Type::System, "Silent updater: v{version} already cached");
} else {
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?;
Self::write_cache(&bytes, &version)?;
}
*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!(
+1
View File
@@ -139,6 +139,7 @@ mod app_init {
cmd::get_network_interfaces,
cmd::get_system_hostname,
cmd::restart_app,
cmd::install_cached_update,
cmd::restart_core,
cmd::upgrade_clash_core,
cmd::get_runtime_state,
@@ -15,7 +15,7 @@ import type { Options as ReactMarkdownOptions } from 'react-markdown'
import { BaseDialog, DialogRef } from '@/components/base'
import { useUpdate } from '@/hooks/use-update'
import { restartApp } from '@/services/cmds'
import { installCachedUpdate, restartApp } from '@/services/cmds'
import { showNotice } from '@/services/notice-service'
import { useSetUpdateState, useUpdateState } from '@/services/states'
import { openExternalUrl } from '@/utils/open-external-url'
@@ -270,7 +270,9 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
}
try {
await updateInfo.downloadAndInstall(onDownloadEvent)
if (!(await installCachedUpdate(updateInfo.version))) {
await updateInfo.downloadAndInstall(onDownloadEvent)
}
await restartApp()
} catch (err: any) {
showNotice.error(err)
+4
View File
@@ -269,6 +269,10 @@ export async function restartApp() {
return invoke<void>('restart_app')
}
export async function installCachedUpdate(version: string) {
return invoke<boolean>('install_cached_update', { version })
}
export async function getAppDir() {
return invoke<string>('get_app_dir')
}