Compare commits

..

2 Commits

Author SHA1 Message Date
Jason 7e1c85feb6 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).
2026-06-11 19:55:48 +08:00
Jason 7d0eacac33 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.
2026-06-11 17:37:24 +08:00
10 changed files with 115 additions and 223 deletions
+64 -6
View File
@@ -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,
@@ -79,17 +80,74 @@ pub async fn restart_app(app: AppHandle) -> Result<bool, String> {
// 在后台延迟重启,让函数有时间返回响应
tauri::async_runtime::spawn(async move {
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
// app.restart() 走 RESTART_EXIT_CODE 路径,ExitRequested 处理器会直接
// 放行给 Tauri 默认 re-exec,不执行代理/Live 清理。但本命令用于
// app_config_dir 变更后的重启:新实例会切到新数据库,拿不到旧库里的
// Live 备份,无法恢复被接管的 Live 配置。因此必须趁旧实例的事件循环
// 仍存活,在这里同步完成恢复(保留代理状态,新实例启动时自动重新接管)。
crate::cleanup_before_exit(&app).await;
app.restart();
});
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() 内启动安装器并直接退出当前进程
// (插件内部 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<Option<String>, String> {
+28 -1
View File
@@ -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};
-16
View File
@@ -1133,22 +1133,6 @@ pub fn import_default_config(state: &AppState, app_type: AppType) -> Result<bool
return Ok(false);
}
// 拒绝把"被代理接管的 Live"导入为供应商:接管期间 Live 里只有
// PROXY_MANAGED 占位符和本地代理地址,不是用户的真实配置。一旦导入,
// 它会成为 current providerSSOT),后续"无备份恢复"路径会把占位符
// 当真实配置写回 Live,永久卡在已失效的本地代理上。
// 典型触发场景:代理接管开启时切换 app_config_dir 并重启,新数据库首启导入。
if state
.proxy_service
.detect_takeover_in_live_config_for_app(&app_type)
{
return Err(AppError::localized(
"provider.import.live_taken_over",
"Live 配置当前处于代理接管状态(包含占位符),不能导入为供应商。请先关闭代理接管或恢复 Live 配置后重试。",
"The live config is currently taken over by the proxy (contains placeholders) and cannot be imported as a provider. Disable proxy takeover or restore the live config first.",
));
}
let settings_config = match app_type {
AppType::Codex => crate::codex_config::read_codex_live_settings()?,
AppType::Claude => {
+1 -11
View File
@@ -1632,7 +1632,7 @@ impl ProxyService {
///
/// 返回值:
/// - Ok(true):已成功写回
/// - Ok(false):缺少当前供应商/供应商不存在/供应商本身含占位符,无法写回
/// - Ok(false):缺少当前供应商/供应商不存在,无法写回
fn restore_live_from_ssot_for_app(&self, app_type: &AppType) -> Result<bool, String> {
let current_id = crate::settings::get_effective_current_provider(&self.db, app_type)
.map_err(|e| format!("获取 {app_type:?} 当前供应商失败: {e}"))?;
@@ -1650,16 +1650,6 @@ impl ProxyService {
return Ok(false);
};
// 供应商配置本身含接管占位符时不可写回(历史异常:接管期间 Live 被
// 误导入成了供应商)。写回只会把占位符固化进 Live;返回 Ok(false)
// 让调用方落到"清理占位符"兜底。
if Self::live_has_proxy_placeholder_for_app(app_type, &provider.settings_config) {
log::warn!(
"{app_type:?} 当前供应商配置含代理接管占位符(疑似接管期间被导入的残留),跳过 SSOT 写回,改走占位符清理"
);
return Ok(false);
}
write_live_with_common_config(self.db.as_ref(), app_type, provider)
.map_err(|e| format!("写入 {app_type:?} Live 配置失败: {e}"))?;
-27
View File
@@ -588,30 +588,3 @@ fn switch_provider_codex_missing_auth_returns_error_and_keeps_state() {
"current provider should remain empty or be the attempted id on failure, got: {current_id:?}"
);
}
#[test]
fn import_refuses_live_config_under_proxy_takeover() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
ensure_test_home();
// 接管态 Codex Liveauth 是 PROXY_MANAGED 占位符,不是用户真实配置
let auth = json!({"OPENAI_API_KEY": "PROXY_MANAGED"});
let config = r#"model = "gpt-5"
"#;
write_codex_live_atomic(&auth, Some(config)).expect("seed taken-over codex live");
let state = create_test_state().expect("create test state");
import_default_config_test_hook(&state, AppType::Codex)
.expect_err("importing a taken-over live config must fail");
let providers = state
.db
.get_all_providers(AppType::Codex.as_str())
.expect("get codex providers");
assert!(
providers.is_empty(),
"taken-over live import must not create providers"
);
}
-59
View File
@@ -1911,62 +1911,3 @@ fn provider_service_delete_current_provider_returns_error() {
other => panic!("expected Config/Message error, got {other:?}"),
}
}
#[test]
fn recover_from_crash_without_backup_cleans_placeholder_instead_of_writing_it_back() {
let _guard = test_mutex().lock().expect("acquire test mutex");
reset_test_fs();
let _home = ensure_test_home();
// 接管态 Claude Live,且 DB 中无备份(模拟切换 app_config_dir 后新库首启的场景)
let taken_over_live = json!({
"env": {
"ANTHROPIC_BASE_URL": "http://127.0.0.1:15721",
"ANTHROPIC_AUTH_TOKEN": "PROXY_MANAGED"
}
});
let settings_path = get_claude_settings_path();
std::fs::create_dir_all(settings_path.parent().expect("settings dir")).expect("create dir");
std::fs::write(
&settings_path,
serde_json::to_string_pretty(&taken_over_live).expect("serialize taken over live"),
)
.expect("write taken over live");
let state = create_test_state().expect("create test state");
// 模拟历史异常:接管态 Live 已被导入成 current providerSSOT 被污染)
let provider = Provider::with_id(
"default".to_string(),
"default".to_string(),
taken_over_live.clone(),
None,
);
state
.db
.save_provider(AppType::Claude.as_str(), &provider)
.expect("save placeholder provider");
state
.db
.set_current_provider(AppType::Claude.as_str(), "default")
.expect("set current provider");
futures::executor::block_on(state.proxy_service.recover_from_crash())
.expect("recover from crash");
let live_after: serde_json::Value =
read_json_file(&settings_path).expect("read live settings after recovery");
let env = live_after.get("env").cloned().unwrap_or_else(|| json!({}));
assert_ne!(
env.get("ANTHROPIC_AUTH_TOKEN").and_then(|v| v.as_str()),
Some("PROXY_MANAGED"),
"recovery must not write the placeholder back to live"
);
assert!(
env.get("ANTHROPIC_BASE_URL")
.and_then(|v| v.as_str())
.map(|url| !url.starts_with("http://127.0.0.1"))
.unwrap_or(true),
"recovery must drop the local proxy base URL"
);
}
+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 复合调用