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不会变。
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。
问: 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 那条报告没回来,因为发它的页面已经随窗口销毁了。
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时窗口关不掉。