mirror of
https://github.com/clash-verge-rev/clash-verge-rev.git
synced 2026-11-03 21:52:11 +08:00
fix(updater): refresh changed update offers before installation
This commit is contained in:
Generated
-1
@@ -1111,7 +1111,6 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"libc",
|
||||
"log",
|
||||
"minisign-verify",
|
||||
"nanoid",
|
||||
"network-interface",
|
||||
"objc2",
|
||||
|
||||
@@ -24,14 +24,18 @@
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
- 修复检测更新后又发布新版时,应用内更新失败
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}...");
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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
@@ -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 =
|
||||
|
||||
@@ -24,14 +24,17 @@
|
||||
<details>
|
||||
<summary><strong> 🐞 修复问题 </strong></summary>
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> ✨ 新增功能 </strong></summary>
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong> 🚀 优化改进 </strong></summary>
|
||||
|
||||
|
||||
</details>
|
||||
|
||||
Reference in New Issue
Block a user