From f57e25a277625518a68eb01afcca9a051a4a13ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=BB=9D=E5=B0=98?= <237809796@qq.com> Date: Mon, 28 Sep 2026 23:22:55 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D:=20=E5=AE=A1=E6=89=B9?= =?UTF-8?q?=E6=B5=AE=E7=AA=97=E6=97=A0=E6=B3=95=E5=85=B3=E9=97=AD(?= =?UTF-8?q?=E6=89=98=E7=9B=98=E6=8B=A6=E6=88=AA=E8=AF=AF=E4=BC=A4+?= =?UTF-8?q?=E5=85=9C=E5=BA=95=E5=A4=B1=E6=95=88)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因=关窗拦截全局生效误伤浮窗等JS窗口,close被吞成隐藏僵尸永不销毁, 且close() resolve≠销毁使前端兜底被误取消;改仅主窗口拦截,关窗兜底 按tauri://destroyed判定超时强制销毁,主窗口closePopup同步加固 --- src-tauri/src/lib.rs | 7 +++- src/composables/ai/useApprovalPopup.ts | 35 +++++++++++++---- src/views/ApprovalPopup.vue | 52 +++++++++++++------------- 3 files changed, 61 insertions(+), 33 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 83d536e..9944c0b 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -510,9 +510,14 @@ pub fn run() { // Phase3: 内嵌 HTTP MCP server 状态查询 crate::mcp::mcp_get_status, ]) - // Phase3: 关窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程) + // 关主窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程)。 + // 仅拦 main:approval-popup / ai-detached / fe-detached 等 JS 创建窗口必须放行 + // 真正关闭,否则 close 被吞成隐藏僵尸窗,浮窗「正在关闭…」卡死、新审批推给不可见窗口。 .on_window_event(|window, event| { if let tauri::WindowEvent::CloseRequested { api, .. } = event { + if window.label() != "main" { + return; // 非主窗口放行真正销毁 + } api.prevent_close(); // 拦截真正关闭 let _ = window.hide(); // 隐藏到托盘 } diff --git a/src/composables/ai/useApprovalPopup.ts b/src/composables/ai/useApprovalPopup.ts index 2db1012..b218d1b 100644 --- a/src/composables/ai/useApprovalPopup.ts +++ b/src/composables/ai/useApprovalPopup.ts @@ -31,7 +31,7 @@ const EVT_ACTIVATE = 'approval-popup-activate' const EVT_CLOSED = 'approval-popup-closed' /** 模块级:当前浮窗引用 + 是否用户手动关闭(防 watch 重复弹) */ -type PopupWin = { close: () => Promise } +type PopupWin = { close: () => Promise; destroy: () => Promise } let _popupWin: PopupWin | null = null let _userClosed = false let _unlistenActivate: (() => void) | null = null @@ -138,14 +138,35 @@ async function openOrUpdate(approvals: AiToolCallInfo[]): Promise { } } -/** 关闭浮窗(若存在) */ +/** close() 超时降级 destroy() 的阈值(对齐浮窗侧 closeWithFallback 的 CLOSE_TIMEOUT_MS) */ +const CLOSE_TIMEOUT_MS = 1500 + +/** 关闭浮窗(若存在)。 + * 根因:Tauri 2.x 的 close() 走 closeRequested(非强制),偶发原生窗口未及时销毁,而主窗口侧 + * 此前裸 await close() 无兜底 → 浮窗空态 hint「正在关闭…」永久残留。 + * 对齐浮窗侧 closeWithFallback 语义:取引用后立即置空 _popupWin(防重复关/防关闭期间拿到旧引用), + * close 失败或超时未落定 → destroy() 强制销毁。 */ async function closePopup(): Promise { + const win = _popupWin + _popupWin = null + if (!win) return + const timer = setTimeout(() => { + console.warn('[ApprovalPopup] close() 超时,降级 destroy()') + win.destroy().catch((e: unknown) => { + console.error('[ApprovalPopup] destroy() 兜底也失败:', e) + }) + }, CLOSE_TIMEOUT_MS) try { - if (!_popupWin) return - await _popupWin.close() - _popupWin = null - } catch { - /* 浮窗已被关/不可用,忽略 */ + await win.close() + clearTimeout(timer) + } catch (e) { + clearTimeout(timer) + console.warn('[ApprovalPopup] close() 失败,降级 destroy():', e) + try { + await win.destroy() + } catch (e2) { + console.error('[ApprovalPopup] destroy() 兜底也失败:', e2) + } } } diff --git a/src/views/ApprovalPopup.vue b/src/views/ApprovalPopup.vue index e21664d..f3597b7 100644 --- a/src/views/ApprovalPopup.vue +++ b/src/views/ApprovalPopup.vue @@ -246,10 +246,7 @@ function _syncArrivalTimes(list: AiToolCallInfo[]) { let _unlistenUpdate: UnlistenFn | null = null let _positioned = false -/** 关窗兜底:防止 close() promise 卡住或被拒导致「正在关闭…」永久停留。 - * close() 在 Tauri 2.x 触发 closeRequested(非强制),某些情况下原生窗口未及时销毁 → - * Vue 组件保持挂载 + 空态 hint 永久可见。此处超时强制 destroy() 兜底。 */ -let _closeFallbackTimer: ReturnType | null = null +/** close() 后窗口仍未销毁则强制 destroy() 的阈值(closeWithFallback 使用) */ const CLOSE_TIMEOUT_MS = 1500 /** Esc 键关浮窗(键盘可达性)。模块级命名函数,onBeforeUnmount 据此精确移除同一引用。 @@ -349,6 +346,13 @@ onMounted(async () => { _tickWaitSecs() if (_waitTimer) clearInterval(_waitTimer) _waitTimer = setInterval(_tickWaitSecs, 1000) + + // 弹出即空态自检:创建竞态下浮窗可能收到已过期的空快照(主窗口 openOrUpdate 推快照时 + // 列表恰好已清空),此后不会再有任何 EVT_UPDATE → 无人触发关闭,空态「正在关闭…」永久残留。 + // mount 2s 后仍为空则主动走 maybeAutoClose 兜底关窗(正常有数据时此自检无操作)。 + setTimeout(() => { + if (approvals.value.length === 0) void maybeAutoClose() + }, 2000) }) onBeforeUnmount(() => { @@ -358,42 +362,40 @@ onBeforeUnmount(() => { window.removeEventListener('keydown', _onKeyDown) // H1:清挂起计时器(防卸载后仍 tick 致空 Map 操作/内存泄漏) if (_waitTimer) { clearInterval(_waitTimer); _waitTimer = null } - if (_closeFallbackTimer) { - clearTimeout(_closeFallbackTimer) - _closeFallbackTimer = null - } }) /** 关窗统一实现:close 先行(优雅),超时/失败兜底 destroy()(强制销毁)。 * 根因修复(BUG3+):原实现仅 await getCurrentWebviewWindow().close(),而 Tauri 2.x 的 * close() 触发 closeRequested(非强制),若原生窗口因任意原因未及时销毁(已知偶发), * Vue 组件保持挂载 → 浮窗内空态 hint「正在关闭…」永久可见,UI 卡死。 - * - 兜底1:close() 失败 → 立即 destroy() 强制销毁 - * - 兜底2:close() 1.5s 未完成 → destroy() 强制销毁(超时定时器) - * - 兜底3:destroy() 也失败 → 控制台报错(不再静默吞掉,便于排查) */ + * 判定依据:close() 只是派发关闭消息即 resolve,即使关闭被原生层 prevent_close 拦截 + * (窗口仅隐藏未销毁),promise 照样成功 → 不能以 resolve 作为「窗口已关」依据, + * 以 once('tauri://destroyed') 置位 destroyed 标志为准,超时触发时若尚未销毁才 destroy。 + * - 兜底1:close() 失败 → 未销毁则立即 destroy() 强制销毁 + * - 兜底2:close() resolve 后 1.5s 仍未销毁 → destroy() 强制销毁(超时定时器) + * - 兜底3:destroy() 也失败 → 控制台报错(不再静默吞掉,便于排查) + * 兜底定时器不取消:并发调用(onClose 与 maybeAutoClose)各自持有 timer, + * destroy 幂等无害,以 destroyed 事件兜住重复强制销毁。 */ async function closeWithFallback(): Promise { const win = getCurrentWebviewWindow() - // 超时兜底:close 卡住则强制 destroy - _closeFallbackTimer = setTimeout(() => { - _closeFallbackTimer = null - console.warn('[ApprovalPopup] close() 超时,降级 destroy()') + // 以 destroyed 事件为「窗口真正销毁」的判定依据(close() resolve≠销毁,见上) + let destroyed = false + void win.once('tauri://destroyed', () => { destroyed = true }) + // 超时兜底:close 后窗口迟迟未销毁则强制 destroy + setTimeout(() => { + if (destroyed) return + console.warn('[ApprovalPopup] close() 后窗口未销毁,降级 destroy()') win.destroy().catch((e: unknown) => { console.error('[ApprovalPopup] destroy() 兜底也失败:', e) }) }, CLOSE_TIMEOUT_MS) try { await win.close() - // close 成功:取消超时定时器(若已触发则 destroy 已并行无害) - if (_closeFallbackTimer) { - clearTimeout(_closeFallbackTimer) - _closeFallbackTimer = null - } + // close() resolve 不取消兜底定时器:被 prevent_close 时 promise 照样成功而窗口未必销毁, + // 是否强制 destroy 交给 destroyed 标志与超时判定(见上) } catch (e) { - // close 失败:取消超时,降级 destroy() - if (_closeFallbackTimer) { - clearTimeout(_closeFallbackTimer) - _closeFallbackTimer = null - } + // close 失败:未销毁则降级 destroy() + if (destroyed) return console.warn('[ApprovalPopup] close() 失败,降级 destroy():', e) try { await win.destroy()