Rust in Everything
基于 tauri 2.12.5

Window、Webview、WebviewWindow

主线位置:启动主线上「setup 按配置建窗口」那一步的展开。WebviewWindowBuilder::from_config(..).build() 先把 webview 变成 PendingWebview、再把窗口变成 PendingWindow,交给运行时建出来,最后把两者登记进 AppManager 的两张表。本页讲这两张表、标签规则、unstable 下一个窗口放多个 webview,以及窗口从 close() 到消失的事件顺序。

flowchart TB
  wwb["WebviewWindowBuilder::build"] --> withwv["WindowBuilder::with_webview"]
  withwv --> intop["into_pending_webview"]
  intop --> prepwv["prepare_webview 查 webview 标签"]
  prepwv --> ppw["prepare_pending_webview 补脚本和协议"]
  withwv --> bi["build_internal"]
  bi --> prepwin["prepare_window 查窗口标签"]
  prepwin --> cw["runtime.create_window"]
  cw --> attw["attach_window 进窗口表"]
  cw --> attv["attach_webview 进 webview 表"]
  attw --> created["emit tauri://window-created"]

本页实验 crate 是 exp-tauri/wv-win:由 base 复制,tauri 开 unstable feature,配置里 withGlobalTauri: true、窗口一律隐藏。平台 M2 Max、macOS 27、rustc 1.99.0,debug 构建,--features tauri/custom-protocol。Windows 和 Linux 只读了源码。

三个类型

Window 拿着运行时给的 DetachedWindow(里面是 WindowDispatch)和 Arc<AppManager>;Webview 拿着 DetachedWebview 和它所在的窗口:

pub struct Webview<R: Runtime> {
  pub(crate) window: Arc<Mutex<Window<R>>>,
  /// The webview created by the runtime.
  pub(crate) webview: DetachedWebview<EventLoopMessage, R>,
  /// The manager to associate this webview with.
  pub(crate) manager: Arc<AppManager<R>>,
  pub(crate) app_handle: AppHandle<R>,
  pub(crate) resources_table: Arc<Mutex<ResourceTable>>,
  use_https_scheme: bool,
}

窗口放在 Arc<Mutex<..>> 里,是因为 reparent 可以把 webview 挪到另一个窗口,挪完要改这个字段。WebviewWindow 就是一对 { window, webview },它的方法分别转发给两边。

2.x 默认只公开 WebviewWindow 这一种用法:Window::builder、Window::add_child、Webview::builder、Manager::get_window / get_webview 都带 #[cfg(feature = "unstable")]。WindowBuilder 和 WebviewBuilder 这两个结构体则用一个小宏 unstable_struct! 声明:开了 unstable 是 pub,没开是 pub(crate)。WebviewWindowBuilder 内部还是用它们拼出来的:

pub struct WebviewWindowBuilder<'a, R: Runtime, M: Manager<R>> {
  window_builder: WindowBuilder<'a, R, M>,
  webview_builder: WebviewBuilder<R>,
}

build 走了哪几步

WebviewWindowBuilder::build 只有一行,调 WindowBuilder::with_webview:

  pub(crate) fn with_webview(
    self,
    webview: WebviewBuilder<R>,
  ) -> crate::Result<(Window<R>, Webview<R>)> {
    let pending_webview = webview.into_pending_webview(self.manager, &self.label)?;
    let window = self.build_internal(Some(pending_webview))?;

    let webview = window.webviews().first().unwrap().clone();

    Ok((window, webview))
  }

先处理 webview,后处理窗口。into_pending_webview 把 builder 上的各种回调(导航、下载、标题变化、页面加载)包一层「按标签回查 Webview」的闭包,再交给 WebviewManager::prepare_webview:第一件事是查 webview 表里有没有同名标签,然后算 URL、调 prepare_pending_webview 补初始化脚本和 URI scheme 协议(见初始化脚本和 tauri:// 协议与 CSP)。

build_internal 再处理窗口:

    let mut pending = PendingWindow::new(self.window_builder, self.label)?;
    if let Some(webview) = webview {
      pending.set_webview(webview);
    }

    let app_manager = self.manager.manager();

    let pending = app_manager.window.prepare_window(pending)?;
    // ...
    let window = match &mut self.manager.runtime() {
      RuntimeOrDispatch::Runtime(runtime) => runtime.create_window(pending, handler),
      RuntimeOrDispatch::RuntimeHandle(handle) => handle.create_window(pending, handler),
      RuntimeOrDispatch::Dispatch(dispatcher) => dispatcher.create_window(pending, handler),
    }
    .map(|detached_window| {
      let window = app_manager.window.attach_window(
        self.manager.app_handle().clone(),
        detached_window.clone(),
        #[cfg(desktop)]
        window_menu,
      );

      if let Some(webview) = detached_window.webview {
        app_manager.webview.attach_webview(
          window.clone(),
          webview.webview,
          webview.use_https_scheme,
        );
      }

      window
    })?;

prepare_window 查窗口表里的同名标签,没设图标就补上默认图标。create_window 在 Wry 上一次建好 tao 窗口和 wry webview(tauri-runtime-wry 与事件循环)。建好之后 attach_window 和 attach_webview 各做三件事:给运行时注册一个事件处理函数(把窗口事件转成 tauri://resize、tauri://close-requested 这些事件发出去,再调 Builder::on_window_event 注册的全局处理函数)、插进表里、在主线程上通知插件。webview 那边最后发 tauri://webview-created,窗口这边回到 build_internal 末尾发 tauri://window-created。

两张表、两套标签

AppManager 里窗口和 webview 各有一张 Mutex<HashMap<String, _>>(AppManager 与 Manager trait)。一个 WebviewWindow 在两张表里各占一个同名条目。get_webview_window 查的是 webview 表,再用 is_webview_window 看它所在的窗口是不是「一窗一 webview」:

  pub(crate) fn is_webview_window(&self) -> bool {
    self.webviews().iter().all(|w| w.label() == self.label())
  }

判据是「这个窗口里每个 webview 的标签都等于窗口标签」。

实验在 setup 里用 unstable API 建一个空窗口 host,往里加两个 webview left、right,然后拿已占用的标签去建各种东西。每一步后打印两张表和 webview_windows()(host[right,left] 表示窗口 host 里有这两个 webview,left@host 表示 webview left 在窗口 host 里):

      let host = Window::builder(app, "host").visible(false).inner_size(800., 600.).build()?;
      watch(&host);
      dump(&h, "after Window::builder(host)");
      let left = host.add_child(
        WebviewBuilder::new("left", WebviewUrl::App("index.html".into())),
        LogicalPosition::new(0., 0.),
        LogicalSize::new(300., 600.),
      )?;
      host.add_child(
        WebviewBuilder::new("right", WebviewUrl::App("index.html".into())),
        LogicalPosition::new(300., 0.),
        LogicalSize::new(500., 600.),
      )?;
      dump(&h, "after add_child x2");
      // ...
      try_build(
        "WebviewWindowBuilder main",
        WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
          .visible(false)
          .build()
          .map(|w| w.label().into()),
      );
      // 依次再试:WebviewWindowBuilder host、Window::builder main、
      // host.add_child(WebviewBuilder::new("main", ..))、Window::builder left

输出(前半段):

  358ms [main] after Window::builder(host): windows=["host[]", "main[main]"] webviews=["main@main"] webview_windows=["main"]
  474ms [main] after add_child x2: windows=["host[right,left]", "main[main]"] webviews=["left@host", "main@main", "right@host"] webview_windows=["main"]
  474ms [main] left.window()=host get_webview_window(left)=false get_webview_window(main)=true
  474ms [main] WebviewWindowBuilder main -> Err(a webview with label `main` already exists)
  475ms [main] WebviewWindowBuilder host -> Err(a window with label `host` already exists)
  475ms [main] Window::builder main -> Err(a window with label `main` already exists)
  475ms [main] host.add_child webview main -> Err(a webview with label `main` already exists)
  483ms [main] Window::builder left -> Ok(left)
...
  567ms [main] REPORT page main/main href=tauri://localhost size=400x268
  570ms [main] REPORT page host/left href=tauri://localhost size=300x568
  623ms [main] REPORT page host/right href=tauri://localhost size=500x568

逐行看:

  • 刚建好的 host 没有 webview,webview_windows() 里没有它:这个方法遍历的是 webview 表,没有 webview 的窗口根本不会被看到。
  • 加了 left、right 之后,get_webview_window("left") 是 false:host 里两个 webview 的标签都不等于 host。只有 main 还是 WebviewWindow。
  • 同一个标签 main 再建 WebviewWindow,报的是 webview 已存在,不是窗口已存在:with_webview 先跑 into_pending_webview,webview 表的检查在前。换成标签 host(webview 表里没有、窗口表里有),过了第一关,在 prepare_window 报窗口已存在。两个错误分别是 Error::WebviewLabelAlreadyExists 和 Error::WindowLabelAlreadyExists。
  • 两张表各管各的唯一性,所以 webview 表里已经有 left 时,Window::builder(app, "left") 照样成功。
  • 每个页面从 __TAURI_INTERNALS__.metadata 读到自己的窗口标签和 webview 标签:host/left、host/right。这两个值是 prepare_pending_webview 在第三段初始化脚本里写死的(初始化脚本),所以 reparent 之后页面里的 currentWindow 不会变。
❗IMPORTANT
动脑筋:上面最后一步建出了一个空窗口 left,而 webview 表里也有一个 left(在 host 里)。这时 get_webview_window("left") 返回什么?窗口 left 的 webviews() 有几个?

平台 M2 Max、macOS 27,tauri 2.12.5(unstable),debug 构建。

标签本身也有字符限制:PendingWindow::new 和 PendingWebview::new 都调 is_label_valid,只允许字母数字和 -、/、:、_。webview 标签不合法时返回的也是 InvalidWindowLabel,错误文本「Window labels must only include alphanumeric characters, -, /, : and _.」。

一个窗口放多个 webview

Window::add_child 是多 webview 的入口,只在桌面、只在 unstable 下有:

  pub fn add_child<P: Into<Position>, S: Into<Size>>(
    &self,
    webview_builder: WebviewBuilder<R>,
    position: P,
    size: S,
  ) -> crate::Result<Webview<R>> {
    use std::sync::mpsc::channel;

    let (tx, rx) = channel();
    let position = position.into();
    let size = size.into();
    let window_ = self.clone();
    self.run_on_main_thread(move || {
      let res = webview_builder.build(window_, position, size);
      tx.send(res).unwrap();
    })?;
    rx.recv().unwrap()
  }

它把建 webview 的活交给主线程,再在当前线程上 recv 等结果。实验在 setup 里(本来就在主线程)调它,run_on_main_thread 直接执行闭包,所以不会自己等自己。WebviewBuilder::build 和 WebviewWindow 那条路共用 into_pending_webview,区别是多设一个 bounds,然后用窗口的 WindowDispatch::create_webview 把 webview 加进已有的 tao 窗口。

子 webview 的位置和大小是固定的,窗口变大变小它不跟着动,除非建的时候调了 WebviewBuilder::auto_resize 或之后 set_auto_resize(true):运行时在 Resized 事件里按建立时记下的比例重算每个子 webview 的位置和大小。WebviewWindow 的 webview 总是铺满窗口,set_auto_resize 的文档写明对它是空操作。

Webview::close 和关窗不同,它不发任何事件:

  pub fn close(&self) -> crate::Result<()> {
    self.webview.dispatcher.close()?;
    self.manager().on_webview_close(self.label());
    Ok(())
  }

派发器发一个 WebviewMessage::Close,主线程上从窗口的 webview 列表里删掉它(drop 掉 wry 的 WebView);on_webview_close 则立刻从 webview 表里删掉它,连同它的事件监听器和没取走的 Channel 数据。实验在一个普通线程上关掉 right,返回时表里已经没有它,前后也没有任何 WebviewEvent:

  828ms [?] --- webview right.close()
  829ms [?] right.close() returned: windows=["host[left]", "main[main]"] webviews=["left@host", "main@main"] webview_windows=["main"]

(线程名打印成 ?,是因为 std::thread::spawn 起的线程没有名字。)

Windows 上 add_child 和 WebviewWindowBuilder::new 的文档都写着同一条已知问题:在同步命令或事件处理函数里调用会死锁(WebView2 的限制),要放到 async 命令或单独的线程里。macOS 上没有这个问题,上面的实验在 setup 里同步调用。

关窗:从 close() 到 Destroyed

Window::close 和 destroy 都不走「在主线程就直接执行」的捷径,而是经 EventLoopProxy 发一条消息(tauri-runtime-wry 里的 close 注释:「close cannot use Context::send_user_message because it accesses the event loop callback」)。事件循环收到 WindowMessage::Close 调 on_close_requested:

fn on_close_requested<'a, T: UserEvent>(
  callback: &'a mut (dyn FnMut(RunEvent<T>) + 'static),
  window_id: WindowId,
  windows: &WindowsStore,
) {
  let (tx, rx) = channel();
  let windows_ref = windows.0.borrow();
  if let Some(w) = windows_ref.get(&window_id) {
    let label = w.label.clone();
    let window_event_listeners = w.window_event_listeners.clone();

    drop(windows_ref);

    let listeners = window_event_listeners.lock().unwrap();
    let handlers = listeners.values();
    for handler in handlers {
      handler(&WindowEvent::CloseRequested {
        signal_tx: tx.clone(),
      });
    }
    callback(RunEvent::WindowEvent {
      label,
      event: WindowEvent::CloseRequested { signal_tx: tx },
    });
    if let Ok(true) = rx.try_recv() {
    } else {
      on_window_close(window_id, windows);
    }
  }
}

先调窗口自己的事件处理函数(tauri 在 attach_window 里注册的那个,以及用户用 Window::on_window_event 注册的),再调 run 的回调,最后 try_recv:任何一方调过 CloseRequestApi::prevent_close(往 tx 里送了 true)就作罢,否则 on_window_close 把 tao 窗口从包装里拿掉(inner = None),窗口被 drop,tao 随后发 Destroyed。destroy() 发的是 WindowMessage::Destroy,直接调 on_window_close,跳过整个 CloseRequested。

📝NOTE
没有蠢问题

问: close() 和 destroy() 都能把窗口关掉,该用哪个?

答: 区别只在有没有 CloseRequested 这一步。close() 相当于用户点了关闭按钮:发 CloseRequested,任何一方都可以 prevent_close,包括下一节「页面监听了 close-requested」这种隐式的拦截。destroy() 直接 drop 窗口,只有 Destroyed。所以 JS 在 close-requested 的处理函数里只能调 destroy():再调 close() 会再触发一次 CloseRequested,又被拦下。

Destroyed 走的是普通窗口事件那条路,顺序反过来:先 run 的回调(on_event_loop_event 在这里调 AppManager::on_window_close 把窗口和它的 webview 从表里删掉,见 AppManager 与 Manager trait),再窗口的事件处理函数(发 tauri://destroyed),最后在 TaoWindowEvent::Destroyed 分支里从运行时的窗口表删掉它,表空了就发 ExitRequested(Builder、App 与 AppHandle)。

实验接着关掉 host,再关掉最后一个窗口 main。每个窗口都用 Window::on_window_event 挂了一个处理函数,Builder::on_window_event 挂了一个全局的,setup 里用 listen_any 听 tauri://close-requested 和 tauri://destroyed,run 的回调打印 RunEvent:

 1134ms [?] --- window host.close()
 1134ms [main] Window::on_window_event host CloseRequested
 1134ms [main] rust listen_any tauri://close-requested payload=null
 1134ms [main] Builder::on_window_event host CloseRequested
 1134ms [main] RunEvent::WindowEvent host CloseRequested
 1134ms [?] host.close() returned: windows=["host[left]", "main[main]"] webviews=["left@host", "main@main"] webview_windows=["main"]
 1135ms [main] RunEvent::WindowEvent host Destroyed
 1135ms [main] Window::on_window_event host Destroyed
 1135ms [main] rust listen_any tauri://destroyed payload=null
 1135ms [main] Builder::on_window_event host Destroyed
 1439ms [?] 300ms later: windows=["main[main]"] webviews=["main@main"] webview_windows=["main"]
 1439ms [?] --- window main.close() (last window)
 1439ms [main] rust listen_any tauri://close-requested payload=null
 1439ms [main] Builder::on_window_event main CloseRequested
 1439ms [main] Window::on_window_event main CloseRequested
 1439ms [main] RunEvent::WindowEvent main CloseRequested
 1440ms [main] RunEvent::WindowEvent main Destroyed
 1440ms [main] rust listen_any tauri://destroyed payload=null
 1440ms [main] Builder::on_window_event main Destroyed
 1440ms [main] Window::on_window_event main Destroyed
 1441ms [main] RunEvent::ExitRequested code=None
 1442ms [main] RunEvent::Exit
  • close() 立即返回,返回时窗口还在表里;删除发生在 Destroyed 的回调里,和 Webview::close 立刻删表不同。
  • CloseRequested:窗口处理函数都在 RunEvent 之前;Destroyed:RunEvent 在前。和上面两段源码一致。
  • Window::on_window_event 和 tauri 自己的处理函数(发事件、调全局处理函数的那个)之间没有固定顺序:host 是用户的在前,main 是 tauri 的在前。两者都存在运行时的 window_event_listeners 里,它的类型是 Arc<Mutex<HashMap<WindowEventId, WindowEventHandler>>>,遍历的是 HashMap 的 values()。
  • 关掉 host 时 left 一起没了;它也没有单独的 webview 事件。

JS 监听了 close-requested 时

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, &())?;
    }

只要有页面为这个窗口监听了 tauri://close-requested,Rust 侧就先替它拦下关窗,再把事件发过去,由 JS 决定要不要真的关。@tauri-apps/api 的 onCloseRequested 就是这么用的:调用户的处理函数,没被 preventDefault 就调 this.destroy()。destroy 是一个普通的插件命令 plugin:window|destroy,要过 ACL,而 core:window:default 里没有 allow-destroy。

实验(MODE=jsclose)建两个 WebviewWindow:jsmain 的能力只有 core:default,destroyable 的能力多一条 core:window:allow-destroy。两个页面都这样监听:

    const win = window.__TAURI__.window.getCurrentWindow();
    await win.listen('tauri://close-requested', () => {
      report(`js ${me}: got tauri://close-requested, calling destroy()`);
      win.destroy().then(
        () => report(`js ${me}: destroy() ok`),
        (e) => report(`js ${me}: destroy() rejected: ${e}`)
      );
    });

Rust 侧先后对两个窗口调 close():

  836ms [?] --- close jsmain (JS listener, no destroy permission)
  836ms [main] rust listen_any tauri://close-requested payload=null
  837ms [main] Builder::on_window_event jsmain CloseRequested
  837ms [main] Window::on_window_event jsmain CloseRequested
  837ms [main] RunEvent::WindowEvent jsmain CloseRequested
  838ms [main] REPORT js jsmain/jsmain: got tauri://close-requested, calling destroy()
  839ms [main] REPORT js jsmain/jsmain: destroy() rejected: window.destroy not allowed on window "jsmain", webview "jsmain", URL: local

allowed on: [windows: "destroyable", URL: local]

referenced by: capability: destroyable, permission: allow-destroy
 1342ms [?] 500ms after jsmain.close(): windows=["destroyable[destroyable]", "jsmain[jsmain]", "main[main]"] webviews=["destroyable@destroyable", "jsmain@jsmain", "main@main"] webview_windows=["destroyable", "jsmain", "main"]
 1342ms [?] --- close destroyable (JS listener, has core:window:allow-destroy)
 1343ms [main] rust listen_any tauri://close-requested payload=null
 1343ms [main] Builder::on_window_event destroyable CloseRequested
 1343ms [main] Window::on_window_event destroyable CloseRequested
 1343ms [main] RunEvent::WindowEvent destroyable CloseRequested
 1344ms [main] REPORT js destroyable/destroyable: got tauri://close-requested, calling destroy()
 1352ms [main] RunEvent::WindowEvent destroyable Destroyed
 1352ms [main] rust listen_any tauri://destroyed payload=null
 1352ms [main] Builder::on_window_event destroyable Destroyed
 1352ms [main] Window::on_window_event destroyable Destroyed

jsmain 关不掉:Rust 已经替页面拦下了关窗,页面的 destroy() 又被 ACL 拒绝(debug 构建下的详细拒绝信息,格式见运行时检查)。用户点窗口的关闭按钮也走同一条路,结果就是一个点不掉的窗口。destroyable 的 destroy() 通过了,Destroyed 紧跟着来,没有第二次 CloseRequested;destroy() ok 那条报告没回来,因为发它的页面已经随窗口销毁了。

📝NOTE
冷知识:「JS 一监听就拦下关窗」是 1.0 之前定下的

tauri://close-requested 早期只在 Rust 侧有。PR #3041(「fix(core): emit tauri://close-requested to JS, closes #2996」,首次发布于 tauri-v1.0.0-rc.0)把它发给 JS,同时引入了上面这段 has_js_listener 判断。CHANGELOG 原话:「Prevent window closing if tauri://close-requested is listened on the JS layer. Users must call appWindow.close() manually when listening to that event.」1.x 时 JS 侧要手动调 close();到 2.12.5,onCloseRequested 替你调的是 destroy(),判断本身一直没变,于是能力里少了 allow-destroy 就会出现上面那个关不掉的窗口。

动脑筋答案

MODE 不设时的那次运行,紧接着建完 left 窗口之后:

  483ms [main] Window::builder left -> Ok(left)
  483ms [main] after duplicate attempts: windows=["host[right,left]", "left[]", "main[main]"] webviews=["left@host", "main@main", "right@host"] webview_windows=["main"]
  483ms [main] get_webview_window(left)=false window(left).webviews()=0
  532ms [main] RunEvent::WindowEvent left Destroyed
  532ms [main] rust listen_any tauri://destroyed payload=null
  532ms [main] Builder::on_window_event left Destroyed
  • get_webview_window("left") 是 None:它按标签找到的是 webview 表里的 left,那个 webview 在 host 里,host 不是一窗一 webview。窗口表里那个同名的空窗口 left 根本没被看一眼。
  • 窗口 left 的 webviews() 是 0 个:Window::webviews 按「webview 所在窗口的标签等于自己」过滤 webview 表,left@host 不算。
  • 最后三行是实验随后 destroy() 掉这个空窗口:只有 Destroyed,没有 CloseRequested。它没有用 Window::on_window_event 挂处理函数,所以少了那一行。

小结

  • Window 和 Webview 是两个对象、两张表;WebviewWindow 只是一对同名的 { window, webview },get_webview_window 靠「窗口里所有 webview 都和窗口同名」来认它。
  • WebviewWindowBuilder::build 先 into_pending_webview(查 webview 标签、补脚本和协议),再 build_internal(查窗口标签、create_window、attach_window / attach_webview);同名时先报 webview 已存在。
  • 标签只在各自的表里唯一:已有 webview left 时还能建窗口 left,但这种窗口和 webview 互不相认。
  • unstable 下 Window::add_child 往一个窗口里加 webview,位置和大小固定,auto_resize 才会跟随窗口;Webview::close 立即删表,不发事件。
  • close():窗口处理函数 → RunEvent → 没被拦就 drop 窗口 → Destroyed(RunEvent 先、窗口处理函数后)→ 最后一个窗口时 ExitRequested。destroy() 跳过 CloseRequested。
  • 页面监听 tauri://close-requested 时 Rust 自动 prevent_close,关窗交给 JS 的 destroy();能力里缺 core:window:allow-destroy 时窗口关不掉。

💬 关联讨论

发起讨论
加载中...