mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-07-24 12:44:18 +08:00
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.
This commit is contained in:
@@ -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,66 @@ pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// 下载并安装应用更新,然后由后端直接重启应用。
|
||||
///
|
||||
/// macOS 更新会原地替换 `.app` bundle。如果先返回前端、再让旧 WebView 调
|
||||
/// `process.relaunch()`,旧进程可能已经处在 bundle 被替换后的不稳定窗口期。
|
||||
/// 这里把退出清理、安装和重启串在同一个后端流程中,避免依赖旧前端继续执行。
|
||||
#[tauri::command]
|
||||
pub async fn install_update_and_restart(app: AppHandle) -> Result<bool, String> {
|
||||
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() 内启动安装器并直接退出当前进程。
|
||||
// 因此清理只能放在 install 前执行。
|
||||
crate::save_window_state_before_exit(&app);
|
||||
crate::cleanup_before_exit(&app).await;
|
||||
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<Option<String>, String> {
|
||||
|
||||
@@ -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,
|
||||
@@ -1962,6 +1963,22 @@ 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 锁后重启当前应用。
|
||||
pub fn restart_process(app_handle: &tauri::AppHandle) -> ! {
|
||||
destroy_single_instance_lock(app_handle);
|
||||
tauri::process::restart(&app_handle.env());
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{classify_exit_request, ExitRequestAction};
|
||||
|
||||
@@ -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<string, WslShellPreference>
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<UpdateInfo | null>(null);
|
||||
const [updateHandle, setUpdateHandle] = useState<UpdateHandle | null>(null);
|
||||
const [isChecking, setIsChecking] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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,
|
||||
|
||||
@@ -36,6 +36,10 @@ export const settingsApi = {
|
||||
return await invoke("restart_app");
|
||||
},
|
||||
|
||||
async installUpdateAndRestart(): Promise<boolean> {
|
||||
return await invoke("install_update_and_restart");
|
||||
},
|
||||
|
||||
async checkUpdates(): Promise<void> {
|
||||
await invoke("check_for_updates");
|
||||
},
|
||||
|
||||
+5
-83
@@ -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<void>;
|
||||
download?: () => Promise<void>;
|
||||
install?: () => Promise<void>;
|
||||
}
|
||||
|
||||
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<string> {
|
||||
try {
|
||||
return await getVersion();
|
||||
@@ -93,8 +25,7 @@ export async function getCurrentVersion(): Promise<string> {
|
||||
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<void> {
|
||||
const { relaunch } = await import("@tauri-apps/plugin-process");
|
||||
await relaunch();
|
||||
}
|
||||
|
||||
// 旧的聚合更新流程已由调用方直接使用 updateHandle 取代
|
||||
// 如需单函数封装,可在需要时基于 checkForUpdate + updateHandle 复合调用
|
||||
|
||||
Reference in New Issue
Block a user