修复: 审批浮窗无法关闭(托盘拦截误伤+兜底失效)

根因=关窗拦截全局生效误伤浮窗等JS窗口,close被吞成隐藏僵尸永不销毁,
且close() resolve≠销毁使前端兜底被误取消;改仅主窗口拦截,关窗兜底
按tauri://destroyed判定超时强制销毁,主窗口closePopup同步加固
This commit is contained in:
lxy
2026-09-28 23:22:55 +08:00
parent 4840f7ebc6
commit f57e25a277
3 changed files with 61 additions and 33 deletions
+6 -1
View File
@@ -510,9 +510,14 @@ pub fn run() {
// Phase3: 内嵌 HTTP MCP server 状态查询 // Phase3: 内嵌 HTTP MCP server 状态查询
crate::mcp::mcp_get_status, crate::mcp::mcp_get_status,
]) ])
// Phase3: 关窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程) // 关主窗口不退出,隐藏到托盘常驻(托盘「退出」菜单才真正结束进程)。
// 仅拦 main:approval-popup / ai-detached / fe-detached 等 JS 创建窗口必须放行
// 真正关闭,否则 close 被吞成隐藏僵尸窗,浮窗「正在关闭…」卡死、新审批推给不可见窗口。
.on_window_event(|window, event| { .on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { api, .. } = event { if let tauri::WindowEvent::CloseRequested { api, .. } = event {
if window.label() != "main" {
return; // 非主窗口放行真正销毁
}
api.prevent_close(); // 拦截真正关闭 api.prevent_close(); // 拦截真正关闭
let _ = window.hide(); // 隐藏到托盘 let _ = window.hide(); // 隐藏到托盘
} }
+28 -7
View File
@@ -31,7 +31,7 @@ const EVT_ACTIVATE = 'approval-popup-activate'
const EVT_CLOSED = 'approval-popup-closed' const EVT_CLOSED = 'approval-popup-closed'
/** 模块级:当前浮窗引用 + 是否用户手动关闭(防 watch 重复弹) */ /** 模块级:当前浮窗引用 + 是否用户手动关闭(防 watch 重复弹) */
type PopupWin = { close: () => Promise<void> } type PopupWin = { close: () => Promise<void>; destroy: () => Promise<void> }
let _popupWin: PopupWin | null = null let _popupWin: PopupWin | null = null
let _userClosed = false let _userClosed = false
let _unlistenActivate: (() => void) | null = null let _unlistenActivate: (() => void) | null = null
@@ -138,14 +138,35 @@ async function openOrUpdate(approvals: AiToolCallInfo[]): Promise<void> {
} }
} }
/** 关闭浮窗(若存在) */ /** 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<void> { async function closePopup(): Promise<void> {
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 { try {
if (!_popupWin) return await win.close()
await _popupWin.close() clearTimeout(timer)
_popupWin = null } catch (e) {
} catch { clearTimeout(timer)
/* 浮窗已被关/不可用,忽略 */ console.warn('[ApprovalPopup] close() 失败,降级 destroy():', e)
try {
await win.destroy()
} catch (e2) {
console.error('[ApprovalPopup] destroy() 兜底也失败:', e2)
}
} }
} }
+27 -25
View File
@@ -246,10 +246,7 @@ function _syncArrivalTimes(list: AiToolCallInfo[]) {
let _unlistenUpdate: UnlistenFn | null = null let _unlistenUpdate: UnlistenFn | null = null
let _positioned = false let _positioned = false
/** 关窗兜底:防止 close() promise 卡住或被拒导致「正在关闭…」永久停留。 /** close() 后窗口仍未销毁则强制 destroy() 的阈值(closeWithFallback 使用) */
* close() 在 Tauri 2.x 触发 closeRequested(非强制),某些情况下原生窗口未及时销毁 →
* Vue 组件保持挂载 + 空态 hint 永久可见。此处超时强制 destroy() 兜底。 */
let _closeFallbackTimer: ReturnType<typeof setTimeout> | null = null
const CLOSE_TIMEOUT_MS = 1500 const CLOSE_TIMEOUT_MS = 1500
/** Esc 键关浮窗(键盘可达性)。模块级命名函数,onBeforeUnmount 据此精确移除同一引用。 /** Esc 键关浮窗(键盘可达性)。模块级命名函数,onBeforeUnmount 据此精确移除同一引用。
@@ -349,6 +346,13 @@ onMounted(async () => {
_tickWaitSecs() _tickWaitSecs()
if (_waitTimer) clearInterval(_waitTimer) if (_waitTimer) clearInterval(_waitTimer)
_waitTimer = setInterval(_tickWaitSecs, 1000) _waitTimer = setInterval(_tickWaitSecs, 1000)
// 弹出即空态自检:创建竞态下浮窗可能收到已过期的空快照(主窗口 openOrUpdate 推快照时
// 列表恰好已清空),此后不会再有任何 EVT_UPDATE → 无人触发关闭,空态「正在关闭…」永久残留。
// mount 2s 后仍为空则主动走 maybeAutoClose 兜底关窗(正常有数据时此自检无操作)。
setTimeout(() => {
if (approvals.value.length === 0) void maybeAutoClose()
}, 2000)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
@@ -358,42 +362,40 @@ onBeforeUnmount(() => {
window.removeEventListener('keydown', _onKeyDown) window.removeEventListener('keydown', _onKeyDown)
// H1:清挂起计时器(防卸载后仍 tick 致空 Map 操作/内存泄漏) // H1:清挂起计时器(防卸载后仍 tick 致空 Map 操作/内存泄漏)
if (_waitTimer) { clearInterval(_waitTimer); _waitTimer = null } if (_waitTimer) { clearInterval(_waitTimer); _waitTimer = null }
if (_closeFallbackTimer) {
clearTimeout(_closeFallbackTimer)
_closeFallbackTimer = null
}
}) })
/** 关窗统一实现:close 先行(优雅),超时/失败兜底 destroy()(强制销毁)。 /** 关窗统一实现:close 先行(优雅),超时/失败兜底 destroy()(强制销毁)。
* 根因修复(BUG3+):原实现仅 await getCurrentWebviewWindow().close(),而 Tauri 2.x 的 * 根因修复(BUG3+):原实现仅 await getCurrentWebviewWindow().close(),而 Tauri 2.x 的
* close() 触发 closeRequested(非强制),若原生窗口因任意原因未及时销毁(已知偶发), * close() 触发 closeRequested(非强制),若原生窗口因任意原因未及时销毁(已知偶发),
* Vue 组件保持挂载 → 浮窗内空态 hint「正在关闭…」永久可见,UI 卡死。 * Vue 组件保持挂载 → 浮窗内空态 hint「正在关闭…」永久可见,UI 卡死。
* - 兜底1:close() 失败 → 立即 destroy() 强制销毁 * 判定依据:close() 只是派发关闭消息即 resolve,即使关闭被原生层 prevent_close 拦截
* - 兜底2:close() 1.5s 未完成 → destroy() 强制销毁(超时定时器) * (窗口仅隐藏未销毁),promise 照样成功 → 不能以 resolve 作为「窗口已关」依据,
* - 兜底3:destroy() 也失败 → 控制台报错(不再静默吞掉,便于排查) */ * 以 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<void> { async function closeWithFallback(): Promise<void> {
const win = getCurrentWebviewWindow() const win = getCurrentWebviewWindow()
// 超时兜底:close 卡住则强制 destroy // 以 destroyed 事件为「窗口真正销毁」的判定依据(close() resolve≠销毁,见上)
_closeFallbackTimer = setTimeout(() => { let destroyed = false
_closeFallbackTimer = null void win.once('tauri://destroyed', () => { destroyed = true })
console.warn('[ApprovalPopup] close() 超时,降级 destroy()') // 超时兜底:close 后窗口迟迟未销毁则强制 destroy
setTimeout(() => {
if (destroyed) return
console.warn('[ApprovalPopup] close() 后窗口未销毁,降级 destroy()')
win.destroy().catch((e: unknown) => { win.destroy().catch((e: unknown) => {
console.error('[ApprovalPopup] destroy() 兜底也失败:', e) console.error('[ApprovalPopup] destroy() 兜底也失败:', e)
}) })
}, CLOSE_TIMEOUT_MS) }, CLOSE_TIMEOUT_MS)
try { try {
await win.close() await win.close()
// close 成功:取消超时定时器(若已触发则 destroy 已并行无害) // close() resolve 不取消兜底定时器:被 prevent_close 时 promise 照样成功而窗口未必销毁,
if (_closeFallbackTimer) { // 是否强制 destroy 交给 destroyed 标志与超时判定(见上)
clearTimeout(_closeFallbackTimer)
_closeFallbackTimer = null
}
} catch (e) { } catch (e) {
// close 失败:取消超时,降级 destroy() // close 失败:未销毁则降级 destroy()
if (_closeFallbackTimer) { if (destroyed) return
clearTimeout(_closeFallbackTimer)
_closeFallbackTimer = null
}
console.warn('[ApprovalPopup] close() 失败,降级 destroy():', e) console.warn('[ApprovalPopup] close() 失败,降级 destroy():', e)
try { try {
await win.destroy() await win.destroy()