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:
Jason
2026-06-03 16:36:54 +08:00
parent a3598fd976
commit 7d0eacac33
6 changed files with 100 additions and 103 deletions
+12 -14
View File
@@ -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 {
+1 -6
View File
@@ -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,
+4
View File
@@ -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
View File
@@ -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 复合调用