窗口关不掉
主线位置:事件主线的反方向。窗口的
CloseRequested由 tao 在主线程上报,tauri 自己的窗口事件处理函数先看「有没有页面监听tauri://close-requested」,有就拦下关窗、把事件emit给页面,由页面经调用主线(plugin:window|destroy,要过 ACL)把窗口关掉。关窗的完整流程见 Window、Webview 与 WebviewWindow,JS 监听器怎么登记见 AppManager 与 Manager trait。
sequenceDiagram participant U as 用户或 close participant R as on_window_event participant P as 页面 U->>R: CloseRequested R->>R: has_js_listener 为真 prevent_close R->>P: emit tauri://close-requested P->>R: invoke plugin:window destroy R-->>P: 没有 allow-destroy 拒绝 Note over U,P: 窗口还在
实验 crate exp-tauri/pit3-close:从模板 base 复制,配置 withGlobalTauri: true,能力只给 main 窗口 core:default。页面准备好后调 ready 命令,Rust 起一个线程,200 ms 后对窗口调 close()(和用户点关闭按钮走同一条路),再过 800 ms 打印还剩哪些窗口并 exit(0);Builder::on_window_event 打印 CloseRequested / Destroyed,run 的回调打印 RunEvent::Exit。PAGE 环境变量选择加载哪个页面。tauri =2.12.5,--features tauri/custom-protocol,隐藏窗口,平台 M2 Max、macOS 27、rustc 1.99.0。
现象
一:onCloseRequested,能力用默认的 core:default。 「关闭前先保存草稿」的常见写法:
const { invoke } = window.__TAURI__.core;
const report = (msg) => invoke('report', { msg });
addEventListener('unhandledrejection', (e) => report(`unhandledrejection: ${e.reason}`));
(async () => {
const win = window.__TAURI__.window.getCurrentWindow();
// "ask before closing" / "save the draft first"
await win.onCloseRequested(async () => {
await report('onCloseRequested handler ran');
});
await invoke('ready');
})();
debug 构建:
771ms Rust: window.close() (same as clicking the close button)
771ms WindowEvent::CloseRequested main
772ms JS: onCloseRequested handler ran
773ms JS: unhandledrejection: window.destroy not allowed. Permissions associated with this command: core:window:allow-destroy
1575ms Rust: 800ms later, windows = ["main"]
1576ms RunEvent::Exit
release 构建,拒绝信息变成 Command plugin:window|destroy not allowed by ACL,结果一样:
722ms Rust: window.close() (same as clicking the close button)
722ms WindowEvent::CloseRequested main
723ms JS: onCloseRequested handler ran
724ms JS: unhandledrejection: Command plugin:window|destroy not allowed by ACL
1532ms Rust: 800ms later, windows = ["main"]
1533ms RunEvent::Exit
处理函数执行了,窗口没关,也没有 Destroyed。拒绝发生在 onCloseRequested 内部调的 destroy() 上,处理函数之外没人接这个 Promise,页面上只是一个未处理的拒绝,不挂 unhandledrejection 根本看不见。最后的 RunEvent::Exit 来自实验自己的 app.exit(0)。
二:权限给齐了,但页面导航过。 能力加上 core:window:allow-destroy(第一种情形的修法,见下文),页面 nav.html 注册 onCloseRequested 后跳到同一应用里的另一个页面 page2.html,后者不监听关窗:
(async () => {
const win = window.__TAURI__.window.getCurrentWindow();
await win.onCloseRequested(() => {});
await window.__TAURI__.core.invoke('report', { msg: 'nav.html: onCloseRequested registered, navigating to page2.html' });
location.href = 'page2.html';
})();
449ms JS: nav.html: onCloseRequested registered, navigating to page2.html
452ms JS: page2.html loaded, no close listener here
662ms Rust: window.close() (same as clicking the close button)
662ms WindowEvent::CloseRequested main
1472ms Rust: 800ms later, windows = ["main"]
1474ms RunEvent::Exit
page2.html 里没有任何关窗相关的代码,窗口照样关不掉,这次连拒绝都没有。
出处
tauri 的窗口事件处理函数 on_window_event 对 CloseRequested 是这样做的:
WindowEvent::CloseRequested { api } => {
if window.has_js_listener(WINDOW_CLOSE_REQUESTED_EVENT) {
api.prevent_close();
}
window.emit_to_window(WINDOW_CLOSE_REQUESTED_EVENT, &())?;
}
只要 Rust 侧记着「有 JS 监听器」,关窗就被拦下,之后全靠页面。@tauri-apps/api 的 onCloseRequested 在用户处理函数返回、没有 preventDefault 时调 this.destroy():
async onCloseRequested(
handler: (event: CloseRequestedEvent) => void | Promise<void>
): Promise<UnlistenFn> {
// eslint-disable-next-line @typescript-eslint/no-misused-promises
return this.listen(TauriEvent.WINDOW_CLOSE_REQUESTED, async (event) => {
const evt = new CloseRequestedEvent(event)
await handler(evt)
if (!evt.isPreventDefault()) {
await this.destroy()
}
})
}
destroy 是插件命令 plugin:window|destroy,要过 ACL;core:window:default 几乎只放行读取类命令,不含 allow-destroy。这就是第一种情形。
第二种情形出在「Rust 侧记着有 JS 监听器」这张表上。JS 的 listen 经 plugin:event|listen 把监听器登记进 Listeners 的 js_event_listeners,键是 webview 的标签。这张表只在 webview 被销毁时清理(remove_webview_listeners,由 on_window_close、on_webview_close 调用),页面导航和刷新都不动它。nav.html 登记的那条留在表里,has_js_listener 仍然为真,关窗被拦;事件 eval 进 page2.html,那里没有对应 id 的回调,什么也不发生。
#8930(「fix(event): clear residual js listeners」,首次发布于 tauri 2.0.0-beta.8)在页面加载时调 unlisten_all_js 清掉这个 webview 的全部 JS 监听器,CHANGELOG 原文:「Clear JS event listeneres on page load, which fixes zombie listeners when the page reloads.」两周后的 #9151(「enhance(core/event): filter js listeners on rust side only then emit filtered ids to be triggered」,首次发布于 2.0.0-beta.12)把 unlisten_all_js 连同这次清理一起删了,提交说明的第一句是「fix regression introduced in https://github.com/tauri-apps/tauri/pull/8930」,CHANGELOG 只记了「Improve and optimize event emit calls.」。之后再没有按导航清理的代码;2.12.0 的 #15604 补上的是 webview 销毁时的清理(remove_webview_js_listeners),不管导航。
修法
第一种:把 allow-destroy 给这个窗口。
{
"identifier": "main",
"windows": ["main"],
"permissions": ["core:default", "core:window:allow-destroy"]
}
742ms Rust: window.close() (same as clicking the close button)
743ms WindowEvent::CloseRequested main
744ms JS: onCloseRequested handler ran
750ms WindowEvent::Destroyed main
755ms RunEvent::Exit
destroy() 通过,窗口销毁;这是最后一个窗口,应用随之退出(RunEvent::Exit 在实验的 800 ms 计时之前就来了)。
第二种:离开页面前取消监听。 onCloseRequested 返回一个取消函数,导航前调它:
const unlisten = await win.onCloseRequested(() => {});
// ...
unlisten();
location.href = 'page2.html';
450ms JS: nav-fix.html: unlisten, then navigate to page2.html
453ms JS: page2.html loaded, no close listener here
659ms Rust: window.close() (same as clicking the close button)
659ms WindowEvent::CloseRequested main
665ms WindowEvent::Destroyed main
671ms RunEvent::Exit
用前端路由(不整页导航)的单页应用不受第二种影响:页面没换,监听器还活着。整页跳转、或者 location.reload() 之后不再注册的页面,都要在离开前 unlisten,或者让每个页面都注册同样的关窗处理。
Rust 侧永远有退路:window.destroy() 跳过 CloseRequested,不看 JS 监听器。
怎么确认没中招
- 搜前端代码里的
onCloseRequested和listen('tauri://close-requested':有的话,对应窗口的能力里必须有core:window:allow-destroy。 - 页面挂
unhandledrejection监听器并在开发构建里上报,destroy被拒只会以这种形式出现。 - 测一次「注册之后整页导航、再关窗」:窗口关不掉就是第二种。
小结
- 页面监听
tauri://close-requested(onCloseRequested)时,Rust 自动prevent_close,关窗交给 JS 调destroy()。 core:default不含core:window:allow-destroy,destroy()被拒,拒绝只是一个未处理的 Promise,窗口关不掉。- JS 监听器登记在 Rust 侧、按 webview 标签存,只在 webview 销毁时清理;整页导航后旧监听器还在,关窗照样被拦。离开页面前
unlisten。