Rust in Everything
基于 tauri 2.12.5

窗口关不掉

主线位置:事件主线的反方向。窗口的 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 的回调,什么也不发生。

📝NOTE
冷知识:导航时清理 JS 监听器,加上过,又拿掉了

#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。

💬 关联讨论

发起讨论
加载中...