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 复合调用