diff --git a/Cargo.lock b/Cargo.lock index f74483935..d1a1c1ed8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1111,7 +1111,6 @@ dependencies = [ "getrandom 0.4.3", "libc", "log", - "minisign-verify", "nanoid", "network-interface", "objc2", diff --git a/Changelog.md b/Changelog.md index 524c12c4a..64b93631d 100644 --- a/Changelog.md +++ b/Changelog.md @@ -24,14 +24,18 @@
🐞 修复问题 +- 修复检测更新后又发布新版时,应用内更新失败 +
✨ 新增功能 +
🚀 优化改进 +
diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8bac714ca..5502ed4a5 100755 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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" diff --git a/src-tauri/src/cmd/app.rs b/src-tauri/src/cmd/app.rs index e043dbc4e..3e6597f3b 100644 --- a/src-tauri/src/cmd/app.rs +++ b/src-tauri/src/cmd/app.rs @@ -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, -) -> CmdResult { - let version: String = version.into(); +) -> CmdResult { SilentUpdater::global() .install_update(&app_handle, &version, |event| { let _ = on_event.send(event); diff --git a/src-tauri/src/core/updater.rs b/src-tauri/src/core/updater.rs index c017582eb..25a938bc4 100644 --- a/src-tauri/src/core/updater.rs +++ b/src-tauri/src/core/updater.rs @@ -18,6 +18,14 @@ pub struct SilentUpdater { background_cancel: Mutex>, } +#[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> { + fn verified_cache(update: &Update) -> Option> { 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 { - 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 { + ) -> Result { 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)> { - let update = Self::check(app_handle) + ) -> Result)>> { + 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}..."); diff --git a/src/components/setting/mods/update-viewer.tsx b/src/components/setting/mods/update-viewer.tsx index e29c5ba74..dc6a2659f 100644 --- a/src/components/setting/mods/update-viewer.tsx +++ b/src/components/setting/mods/update-viewer.tsx @@ -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 }) { const updateState = useUpdateState() const setUpdateState = useSetUpdateState() - const { updateInfo } = useUpdate() + const { updateInfo: detectedUpdate, checkUpdate } = useUpdate() + const [refreshedUpdate, setRefreshedUpdate] = useState() + 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 }) { }, [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 }) { } 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 }) { 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 }) { showNotice.error(err) } finally { setUpdateState(false) - setDownloaded(0) - setTotal(0) - downloadedRef.current = 0 - totalRef.current = 0 + setDownload({ downloaded: 0, total: 0 }) } }) diff --git a/src/services/bindings.ts b/src/services/bindings.ts index 073da075a..b74dd2e04 100644 --- a/src/services/bindings.ts +++ b/src/services/bindings.ts @@ -24,7 +24,10 @@ export const commands = { getSystemHostname: () => __TAURI_INVOKE('get_system_hostname'), restartApp: () => __TAURI_INVOKE('restart_app'), installUpdate: (version: string, onEvent: Channel) => - __TAURI_INVOKE('install_update', { version, onEvent }), + __TAURI_INVOKE('install_update', { + version, + onEvent, + }), cancelUpdateDownload: () => __TAURI_INVOKE('cancel_update_download'), restartCore: () => __TAURI_INVOKE('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 }) diff --git a/src/services/update.ts b/src/services/update.ts index 26fb6d1cf..16eea0773 100644 --- a/src/services/update.ts +++ b/src/services/update.ts @@ -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 = diff --git a/template/Changelog.md b/template/Changelog.md index 78356a393..34cecbaa4 100644 --- a/template/Changelog.md +++ b/template/Changelog.md @@ -24,14 +24,17 @@
🐞 修复问题 +
✨ 新增功能 +
🚀 优化改进 +