fix(updater): refresh changed update offers before installation

This commit is contained in:
Tunglies
2026-11-03 18:57:05 +08:00
parent 40fd1d5012
commit 2b7b7cc0e7
9 changed files with 87 additions and 148 deletions
Generated
-1
View File
@@ -1111,7 +1111,6 @@ dependencies = [
"getrandom 0.4.3",
"libc",
"log",
"minisign-verify",
"nanoid",
"network-interface",
"objc2",
+4
View File
@@ -24,14 +24,18 @@
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
- 修复检测更新后又发布新版时,应用内更新失败
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
</details>
-1
View File
@@ -84,7 +84,6 @@ flate2 = "1.1.10"
reqwest_dav = "0.3.3"
aes-gcm = { version = "0.11.1" }
base64 = "0.23.1"
minisign-verify = "0.3.0"
sha2 = "0.11.1"
getrandom = "0.4.3"
futures = "0.3.34"
+5 -3
View File
@@ -1,7 +1,10 @@
use super::CmdResult;
use crate::{
cmd::StringifyErr as _,
core::{SilentUpdater, updater::DownloadEvent},
core::{
SilentUpdater,
updater::{DownloadEvent, InstallUpdateOutcome},
},
feat,
utils::dirs,
};
@@ -61,8 +64,7 @@ pub async fn install_update(
app_handle: AppHandle,
version: std::string::String,
on_event: Channel<DownloadEvent>,
) -> CmdResult<bool> {
let version: String = version.into();
) -> CmdResult<InstallUpdateOutcome> {
SilentUpdater::global()
.install_update(&app_handle, &version, |event| {
let _ = on_event.send(event);
+28 -55
View File
@@ -18,6 +18,14 @@ pub struct SilentUpdater {
background_cancel: Mutex<Option<CancellationToken>>,
}
#[derive(Serialize, specta::Type)]
#[serde(rename_all = "snake_case")]
pub enum InstallUpdateOutcome {
Installed,
Cancelled,
UpdateChanged,
}
singleton!(SilentUpdater, SILENT_UPDATER);
impl SilentUpdater {
@@ -34,7 +42,7 @@ impl SilentUpdater {
}
}
#[derive(Serialize, Deserialize, specta::Type)]
#[derive(Serialize, Deserialize)]
struct UpdateCacheMeta {
version: String,
downloaded_at: String,
@@ -101,11 +109,11 @@ 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>> {
fn verified_cache(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)?;
update.verify(&bytes)?;
Ok(bytes)
})
} else {
@@ -127,46 +135,6 @@ impl SilentUpdater {
}
}
/// 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 {
let Some((base, build)) = current.trim_start_matches('v').split_once('+') else {
return false;
@@ -391,7 +359,7 @@ impl SilentUpdater {
}
};
let Some(bytes) = Self::verified_cache(app_handle, &update) else {
let Some(bytes) = Self::verified_cache(&update) else {
return false;
};
@@ -424,13 +392,13 @@ impl SilentUpdater {
}
/// Installs `version` for the update dialog, reusing the cache when it holds that version;
/// `false` when cancelled before the installer starts.
/// A changed offer must be confirmed again before downloading or installing it.
pub async fn install_update(
&self,
app_handle: &tauri::AppHandle,
version: &str,
on_event: impl Fn(DownloadEvent) + Sync,
) -> Result<bool> {
) -> Result<InstallUpdateOutcome> {
let cancel = CancellationToken::new();
*self.manual_cancel.lock() = Some(cancel.clone());
let prepared = tokio::select! {
@@ -440,31 +408,36 @@ impl SilentUpdater {
// `cancel_download` cancels under the lock, so once the token is taken back its state is
// final; a cancel during the last synchronous poll of `prepare_install` still wins.
self.manual_cancel.lock().take();
let Some((update, bytes)) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else {
let Some(prepared) = prepared.filter(|_| !cancel.is_cancelled()).transpose()? else {
logging!(info, Type::System, "Update v{version} cancelled");
return Ok(false);
return Ok(InstallUpdateOutcome::Cancelled);
};
let Some((update, bytes)) = prepared else {
return Ok(InstallUpdateOutcome::UpdateChanged);
};
logging!(info, Type::System, "Installing update v{version}...");
Self::install(update, bytes).await?;
Self::delete_cache();
Ok(true)
Ok(InstallUpdateOutcome::Installed)
}
async fn prepare_install(
app_handle: &tauri::AppHandle,
version: &str,
on_event: impl Fn(DownloadEvent) + Sync,
) -> Result<(Update, Vec<u8>)> {
let update = Self::check(app_handle)
) -> Result<Option<(Update, Vec<u8>)>> {
let Some(update) = Self::check(app_handle)
.await?
.filter(|update| update.version == version)
.with_context(|| format!("v{version} is no longer offered"))?;
let bytes = match Self::verified_cache(app_handle, &update) {
else {
return Ok(None);
};
let bytes = match Self::verified_cache(&update) {
Some(bytes) => bytes,
None => download(&update, on_event).await?,
};
Ok((update, bytes))
Ok(Some((update, bytes)))
}
pub fn cancel_download(&self) {
@@ -641,7 +614,7 @@ impl SilentUpdater {
return Ok(());
}
if Self::verified_cache(app_handle, &update).is_some() {
if Self::verified_cache(&update).is_some() {
logging!(info, Type::System, "Silent updater: v{version} already cached");
} else {
logging!(info, Type::System, "Silent updater: downloading v{version}...");
+40 -42
View File
@@ -1,14 +1,8 @@
import { alpha, Box, Button, LinearProgress } from '@mui/material'
import type { Update } from '@tauri-apps/plugin-updater'
import { useLockFn } from 'ahooks'
import type { Ref } from 'react'
import {
lazy,
Suspense,
useImperativeHandle,
useMemo,
useRef,
useState,
} from 'react'
import { lazy, Suspense, useImperativeHandle, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import type { Options as ReactMarkdownOptions } from 'react-markdown'
@@ -180,12 +174,16 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
const updateState = useUpdateState()
const setUpdateState = useSetUpdateState()
const { updateInfo } = useUpdate()
const { updateInfo: detectedUpdate, checkUpdate } = useUpdate()
const [refreshedUpdate, setRefreshedUpdate] = useState<Update | null>()
const updateInfo =
refreshedUpdate === undefined ? detectedUpdate : refreshedUpdate
const [downloaded, setDownloaded] = useState(0)
const [total, setTotal] = useState(0)
const downloadedRef = useRef(0)
const totalRef = useRef(0)
const [download, setDownload] = useState({
downloaded: 0,
total: 0,
})
const { downloaded, total } = download
const openUrlWithNotice = (url: string) => {
void openExternalUrl(url).catch(showNotice.error)
@@ -197,7 +195,10 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
}, [downloaded, total])
useImperativeHandle(ref, () => ({
open: () => setOpen(true),
open: () => {
setRefreshedUpdate(undefined)
setOpen(true)
},
close: () => setOpen(false),
}))
@@ -242,32 +243,21 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
}
if (updateState) return
setUpdateState(true)
setDownloaded(0)
setTotal(0)
downloadedRef.current = 0
totalRef.current = 0
setDownload({ downloaded: 0, total: 0 })
const onDownloadEvent = (event: DownloadEvent) => {
if (event.event === 'Started') {
const contentLength = event.data.contentLength ?? 0
totalRef.current = contentLength
setTotal(contentLength)
setDownloaded(0)
downloadedRef.current = 0
return
}
if (event.event === 'Progress') {
setDownloaded((prev) => {
const next = prev + event.data.chunkLength
downloadedRef.current = next
return next
})
}
if (event.event === 'Finished' && totalRef.current === 0) {
totalRef.current = downloadedRef.current
setTotal(downloadedRef.current)
setDownload({ downloaded: 0, total: event.data.contentLength ?? 0 })
} else if (event.event === 'Progress') {
setDownload((prev) => ({
...prev,
downloaded: prev.downloaded + event.data.chunkLength,
}))
} else if (event.event === 'Finished') {
setDownload((prev) => ({
...prev,
total: prev.total || prev.downloaded,
}))
}
}
@@ -279,7 +269,18 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
errorNotice: false,
},
)
if (result.ok && result.value) {
if (result.ok && result.value === 'update_changed') {
const { data } = await checkUpdate()
setRefreshedUpdate(data)
if (data) {
showNotice.info('settings.modals.update.messages.available')
} else {
setOpen(false)
showNotice.info(
'settings.components.verge.advanced.notifications.latestVersion',
)
}
} else if (result.ok && result.value === 'installed') {
await mutate(() => restartApp(), {
id: 'restart-app',
errorNotice: false,
@@ -289,10 +290,7 @@ export function UpdateViewer({ ref }: { ref?: Ref<DialogRef> }) {
showNotice.error(err)
} finally {
setUpdateState(false)
setDownloaded(0)
setTotal(0)
downloadedRef.current = 0
totalRef.current = 0
setDownload({ downloaded: 0, total: 0 })
}
})
+6 -1
View File
@@ -24,7 +24,10 @@ export const commands = {
getSystemHostname: () => __TAURI_INVOKE<string>('get_system_hostname'),
restartApp: () => __TAURI_INVOKE<null>('restart_app'),
installUpdate: (version: string, onEvent: Channel<DownloadEvent>) =>
__TAURI_INVOKE<boolean>('install_update', { version, onEvent }),
__TAURI_INVOKE<InstallUpdateOutcome>('install_update', {
version,
onEvent,
}),
cancelUpdateDownload: () => __TAURI_INVOKE<void>('cancel_update_download'),
restartCore: () => __TAURI_INVOKE<null>('restart_core'),
/** Replaces the managed core binary atomically instead of letting mihomo overwrite itself. */
@@ -536,6 +539,8 @@ export type IconInfo = {
current_t: string
}
export type InstallUpdateOutcome = 'installed' | 'cancelled' | 'update_changed'
export type InterfaceAddress =
| ({ V4: IpAddress } & { V6?: never })
| ({ V6: IpAddress } & { V4?: never })
+1 -45
View File
@@ -7,11 +7,6 @@ import { compareVersions as compareSemver } from 'compare-versions'
import { version as appVersion } from '@root/package.json'
const SEMVER_FULL_REGEX =
/^\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/
const SEMVER_SEARCH_REGEX =
/v?\d+(?:\.\d+){1,2}(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?/i
const normalizeVersion = (input: string | null | undefined): string | null => {
if (typeof input !== 'string') return null
const trimmed = input.trim()
@@ -19,19 +14,6 @@ const normalizeVersion = (input: string | null | undefined): string | null => {
return trimmed.replace(/^v/i, '')
}
const ensureSemver = (input: string | null | undefined): string | null => {
const normalized = normalizeVersion(input)
if (!normalized) return null
return SEMVER_FULL_REGEX.test(normalized) ? normalized : null
}
const extractSemver = (input: string | null | undefined): string | null => {
if (typeof input !== 'string') return null
const match = input.match(SEMVER_SEARCH_REGEX)
if (!match) return null
return normalizeVersion(match[0])
}
const compareVersions = (a: string | null, b: string | null): number | null => {
if (!a || !b) return null
try {
@@ -41,32 +23,6 @@ const compareVersions = (a: string | null, b: string | null): number | null => {
}
}
const resolveRemoteVersion = (update: Update): string | null => {
const primary = ensureSemver(update.version)
if (primary) return primary
const fallbackPrimary = extractSemver(update.version)
if (fallbackPrimary) return fallbackPrimary
const raw = update.rawJson ?? {}
const rawVersion = ensureSemver(
typeof raw.version === 'string' ? raw.version : null,
)
if (rawVersion) return rawVersion
const tagVersion = extractSemver(
typeof raw.tag_name === 'string' ? raw.tag_name : null,
)
if (tagVersion) return tagVersion
const nameVersion = extractSemver(
typeof raw.name === 'string' ? raw.name : null,
)
if (nameVersion) return nameVersion
return null
}
const localVersionNormalized = normalizeVersion(appVersion)
export const checkUpdateSafe = async (
@@ -75,7 +31,7 @@ export const checkUpdateSafe = async (
const result = await check(options ?? {})
if (!result) return null
const remoteVersion = resolveRemoteVersion(result)
const remoteVersion = result.version
const comparison = compareVersions(remoteVersion, localVersionNormalized)
const isBuildToStable =
+3
View File
@@ -24,14 +24,17 @@
<details>
<summary><strong> 🐞 修复问题 </strong></summary>
</details>
<details>
<summary><strong> ✨ 新增功能 </strong></summary>
</details>
<details>
<summary><strong> 🚀 优化改进 </strong></summary>
</details>