tauri-runtime 的抽象
主线位置:启动主线上「
Builder::build:建AppManager、R::new、按配置建窗口」这一段的接口层。R::new返回的是哪个类型、建窗口时交出去什么、拿回来什么,都由这一页的 trait 和结构体规定;Wry怎么实现它们在 tauri-runtime-wry 与事件循环。调用主线和事件主线里每一次webview.eval、window.title()也都经过这里的WebviewDispatch/WindowDispatch。
flowchart TB builder["Builder Wry"] --> rnew["R::new 建运行时"] rnew --> rt["Runtime 只在主线程"] rt --> handle["RuntimeHandle 可 Send"] wwb["WebviewWindowBuilder"] --> pending["PendingWindow 和 PendingWebview"] pending --> cw["create_window"] rt --> cw handle --> cw cw --> detached["DetachedWindow"] detached --> wd["WindowDispatch 可 Send"] detached --> vd["WebviewDispatch 可 Send"] wd --> tw["tauri Window 持有"] vd --> tv["tauri Webview 持有"]
tauri-runtime 2.12.2 只有 trait、数据结构和错误类型,src/ 下五个文件约 2900 行,不依赖 wry 也不依赖 tao。crate 文档第一句写着它的定位:「Internal runtime between Tauri and the underlying webview runtime」,并声明「None of the exposed API of this crate is stable」。
实验平台:M2 Max、macOS 27、rustc 1.99.0,debug 构建。实验 crate 由 exp-tauri/base 复制而来(tauri =2.12.5,features = ["test"]),带 --features tauri/custom-protocol 编译;MockRuntime 那部分用 cargo test。
一个类型参数,一个 user event
tauri 本体几乎每个类型都带 R: Runtime:App<R>、AppHandle<R>、Window<R>、Webview<R>、AppManager<R>……crates/tauri/src 里写着 R: Runtime 的行有 355 行。这个 Runtime 不是 tauri-runtime 里那个,而是 tauri 自己在 lib.rs 里包的一层 pub trait Runtime,把 user event 的类型固定成 EventLoopMessage:
pub enum EventLoopMessage {
/// An event from a menu item, could be on the window menu bar, application menu bar (on macOS) or tray icon menu.
#[cfg(desktop)]
MenuEvent(menu::MenuEvent),
/// An event from a menu item, could be on the window menu bar, application menu bar (on macOS) or tray icon menu.
#[cfg(all(desktop, feature = "tray-icon"))]
#[cfg_attr(docsrs, doc(cfg(all(desktop, feature = "tray-icon"))))]
TrayIconEvent(tray::TrayIconEvent),
}
/// The webview runtime interface. A wrapper around [`runtime::Runtime`] with the proper user event type associated.
pub trait Runtime: runtime::Runtime<EventLoopMessage> {}
/// The webview runtime handle. A wrapper around [`runtime::RuntimeHandle`] with the proper user event type associated.
pub trait RuntimeHandle: runtime::RuntimeHandle<EventLoopMessage> {}
impl<W: runtime::Runtime<EventLoopMessage>> Runtime for W {}
impl<R: runtime::RuntimeHandle<EventLoopMessage>> RuntimeHandle for R {}
默认的运行时是 pub type Wry = tauri_runtime_wry::Wry<EventLoopMessage>;(wry feature,默认开),Builder::default() 只为 Builder<crate::Wry> 实现。所以写应用时看不到 R:tauri::Builder::default() 已经选定了 Wry,命令里的 WebviewWindow 是 WebviewWindow<Wry> 的默认参数。
user event 是运行时事件循环上的「自定义事件」。UserEvent 只要求 Debug + Clone + Send + 'static,有一个覆盖实现。tauri 用它送菜单和托盘事件:Builder::build 在 R::new 之后给 muda 的 MenuEvent::set_event_handler、tray-icon 的 TrayIconEvent::set_event_handler 各装一个闭包,闭包里只有一句 proxy.send_event(EventLoopMessage::MenuEvent(e.into()))(托盘同理),事件回到主线程的事件循环后再交给应用。
五个 trait:谁可以离开主线程
| trait | 约束 | 实现方(Wry) | 用途 |
|---|---|---|---|
Runtime<T> | Debug + Sized + 'static | Wry<T> | 建、跑事件循环;App 在 run 之前持有它 |
RuntimeHandle<T> | Debug + Clone + Send + Sync + Sized + 'static | WryHandle<T> | AppHandle 里的那一份,建窗口、run_on_main_thread、request_exit |
WindowDispatch<T> | Debug + Clone + Send + Sync + Sized + 'static | WryWindowDispatcher<T> | 一个窗口的 getter / setter |
WebviewDispatch<T> | 同上 | WryWebviewDispatcher<T> | 一个 webview 的 eval_script、navigate、cookie、devtools |
EventLoopProxy<T> | Debug + Clone + Send + Sync | EventProxy<T> | 只有一个 send_event(T) |
区别在第二列:只有 Runtime 不要求 Send。它的文档写着 fn new 「Must be used on the main thread」,Windows 和 Linux 另有 new_any_thread,macOS 上没有这个方法。其余四个都要能被复制、能跨线程,trait 文档管 WindowDispatch 叫「A thread-safe handle to the window APIs」。
Runtime 的主体:
pub trait Runtime<T: UserEvent>: Debug + Sized + 'static {
/// The window message dispatcher.
type WindowDispatcher: WindowDispatch<T, Runtime = Self>;
/// The webview message dispatcher.
type WebviewDispatcher: WebviewDispatch<T, Runtime = Self>;
/// The runtime handle type.
type Handle: RuntimeHandle<T, Runtime = Self>;
/// The proxy type.
type EventLoopProxy: EventLoopProxy<T>;
/// Creates a new webview runtime. Must be used on the main thread.
fn new(args: RuntimeInitArgs) -> Result<Self>;
// ...
/// Create a new window.
fn create_window<F: Fn(RawWindow) + Send + 'static>(
&self,
pending: PendingWindow<T, Self>,
after_window_creation: Option<F>,
) -> Result<DetachedWindow<T, Self>>;
// ...
/// Runs an iteration of the runtime event loop and returns control flow to the caller.
#[cfg(desktop)]
fn run_iteration<F: FnMut(RunEvent<T>) + 'static>(&mut self, callback: F);
/// Equivalent to [`Runtime::run`] but returns the exit code instead of exiting the process.
fn run_return<F: FnMut(RunEvent<T>) + 'static>(self, callback: F) -> i32;
/// Run the webview runtime.
fn run<F: FnMut(RunEvent<T>) + 'static>(self, callback: F);
}
四个关联类型把一整套实现绑在一起:Wry 的派发器只能是 WryWindowDispatcher,不会出现「Wry 的窗口配上 mock 的 webview」。create_window 在 Runtime、RuntimeHandle、WindowDispatch 上各有一份,签名一样,tauri 用 RuntimeOrDispatch 在三者间选:App 还没 run(比如在 setup 里)时手里有 Runtime 本身,直接调;run 把 Runtime 拿走之后只剩 handle 和派发器。三条路在 Wry 里最后都走到同一个 create_window 函数,差别只在要不要发消息,见下一页。
WindowDispatch 和 WebviewDispatch 的 getter 全部返回 Result:fn title(&self) -> Result<String>、fn inner_size(&self) -> Result<PhysicalSize<u32>>。读一个标题为什么会失败?因为派发器在别的线程上,读标题是一次「发消息、等回答」的往返,事件循环已经退出时回答收不到,错误是 Error::FailedToReceiveMessage(「failed to receive message from webview」)。setter 也返回 Result,失败时是 FailedToSendMessage。
问: 既然有五个 trait,为什么 tauri 不存一个 Box<dyn Runtime>,而要让 355 行代码都带上 R?
答: 这几个 trait 用不成 trait 对象。Runtime 的父 trait 里就有 Sized,create_window<F>、run<F> 又都是泛型方法,按 Rust 的规则它不是 dyn 兼容的;RuntimeHandle、两个 Dispatch 也一样带 Sized 和泛型方法。改成 dyn 就得把这些闭包参数都换成 Box<dyn Fn..>、把派发器都装箱;泛型的代价是编译期展开和到处可见的 R,换来的是 Window<R> 里直接存着 R::WindowDispatcher,调用不经过虚表,而测试可以把整个 R 换成 MockRuntime。
RunEvent:事件循环交给应用的东西
RunEvent<T> 是运行时回调 run(callback) 的参数,tauri 在 app.rs 里再转成面向应用的 tauri::RunEvent。桌面上的成员:
| 成员 | 什么时候 |
|---|---|
Ready | 事件循环开始(tao 的 NewEvents(StartCause::Init)) |
WindowEvent { label, event }、WebviewEvent { label, event } | 某个窗口 / webview 的事件,按标签区分 |
ExitRequested { code, tx } | 最后一个窗口销毁,或有人调了 request_exit |
MainEventsCleared | 一轮输入事件处理完 |
Resumed | StartCause::Poll |
Exit | 事件循环结束(LoopDestroyed) |
UserEvent(T) | 经 proxy 送来的 user event |
Opened { urls }、Reopen { .. } | macOS 的「用本应用打开」和点 Dock 图标 |
ExitRequested 带着一个 Sender<ExitRequestedEventAction>。应用在回调里往 tx 发一个 Prevent,运行时回调返回后 try_recv 看到它,就不退出。用 channel 而不是返回值,是因为 RunEvent 要一路包装、转发到 tauri 的回调和插件,tx 跟着事件走,谁拿到都能投一票。prevent_exit 的用法在 Builder、App、AppHandle。
Pending 和 Detached:交出去一份描述,拿回来一个遥控器
建窗口时 tauri 交给运行时的是 PendingWindow:
pub struct PendingWindow<T: UserEvent, R: Runtime<T>> {
/// The label that the window will be named.
pub label: String,
/// The [`WindowBuilder`] that the window will be created with.
pub window_builder: <R::WindowDispatcher as WindowDispatch<T>>::WindowBuilder,
/// The webview that gets added to the window. Optional in case you want to use child webviews or other window content instead.
pub webview: Option<PendingWebview<T, R>>,
}
window_builder 的类型由运行时决定(Wry 是包着 tao WindowBuilder 的 WindowBuilderWrapper),tauri 只通过 WindowBuilder trait 的 title、inner_size、visible 这些方法往里填。PendingWindow::new 先检查标签,只允许字母数字和 -、/、:、_,不合法返回 Error::InvalidWindowLabel。
PendingWebview 是 webview 的全部描述,字段几乎都是回调:
pub struct PendingWebview<T: UserEvent, R: Runtime<T>> {
/// The label that the webview will be named.
pub label: String,
/// The [`WebviewAttributes`] that the webview will be created with.
pub webview_attributes: WebviewAttributes,
/// Custom protocols to register on the webview
pub uri_scheme_protocols: HashMap<String, Box<UriSchemeProtocolHandler>>,
/// How to handle IPC calls on the webview.
pub ipc_handler: Option<WebviewIpcHandler<T, R>>,
/// A handler to decide if incoming url is allowed to navigate.
pub navigation_handler: Option<Box<NavigationHandler>>,
// ...
/// The resolved URL to load on the webview.
pub url: String,
// ...
}
PendingWebview::new 把 url 初始化成 "tauri://localhost"。uri_scheme_protocols 是「scheme 名 → 处理函数」的表,tauri 的 prepare_pending_webview 往里放 tauri、ipc(以及 asset、isolation)四个,见 Window、Webview、WebviewWindow。处理函数的类型是:
type UriSchemeProtocolHandler = dyn Fn(&str, http::Request<Vec<u8>>, Box<dyn FnOnce(http::Response<Cow<'static, [u8]>>) + Send>)
+ Send
+ Sync
+ 'static;
第一个参数是 webview 标签,第二个是 http crate 的请求(http::Request / http::Response 这两个类型在 hyper 卷的 Service 与 Body 里讲过),第三个是「回答」:一个 Send 的一次性闭包。处理函数不返回响应,而是在任何时候、任何线程上调这个闭包。async 命令能在 tokio 线程上算完再回答,靠的就是它,wry 怎么把这个闭包接到 WKURLSchemeTask 上在 wry 在 macOS 上。
1.x 时 tauri-runtime 有一个自己的 http 模块(request.rs、response.rs,四百多行),协议处理函数的类型是 dyn Fn(&HttpRequest) -> Result<HttpResponse, Box<dyn std::error::Error>>:拿到请求的引用,必须当场返回响应。PR #7754(「refactor(core): allow custom protocol handler to resolve async」)删掉了这个模块,tauri-runtime 的 CHANGELOG 记了两条:「Changed custom protocol closure type to enable asynchronous usage.」「tauri-runtime no longer implements its own HTTP types and relies on the http crate instead.」类型从此变成上面那样带一个 Box<dyn FnOnce(..) + Send> 的样子;最前面的 &str 标签参数是后来 2.0.3 的 #11268(「fix incorrect label passed to ipc protocol」)加的。同一个 PR 把 wry 升到 0.32,tauri-runtime-wry 的 CHANGELOG 写的是「Update wry to 0.32 to include asynchronous custom protocol support.」第一个包含它的版本是 tauri-runtime 1.0.0-alpha.1,对应 tauri 2.0.0-alpha.12(预发布),正式版是 2.0.0。
运行时建好窗口后还回来 DetachedWindow:
pub struct DetachedWindow<T: UserEvent, R: Runtime<T>> {
/// The identifier of the window.
pub id: WindowId,
/// Name of the window
pub label: String,
/// The [`WindowDispatch`] associated with the window.
pub dispatcher: R::WindowDispatcher,
/// The webview dispatcher in case this window has an attached webview.
pub webview: Option<DetachedWindowWebview<T, R>>,
}
里面没有窗口本身,只有一个 WindowId(运行时自己数的 u32)和派发器。真正的 tao 窗口、wry webview 留在运行时的主线程存储里;tauri 的 Window<R> 存的是 DetachedWindow<EventLoopMessage, R>,Webview<R> 存的是 DetachedWebview。Window::title() 就是 self.window.dispatcher.title()。这就是 Window 能 Clone、能 move 进 async 命令的原因:它只是一个遥控器。
MockRuntime:第二个实现
trait 之所以值得存在,是因为有第二个实现。MockRuntime 在 tauri 的 test feature 里,tauri::test::mock_builder() 返回的就是 Builder<MockRuntime>。它不建窗口,windows 只是一张 HashMap<WindowId, Window>,Window 里存一个标签和若干空的 Webview 结构体;getter 返回固定值,title() 是 Ok(String::new()),inner_size() 是 0×0;eval_script 把脚本存进 last_evaluated_script。
它的「主线程」概念很粗。RuntimeContext::send_message:
fn send_message(&self, message: Message) -> Result<()> {
if std::thread::current().id() == self.main_thread_id {
if let Message::Task(task) = message {
task();
return Ok(());
}
}
if self.is_running.load(Ordering::Relaxed) {
self
.run_tx
.send(message)
.map_err(|_| Error::FailedToSendMessage)
} else {
match message {
Message::Task(task) => task(),
Message::CloseWindow(id) | Message::DestroyWindow(id) => {
self.windows.borrow_mut().remove(&id);
}
}
Ok(())
}
}
在建它的线程上直接执行;run 还没被调用时,在哪个线程上都直接执行;只有 run 起来之后,别的线程发来的消息才进 sync_channel(256)。而 run 是一个 loop:try_recv 一条、回调 MainEventsCleared、sleep 一秒。单元测试一般不调 run,所以消息全都当场执行。
实验用同一个泛型函数分别跑在 Wry 和 MockRuntime 上:
// Records the order in which "before", "inside" and "after" happen.
fn order_probe<R: tauri::Runtime>(w: &WebviewWindow<R>, tag: &str) -> Arc<Mutex<Vec<String>>> {
let log = Arc::new(Mutex::new(Vec::new()));
log.lock().unwrap().push("before".to_string());
let l = log.clone();
let tag_ = tag.to_string();
w.run_on_main_thread(move || {
l.lock().unwrap().push("inside".to_string());
println!("[{tag_}] run_on_main_thread closure on {}", th());
})
.unwrap();
log.lock().unwrap().push("after".to_string());
log
}
// The same generic code runs on Wry and on MockRuntime.
fn describe<R: tauri::Runtime>(w: &WebviewWindow<R>) -> String {
format!(
"title={:?} inner_size={:?} url={:?}",
w.title(),
w.inner_size().map(|s| (s.width, s.height)),
w.url().map(|u| u.to_string())
)
}
th() 打印线程名和 ThreadId。Wry 这边在 setup 里调 describe(窗口配置 400×300、visible: false,标题 base):
[setup] on main/ThreadId(1)
[setup] run_on_main_thread closure on main/ThreadId(1)
[setup] order=["before", "inside", "after"]
[setup] describe: title=Ok("base") inner_size=Ok((800, 600)) url=Ok("tauri://localhost")
MockRuntime 这边是一个 #[test],窗口用 WebviewWindowBuilder::new(&app, "main", Default::default()).title("hello") 建,然后在测试线程和另一个名叫 other 的线程上各跑一次 order_probe:
let w2 = w.clone();
thread::Builder::new()
.name("other".into())
.spawn(move || {
let log = order_probe(&w2, "other");
println!("[other] order={:?}", log.lock().unwrap());
})
.unwrap()
.join()
.unwrap();
MockRuntime 上,从 other 线程调 run_on_main_thread,闭包在哪个线程上执行?order 打印什么?先想一想 send_message 的三个分支,再看文末的答案。
describe 在 mock 上的输出:
[test] on tests::mock_runtime/ThreadId(2)
[test] describe: title=Ok("") inner_size=Ok((0, 0)) url=Ok("tauri://localhost")
标题不是 hello:MockWindowBuilder::title 什么也不存,getter 返回空串。Wry 那边 inner_size 是 800×600 而不是配置里的 400×300,因为 getter 返回物理像素,屏幕缩放是 2。url 两边一样,它来自 PendingWebview 的 url 字段,mock 原样存下了。
所以 MockRuntime 适合测「命令、ACL、状态、事件分发」这些不依赖窗口的逻辑(tauri::test 的 get_ipc_response 就跑在它上面),不能用来测窗口行为和线程行为。
动脑筋答案
[test] run_on_main_thread closure on tests::mock_runtime/ThreadId(2)
[test] order=["before", "inside", "after"]
[other] run_on_main_thread closure on other/ThreadId(3)
[other] order=["before", "inside", "after"]
闭包就在 other 线程上执行,顺序和在「主线程」上一样是 before、inside、after。测试没有调 run,is_running 是 false,send_message 走第三个分支,当场 task()。同样的代码在 Wry 上,闭包一定在主线程执行,而且从别的线程调时顺序会变,见 tauri-runtime-wry 与事件循环。
小结
- tauri 本体对
R: Runtime泛型,tauri::Runtime是把 user event 固定成EventLoopMessage(菜单、托盘事件)的包装;默认的Wry由Builder::default()选定。 Runtime不要求Send,必须在主线程创建;RuntimeHandle、WindowDispatch、WebviewDispatch、EventLoopProxy都是Clone + Send + Sync,getter 返回Result,因为它们是跨线程的往返。- 建窗口:交出
PendingWindow(标签、运行时自己的WindowBuilder、可选的PendingWebview),拿回DetachedWindow(WindowId和派发器)。tauri 的Window/Webview只存派发器。 - 协议处理函数收
http::Request<Vec<u8>>和一个Send的一次性回答闭包,可以晚些、在别的线程回答;这个形状来自 #7754(2.0.0-alpha.12)。 MockRuntime是第二个实现:没有窗口、getter 返回固定值、run之前所有消息在调用线程当场执行,适合测 IPC 和 ACL,不适合测窗口和线程。