From 4f3e85fcd1c1b68b7ebd0ca131ac8ffa6e85a951 Mon Sep 17 00:00:00 2001 From: Jason Young <44939412+farion1231@users.noreply.github.com> Date: Thu, 11 Jun 2026 21:30:02 +0800 Subject: [PATCH] fix(updater): drive download/install/restart from backend to avoid hang (#4074) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(updater): drive download/install/restart from backend to avoid hang The 3.16/3.16.1 update flow was frontend-driven: downloadAndInstall() then relaunchApp(). relaunch() routed through AppHandle::restart(), and the old WebView had to keep running JS after the .app bundle was already swapped — an unstable window that could hang the update or leave the old version running until a manual restart. Move the whole download -> install -> cleanup -> restart chain into a new backend command install_update_and_restart, so it no longer depends on the old WebView running JS after the bundle is swapped. Platform-aware install ordering (install() behaves differently per OS): - Windows: install() launches the external installer and exits the process internally, so cleanup + single-instance destroy must run before install. Surface a recovery hint on failure since the proxy may already be stopped. - macOS/Linux: install() returns, so install first then cleanup — an install failure no longer wrongly stops the proxy / reverts takeover. Eliminate the restart vs single-instance race: restart_process() destroys the single-instance lock (remove socket on macOS, ReleaseMutex on Windows) before tauri::process::restart(), so the freshly spawned process can't connect to the old listener and exit itself. Also remove the now-dead frontend update plumbing (relaunchApp, UpdateHandle, mapUpdateHandle) and surface backend errors in the toast. Adapted from the original af4271f4 while rebasing onto #4069: the ExitRequested handler changes were dropped entirely — the classifier from #4069 already routes RESTART_EXIT_CODE to Tauri's default restart flow, and the original should_restart branch (prevent_exit + async cleanup) would have reintroduced the window-state deadlock that #4069 fixed. install_update_and_restart bypasses ExitRequested entirely, so the two fixes compose cleanly. * fix(updater): clear tray icon on the direct-restart update path restart_process re-execs via tauri::process::restart (spawn + exit(0)), which skips Tauri's internal cleanup_before_exit and all RunEvent::Exit plugin hooks. Window state, proxy/live restore and the single-instance lock were already compensated explicitly; the tray icon was not. On macOS/Linux the OS drops the status item when the process dies, so the gap there was cosmetic at most. The real residue risk is the Windows branch, which never reaches restart_process at all: update.install() exits the process inside the updater plugin (std::process::exit(0)), bypassing TrayIcon::drop — no NIM_DELETE is sent and a stale icon lingers in the shell until hovered, the same failure remove_tray_icon_before_exit was originally added for on the quit path. Call remove_tray_icon_before_exit (set_visible(false), proxied to the main thread via run_item_main_thread) in restart_process and before the Windows install. Deliberately not AppHandle::cleanup_before_exit(): it drops tray icons on the calling thread, which is not safe off the main thread on macOS (NSStatusItem). --- src-tauri/src/commands/settings.rs | 64 +++++++++++++++++ src-tauri/src/lib.rs | 29 +++++++- src/components/settings/AboutSection.tsx | 26 ++++--- src/contexts/UpdateContext.tsx | 7 +- src/lib/api/settings.ts | 4 ++ src/lib/updater.ts | 88 ++---------------------- 6 files changed, 114 insertions(+), 104 deletions(-) diff --git a/src-tauri/src/commands/settings.rs b/src-tauri/src/commands/settings.rs index 596255dc7..7b5b21d95 100644 --- a/src-tauri/src/commands/settings.rs +++ b/src-tauri/src/commands/settings.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] use tauri::AppHandle; +use tauri_plugin_updater::UpdaterExt; fn merge_settings_for_save( mut incoming: crate::settings::AppSettings, @@ -84,6 +85,69 @@ pub async fn restart_app(app: AppHandle) -> Result { Ok(true) } +/// 下载并安装应用更新,然后由后端直接重启应用。 +/// +/// macOS 更新会原地替换 `.app` bundle。如果先返回前端、再让旧 WebView 调 +/// `process.relaunch()`,旧进程可能已经处在 bundle 被替换后的不稳定窗口期。 +/// 这里把退出清理、安装和重启串在同一个后端流程中,避免依赖旧前端继续执行。 +#[tauri::command] +pub async fn install_update_and_restart(app: AppHandle) -> Result { + let updater = app + .updater_builder() + .build() + .map_err(|e| format!("初始化更新器失败: {e}"))?; + + let Some(update) = updater + .check() + .await + .map_err(|e| format!("检查更新失败: {e}"))? + else { + return Ok(false); + }; + + log::info!("开始下载应用更新: {}", update.version); + let bytes = update + .download(|_, _| {}, || {}) + .await + .map_err(|e| format!("下载更新失败: {e}"))?; + + log::info!("开始安装应用更新: {}", update.version); + + #[cfg(target_os = "windows")] + { + // Windows updater 会在 install() 内启动安装器并直接退出当前进程 + // (插件内部 std::process::exit(0),绕过 TrayIcon::drop、不发 + // NIM_DELETE,会残留死图标——与托盘"退出"路径相同的问题)。 + // 因此清理只能放在 install 前执行,且必须显式移除托盘图标。 + crate::save_window_state_before_exit(&app); + crate::cleanup_before_exit(&app).await; + crate::remove_tray_icon_before_exit(&app); + crate::destroy_single_instance_lock(&app); + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + update.install(bytes).map_err(|e| { + format!( + "Windows 更新安装失败: {e}。已执行退出前清理,代理或 Live 接管可能已暂停;请重启应用或重新开启代理后再试。" + ) + })?; + return Ok(true); + } + + #[cfg(not(target_os = "windows"))] + { + // macOS/Linux install() 会返回;先安装,避免安装失败时误停代理/撤回接管。 + update + .install(bytes) + .map_err(|e| format!("安装更新失败: {e}"))?; + + crate::save_window_state_before_exit(&app); + crate::cleanup_before_exit(&app).await; + + log::info!("应用更新安装完成,正在重启应用"); + tokio::time::sleep(tokio::time::Duration::from_millis(100)).await; + crate::restart_process(&app); + } +} + /// 获取 app_config_dir 覆盖配置 (从 Store) #[tauri::command] pub async fn get_app_config_dir_override(app: AppHandle) -> Result, String> { diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index a93e6f8be..eeebed147 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1165,6 +1165,7 @@ pub fn run() { commands::get_log_config, commands::set_log_config, commands::restart_app, + commands::install_update_and_restart, commands::check_for_updates, commands::is_portable_mode, commands::copy_text_to_clipboard, @@ -1645,7 +1646,7 @@ pub async fn cleanup_before_exit(app_handle: &tauri::AppHandle) { /// 触发 tray-icon 内部的 `remove_tray_icon` → `Shell_NotifyIconW(NIM_DELETE)`, /// 在进程结束前干净地把图标摘掉。其它平台 `set_visible(false)` 也是 /// 正常的隐藏/移除语义,作为跨平台兜底也安全。 -fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) { +pub(crate) fn remove_tray_icon_before_exit(app_handle: &tauri::AppHandle) { if let Some(tray) = app_handle.tray_by_id(tray::TRAY_ID) { if let Err(e) = tray.set_visible(false) { log::warn!("退出时移除托盘图标失败: {e}"); @@ -1962,6 +1963,32 @@ pub fn save_window_state_before_exit(app_handle: &tauri::AppHandle) { } } +/// 主动释放 single-instance 锁。 +/// +/// macOS single-instance 使用 `/tmp/{identifier}.sock`。我们有若干路径会直接 +/// `std::process::exit(0)`,不会触发插件挂在 `RunEvent::Exit` 上的清理钩子。 +/// 重启前主动 destroy 可以避免新进程误连旧 listener 后自行退出。 +pub fn destroy_single_instance_lock(app_handle: &tauri::AppHandle) { + #[cfg(any(target_os = "macos", target_os = "windows", target_os = "linux"))] + tauri_plugin_single_instance::destroy(app_handle); +} + +/// 清理托盘图标、释放 single-instance 锁后重启当前应用。 +/// +/// 直接走 `tauri::process::restart`(spawn 新进程 + `exit(0)`),不经过事件 +/// 循环退出,因此 Tauri 内部的 `cleanup_before_exit` 和各插件的 +/// `RunEvent::Exit` 钩子都不会执行。需要的清理由调用方与本函数显式补偿: +/// 窗口状态、代理/Live 恢复(调用方);托盘图标、single-instance 锁(本函数)。 +/// +/// 有意不调 `AppHandle::cleanup_before_exit()`:它会在调用线程上 Drop 托盘 +/// 图标,而 macOS 的 NSStatusItem 操作要求主线程;`set_visible(false)` 走 +/// `run_item_main_thread` 代理,跨线程安全(见 `remove_tray_icon_before_exit`)。 +pub fn restart_process(app_handle: &tauri::AppHandle) -> ! { + remove_tray_icon_before_exit(app_handle); + destroy_single_instance_lock(app_handle); + tauri::process::restart(&app_handle.env()); +} + #[cfg(test)] mod tests { use super::{classify_exit_request, ExitRequestAction}; diff --git a/src/components/settings/AboutSection.tsx b/src/components/settings/AboutSection.tsx index 5d6820f9a..47ea93b11 100644 --- a/src/components/settings/AboutSection.tsx +++ b/src/components/settings/AboutSection.tsx @@ -32,7 +32,6 @@ import type { ToolInstallationReport, } from "@/lib/api/settings"; import { useUpdate } from "@/contexts/UpdateContext"; -import { relaunchApp } from "@/lib/updater"; import { Badge } from "@/components/ui/badge"; import { motion } from "framer-motion"; import appIcon from "@/assets/icons/app-icon.png"; @@ -195,14 +194,8 @@ export function AboutSection({ isPortable }: AboutSectionProps) { ); const [showInstallCommands, setShowInstallCommands] = useState(false); - const { - hasUpdate, - updateInfo, - updateHandle, - checkUpdate, - resetDismiss, - isChecking, - } = useUpdate(); + const { hasUpdate, updateInfo, checkUpdate, resetDismiss, isChecking } = + useUpdate(); const [wslShellByTool, setWslShellByTool] = useState< Record @@ -392,7 +385,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) { }, [t, updateInfo?.availableVersion, version]); const handleCheckUpdate = useCallback(async () => { - if (hasUpdate && updateHandle) { + if (hasUpdate) { if (isPortable) { try { await settingsApi.checkUpdates(); @@ -405,11 +398,16 @@ export function AboutSection({ isPortable }: AboutSectionProps) { setIsDownloading(true); try { resetDismiss(); - await updateHandle.downloadAndInstall(); - await relaunchApp(); + const installed = await settingsApi.installUpdateAndRestart(); + if (!installed) { + toast.success(t("settings.upToDate"), { closeButton: true }); + } } catch (error) { console.error("[AboutSection] Update failed", error); - toast.error(t("settings.updateFailed")); + toast.error(t("settings.updateFailed"), { + description: extractErrorMessage(error) || undefined, + closeButton: true, + }); try { await settingsApi.checkUpdates(); } catch (fallbackError) { @@ -433,7 +431,7 @@ export function AboutSection({ isPortable }: AboutSectionProps) { console.error("[AboutSection] Check update failed", error); toast.error(t("settings.checkUpdateFailed")); } - }, [checkUpdate, hasUpdate, isPortable, resetDismiss, t, updateHandle]); + }, [checkUpdate, hasUpdate, isPortable, resetDismiss, t]); const handleCopyInstallCommands = useCallback(async () => { try { diff --git a/src/contexts/UpdateContext.tsx b/src/contexts/UpdateContext.tsx index a22e2c309..bdc7fb6bf 100644 --- a/src/contexts/UpdateContext.tsx +++ b/src/contexts/UpdateContext.tsx @@ -6,14 +6,13 @@ import React, { useCallback, useRef, } from "react"; -import type { UpdateInfo, UpdateHandle } from "../lib/updater"; +import type { UpdateInfo } from "../lib/updater"; import { checkForUpdate } from "../lib/updater"; interface UpdateContextValue { // 更新状态 hasUpdate: boolean; updateInfo: UpdateInfo | null; - updateHandle: UpdateHandle | null; isChecking: boolean; error: string | null; @@ -34,7 +33,6 @@ export function UpdateProvider({ children }: { children: React.ReactNode }) { const [hasUpdate, setHasUpdate] = useState(false); const [updateInfo, setUpdateInfo] = useState(null); - const [updateHandle, setUpdateHandle] = useState(null); const [isChecking, setIsChecking] = useState(false); const [error, setError] = useState(null); const [isDismissed, setIsDismissed] = useState(false); @@ -72,7 +70,6 @@ export function UpdateProvider({ children }: { children: React.ReactNode }) { if (result.status === "available") { setHasUpdate(true); setUpdateInfo(result.info); - setUpdateHandle(result.update); // 检查是否已经关闭过这个版本的提醒 let dismissedVersion = localStorage.getItem(DISMISSED_VERSION_KEY); @@ -89,7 +86,6 @@ export function UpdateProvider({ children }: { children: React.ReactNode }) { } else { setHasUpdate(false); setUpdateInfo(null); - setUpdateHandle(null); setIsDismissed(false); return false; // 已是最新 } @@ -132,7 +128,6 @@ export function UpdateProvider({ children }: { children: React.ReactNode }) { const value: UpdateContextValue = { hasUpdate, updateInfo, - updateHandle, isChecking, error, isDismissed, diff --git a/src/lib/api/settings.ts b/src/lib/api/settings.ts index eb089e761..813ea929a 100644 --- a/src/lib/api/settings.ts +++ b/src/lib/api/settings.ts @@ -36,6 +36,10 @@ export const settingsApi = { return await invoke("restart_app"); }, + async installUpdateAndRestart(): Promise { + return await invoke("install_update_and_restart"); + }, + async checkUpdates(): Promise { await invoke("check_for_updates"); }, diff --git a/src/lib/updater.ts b/src/lib/updater.ts index 2926cb184..0bc7da2f9 100644 --- a/src/lib/updater.ts +++ b/src/lib/updater.ts @@ -1,22 +1,7 @@ import { getVersion } from "@tauri-apps/api/app"; -// 可选导入:在未注册插件或非 Tauri 环境下,调用时会抛错,外层需做兜底 -// 我们按需加载并在运行时捕获错误,避免构建期类型问题 -// eslint-disable-next-line @typescript-eslint/consistent-type-imports -import type { Update } from "@tauri-apps/plugin-updater"; - export type UpdateChannel = "stable" | "beta"; -export type UpdaterPhase = - | "idle" - | "checking" - | "available" - | "downloading" - | "installing" - | "restarting" - | "upToDate" - | "error"; - export interface UpdateInfo { currentVersion: string; availableVersion: string; @@ -24,64 +9,11 @@ export interface UpdateInfo { pubDate?: string; } -export interface UpdateProgressEvent { - event: "Started" | "Progress" | "Finished"; - total?: number; - downloaded?: number; -} - -export interface UpdateHandle { - version: string; - notes?: string; - date?: string; - downloadAndInstall: ( - onProgress?: (e: UpdateProgressEvent) => void, - ) => Promise; - download?: () => Promise; - install?: () => Promise; -} - export interface CheckOptions { timeout?: number; channel?: UpdateChannel; } -function mapUpdateHandle(raw: Update): UpdateHandle { - return { - version: (raw as any).version ?? "", - notes: (raw as any).notes, - date: (raw as any).date, - async downloadAndInstall(onProgress?: (e: UpdateProgressEvent) => void) { - await (raw as any).downloadAndInstall((evt: any) => { - if (!onProgress) return; - const mapped: UpdateProgressEvent = { - event: evt?.event, - }; - if (evt?.event === "Started") { - mapped.total = evt?.data?.contentLength ?? 0; - mapped.downloaded = 0; - } else if (evt?.event === "Progress") { - mapped.downloaded = evt?.data?.chunkLength ?? 0; // 累积由调用方完成 - } - onProgress(mapped); - }); - }, - // 透传可选 API(若插件版本支持) - download: (raw as any).download - ? async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - await (raw as any).download(); - } - : undefined, - install: (raw as any).install - ? async () => { - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - await (raw as any).install(); - } - : undefined, - }; -} - export async function getCurrentVersion(): Promise { try { return await getVersion(); @@ -93,8 +25,7 @@ export async function getCurrentVersion(): Promise { export async function checkForUpdate( opts: CheckOptions = {}, ): Promise< - | { status: "up-to-date" } - | { status: "available"; info: UpdateInfo; update: UpdateHandle } + { status: "up-to-date" } | { status: "available"; info: UpdateInfo } > { // 动态引入,避免在未安装插件时导致打包期问题 const { check } = await import("@tauri-apps/plugin-updater"); @@ -106,21 +37,12 @@ export async function checkForUpdate( return { status: "up-to-date" }; } - const mapped = mapUpdateHandle(update); const info: UpdateInfo = { currentVersion, - availableVersion: mapped.version, - notes: mapped.notes, - pubDate: mapped.date, + availableVersion: (update as any).version ?? "", + notes: (update as any).notes, + pubDate: (update as any).date, }; - return { status: "available", info, update: mapped }; + return { status: "available", info }; } - -export async function relaunchApp(): Promise { - const { relaunch } = await import("@tauri-apps/plugin-process"); - await relaunch(); -} - -// 旧的聚合更新流程已由调用方直接使用 updateHandle 取代 -// 如需单函数封装,可在需要时基于 checkForUpdate + updateHandle 复合调用