Builder、App 与 AppHandle
主线位置:启动主线上「
Builder::build:建AppManager、R::new、注册插件、按配置建窗口 → … →App::run进入事件循环,RunEvent::Ready」这一段,以及启动主线的反方向:程序怎么退出。Wry::new内部怎么建 tao 的事件循环在 tauri-runtime-wry 与事件循环;AppManager里装了什么在 AppManager 与 Manager trait。
sequenceDiagram participant U as main participant B as Builder participant M as AppManager participant R as Wry participant P as PluginStore participant A as App U->>B: plugin manage setup invoke_handler U->>B: build context B->>M: with_handlers B->>R: R::new B->>P: 内置插件 初始化并登记 B->>P: initialize_all B-->>U: App U->>A: run callback A->>R: run R-->>A: RuntimeRunEvent::Ready A->>A: 按配置建窗口, 跑 setup A->>P: on_event Ready A->>U: callback Ready
本页的源码都在 app.rs。实验程序 app-life 在 M2 Max、macOS 27、rustc 1.99.0 上用 tauri 2.12.5 的真实 Wry 运行时跑(debug 构建、--features tauri/custom-protocol、窗口 "visible": false),没有用 MockRuntime:MockRuntime::run 自己按固定顺序合成事件,看不出 tao 事件循环的真实顺序。Windows、Linux 上的行为只读了源码。
Builder:只攒东西
Builder 的每个方法都只是把参数存进一个字段:
| 方法 | 字段 | 在 build 里交给谁 |
|---|---|---|
invoke_handler | invoke_handler: Box<InvokeHandler<R>>,默认 |_| false | AppManager.webview.invoke_handler |
setup | setup: SetupHook<R>,默认 |_| Ok(()) | App.setup,Ready 时调用 |
plugin / plugin_boxed | plugins: PluginStore<R> | AppManager.plugins |
manage | state: StateManager | AppManager.state |
on_page_load、on_window_event、on_webview_event、register_uri_scheme_protocol … | 各自的 Option<Arc<..>>、Vec<..>、HashMap<..> | AppManager 里对应的管理器 |
invoke_system、append_invoke_initialization_script | invoke_initialization_script: String | AppManager.webview |
menu、on_menu_event、on_tray_icon_event | 菜单与托盘相关字段 | AppManager.menu、AppManager.tray |
any_thread(只在 Windows、Linux 上有) | runtime_any_thread | 决定调 R::new 还是 R::new_any_thread |
只有一件事在 Builder::new 里就做了:生成 invoke key。
pub fn new() -> Self {
let invoke_key = crate::generate_invoke_key().unwrap();
Self {
// ...
invoke_initialization_script: InvokeInitializationScript {
process_ipc_message_fn: crate::manager::webview::PROCESS_IPC_MESSAGE_FN,
os_name: std::env::consts::OS,
fetch_channel_data_command: crate::ipc::channel::FETCH_CHANNEL_DATA_COMMAND,
invoke_key: &invoke_key.clone(),
}
.render_default(&Default::default())
.unwrap()
.into_string(),
// ...
invoke_key,
}
}
generate_invoke_key 取 16 个随机字节,用 Z85 编成 20 个字符。它要马上渲染进 ipc-protocol.js 的模板,所以不能等到 build。JS 每次 invoke 都带上它,Webview::on_message 比对不上就丢弃请求,见 ipc 协议处理。
manage 是唯一一个会在 Builder 上 panic 的方法:同一类型 manage 两次,assert! 失败(见状态)。plugin 同名两次不报错,后一个替换前一个,见插件。
build:建管理器、建事件循环、初始化插件
Builder::build 去掉平台分支后是这样一串:
pub fn build(mut self, context: Context<R>) -> crate::Result<App<R>> {
#[cfg(target_os = "macos")]
if self.menu.is_none() && self.enable_macos_default_menu {
self.menu = Some(Box::new(|app_handle| {
crate::menu::Menu::default(app_handle)
}));
}
let manager = Arc::new(AppManager::with_handlers(
context,
self.plugins,
self.invoke_handler,
// ...
self.invoke_key,
));
// ...
#[cfg(not(any(windows, target_os = "linux")))]
let mut runtime = R::new(runtime_args)?;
// ...
let runtime_handle = runtime.handle();
let app = App {
runtime: Some(runtime),
setup: Some(self.setup),
manager: manager.clone(),
handle: AppHandle {
runtime_handle,
manager,
event_loop: Arc::new(Mutex::new(EventLoop {
main_thread_id: std::thread::current().id(),
})),
},
ran_setup: false,
};
app.register_core_plugins()?;
// ... 菜单
let env = Env::default();
app.manage(env);
app.manage(Scopes {
#[cfg(feature = "protocol-asset")]
asset_protocol: crate::scope::fs::Scope::new(
&app,
&app.config().app.security.asset_protocol.scope,
)?,
});
app.manage(ChannelDataIpcQueue::default());
app.handle.plugin(crate::ipc::channel::plugin())?;
// ... 托盘
app.manager.initialize_plugins(handle)?;
Ok(app)
}
顺序有几处要注意:
Context整个交给AppManager::with_handlers,配置、内嵌资源、ACL 从此都在AppManager里(Context怎么来的见 generate_context!)。R::new在这里调用。Wry::new建 tao 的EventLoop,macOS 上它必须在主线程,所以any_thread在 macOS 上根本不存在。- 内置插件用
app.handle.plugin(..)注册,AppHandle::plugin是「先initialize再登记」,所以core:path、core:event等在这一步就初始化了;用户插件在Builder.plugins里,等到最后的initialize_plugins才初始化。插件初始化的完整顺序见插件。 Env、Scopes、ChannelDataIpcQueue这三份状态是 tauri 自己manage的,所以Manager::env()、asset_protocol_scope()实际上就是state::<Env>()、state::<Scopes>()。- 整个
build里没有建窗口。tauri.conf.json里的app.windows要等事件循环跑起来才建。
build 的错误都来自这几步:R::new 失败(不在主线程、连不上显示服务、Windows 上没有 WebView2)、assetProtocol.scope 的模式不合法、菜单或托盘建不出来、某个插件的 initialize 返回 Err。最后一种被包成 Error::PluginInitialization(name, msg)。
setup 在什么时候跑
窗口和 setup 都在 make_run_event_loop_callback 里,对 Ready 事件的处理:
move |event| match event {
RuntimeRunEvent::Ready => {
if let Err(e) = setup(&mut self) {
panic!("Failed to setup app: {e}");
}
let event = on_event_loop_event(self.handle(), RuntimeRunEvent::Ready, self.manager());
callback(self.handle(), event);
}
setup 先按配置建窗口,再让资源做准备,最后调用户的钩子:
fn setup<R: Runtime>(app: &mut App<R>) -> crate::Result<()> {
app.ran_setup = true;
for window_config in app.config().app.windows.iter().filter(|w| w.create) {
WebviewWindowBuilder::from_config(app.handle(), window_config)?.build()?;
}
app.manager.assets.setup(app);
if let Some(setup) = app.setup.take() {
(setup)(app).map_err(|e| crate::Error::Setup(e.into()))?;
}
Ok(())
}
tauri-runtime-wry 在 tao 第一次回调 Event::NewEvents(StartCause::Init) 时发出 RuntimeRunEvent::Ready(handle_event_loop)。这时候事件循环已经在主线程上跑起来了,建窗口和 WKWebView 直接在这个回调里做。于是有三个结论:
setup里app.get_webview_window("main")拿得到窗口。setup返回Err不会变成run的返回值,而是panic!("Failed to setup app: {e}")。App::run的文档里也写着「This function will panic if the setup-function supplied inBuilder::setupfails」。RunEvent::Ready到达插件的on_event和run的回调时,setup已经跑完了。
实验程序给每个钩子都打一行日志,日志里带启动后的毫秒数和线程名:
let app = builder
.setup(|app| {
log("app setup");
// ...
log(format!(
"main window exists in setup: {}",
app.get_webview_window("main").is_some()
));
app.listen("ping-evt", |e| log(format!("rust listener ping-evt {}", e.payload())));
app.emit("ping-evt", "from setup")?;
Ok(())
})
.on_page_load(|w, p| log(format!("app on_page_load {:?} {}", p.event(), w.label())))
.invoke_handler(tauri::generate_handler![report, mode, open_res, block_sync, block_async, sync_cmd, async_cmd, need_state])
.build(tauri::generate_context!())
.expect("error while building tauri application");
log("Builder::build returned");
probe 插件的 setup、on_window_ready、on_webview_ready、on_event 等也各打一行。运行输出的前一段:
0ms [main] main
113ms [main] plugin setup
113ms [main] Builder::build returned
378ms [main] plugin on_window_ready main
378ms [main] plugin on_webview_ready main
379ms [main] app setup
379ms [main] manage(Counter) again -> false, state = 0
379ms [main] main window exists in setup: true
379ms [main] rust listener ping-evt "from setup"
379ms [main] plugin on_event Ready
379ms [main] app run callback Ready
439ms [main] plugin on_navigation tauri://localhost -> true
448ms [main] app on_page_load Started main
448ms [main] plugin on_page_load Started tauri://localhost
449ms [main] app on_page_load Finished main
449ms [main] plugin on_page_load Finished tauri://localhost
- 插件的
setup在build返回之前就跑了(113 ms),用户的setup要等到 379 ms。 - 两者之间的 265 ms 是
run进事件循环、建窗口、建 WKWebView;插件的on_window_ready、on_webview_ready在建窗口时触发,比用户setup还早。 - 页面的导航和加载(
on_navigation、on_page_load)都排在Ready回调之后:建 WKWebView 只是让它开始加载tauri://localhost,真正的加载在之后的事件循环迭代里完成。setup跑的时候页面上还没有任何 JS 监听器(JS 的listen本身是一次 invoke),所以在setup里emit的事件到不了页面;Rust 侧在setup里先listen再emit,监听器当场就被调用了。
把 tao、wry 内部的 span 也打出来、看 Ready 和 applicationDidFinishLaunching 的关系,见 一次启动。
App 与 AppHandle
pub struct App<R: Runtime> {
runtime: Option<R>,
setup: Option<SetupHook<R>>,
manager: Arc<AppManager<R>>,
handle: AppHandle<R>,
ran_setup: bool,
}
pub struct AppHandle<R: Runtime> {
pub(crate) runtime_handle: R::Handle,
pub(crate) manager: Arc<AppManager<R>>,
event_loop: Arc<Mutex<EventLoop>>,
}
App 拿着运行时本身(R,对 Wry 来说里面就是 tao 的 EventLoop),AppHandle 只拿着运行时的句柄 R::Handle(WryHandle,内部是 EventLoopProxy 和一些 Arc)。两者共享同一个 Arc<AppManager>,所以通过谁 manage、emit、查窗口,效果都一样。
差别在线程。tauri 的测试里断言了 AppHandle: Send + Sync;对 App 做同样的断言,rustc 报 5 个错误,第一个是:
mod app_send_try {
fn assert_send<T: Send>() {}
#[allow(dead_code)]
fn check() {
assert_send::<tauri::AppHandle>();
assert_send::<tauri::App>();
}
}
error[E0277]: `Rc<tao::event_loop::EventLoopWindowTarget<tauri_runtime_wry::Message<EventLoopMessage>>>` cannot be sent between threads safely
--> src/main.rs:279:19
|
279 | assert_send::<tauri::App>();
| ^^^^^^^^^^ `Rc<tao::event_loop::EventLoopWindowTarget<tauri_runtime_wry::Message<EventLoopMessage>>>` cannot be sent between threads safely
|
...
note: required because it appears within the type `tauri_runtime_wry::Wry<EventLoopMessage>`
...
note: required because it appears within the type `tauri::App`
AppHandle 那一行没有报错。另外四个错误也都出在 App 上:*mut ()、*mut objc2::runtime::AnyObject、tao 事件处理闭包的 Rc<RefCell<..>>、Rc<PanicInfo>。tao 的事件循环只能在建它的线程上跑(macOS 上是主线程),类型系统就用 !Send 把 App 钉在那里。要在别的线程、命令、插件里操作应用,用 app.handle().clone();命令参数里写 AppHandle,CommandArg 的实现是 Ok(command.message.webview().app_handle),永远不会失败。
App 上有、AppHandle 上没有的,都和「运行时还没开始跑」有关:run、run_return、run_iteration。set_activation_policy、set_device_event_filter 两边都有,App 上的版本在 run 之前直接改 runtime,AppHandle 上的版本发消息给事件循环。App 实现 Manager 时,runtime() 返回的是 RuntimeOrDispatch::Runtime(&R),run 把 runtime 取走之后才退回到用 handle(见 AppManager 与 Manager trait)。
AppHandle 里的 event_loop 不是事件循环,只是一个 main_thread_id。build 时记下当前线程,run 时再记一次。它只被 restart 用来判断自己是不是在主线程上,下文再说。
run、run_return 与 run_iteration
pub fn run<F: FnMut(&AppHandle<R>, RunEvent) + 'static>(mut self, callback: F) {
self.handle.event_loop.lock().unwrap().main_thread_id = std::thread::current().id();
self
.runtime
.take()
.unwrap()
.run(self.make_run_event_loop_callback(callback));
}
App::run 按值拿走 self,把 runtime 取出来交给 Wry::run,后者调 tao 的 EventLoop::run,不再返回:事件循环结束时进程直接以退出码退出。Builder::run 只是 self.build(context)?.run(|_, _| {}),所以它签名上的 crate::Result<()> 只可能是 build 的错误,Ok(()) 永远走不到。
run_return 用 tao 的 run_return,事件循环结束后把退出码还给调用方。下面的实验里,把 run 换成 run_return,同样的退出流程最后打印:
1504ms [main] plugin on_event Exit
1504ms [main] app run callback Exit
1504ms [main] run_return returned 7
exit=0
run_return 返回了 exit(7) 的那个 7,进程本身由 main 正常结束(退出码 0)。2.12.0 之前 run_return 在这里总是返回 0,CHANGELOG 里 #15821 的原话是「Transfer the exit code from the window.app_handle().exit(1) call to the run_return() result instead of always returning 0.」
run_iteration 已经 #[deprecated],理由写在属性里:「When called in a loop (as suggested by the name), this function will busy-loop.」它在第一次调用时检查 ran_setup,自己跑 setup。
三种入口都用同一个转换函数 on_event_loop_event:把 RuntimeRunEvent 转成公开的 RunEvent,遇到窗口 Destroyed 先调 manager.on_window_close,最后锁住插件表、把事件交给每个插件的 on_event,返回后才轮到 run 的回调。所以日志里同一个事件总是 plugin on_event 在前、app run callback 在后。
退出:ExitRequested 与 prevent_exit
RunEvent 里和退出有关的是两个变体:
/// Event loop is exiting.
Exit,
/// The app is about to exit
#[non_exhaustive]
ExitRequested {
/// Exit code.
/// [`Option::None`] when the exit is requested by user interaction,
/// [`Option::Some`] when requested programmatically via [`AppHandle#method.exit`] and [`AppHandle#method.restart`].
code: Option<i32>,
/// Event API
api: ExitRequestApi,
},
退出有两条路,都走到 tauri-runtime-wry 的事件处理里:
flowchart TB
exitcall["AppHandle::exit code"] --> reqmsg["proxy 发 Message::RequestExit"]
reqmsg --> evsome["ExitRequested code Some"]
closeall["最后一个窗口 Destroyed"] --> evnone["ExitRequested code None"]
evsome --> chk{"try_recv 收到 Prevent"}
evnone --> chk
chk -->|是| keep["继续跑事件循环"]
chk -->|否| cf["ControlFlow 设为 Exit"]
cf --> destroyed["LoopDestroyed"]
destroyed --> exitev["RunEvent::Exit, cleanup"]
AppHandle::exit 调 runtime_handle.request_exit(code)。Wry 的 request_exit 不走「在主线程上就直接执行」的捷径,总是经 EventLoopProxy 发一个 Message::RequestExit(code)(注释:「request_exit cannot use Context::send_user_message because it accesses the event loop callback」),所以在主线程的命令里调 exit 也不会重入事件回调。事件循环收到后:
Message::RequestExit(code) => {
let (tx, rx) = channel();
callback(RunEvent::ExitRequested {
code: Some(code),
tx,
});
let recv = rx.try_recv();
let should_prevent = matches!(recv, Ok(ExitRequestedEventAction::Prevent));
if !should_prevent {
*control_flow = ControlFlow::ExitWithCode(code);
}
}
窗口那条路在 TaoWindowEvent::Destroyed 里:从窗口表里删掉这个窗口后表空了,就用同样的写法发 ExitRequested { code: None, tx }。
ExitRequestApi 就是这个 tx 加上 code:
impl ExitRequestApi {
/// Prevents the app from exiting.
///
/// **Note:** This is ignored when using [`AppHandle#method.restart`].
pub fn prevent_exit(&self) {
if self.code != Some(RESTART_EXIT_CODE) {
self.tx.send(ExitRequestedEventAction::Prevent).unwrap();
}
}
}
运行时在回调返回之后立刻 try_recv 一次,然后丢掉 rx。所以 prevent_exit 必须在回调里同步调用:
- 在回调里调:
send成功,try_recv收到Prevent,事件循环继续跑。 - 把
api克隆出去、等回调返回再调:rx已经被丢掉,send返回Err,unwrap在调用它的线程上 panic;而运行时那一侧try_recv是空的,照常退出。
实验的 run 回调第一次收到 ExitRequested 时调 prevent_exit,再起一个线程 200 ms 后 exit(7):
let callback = move |handle: &tauri::AppHandle, event: RunEvent| match event {
RunEvent::MainEventsCleared => {}
RunEvent::ExitRequested { code, api, .. } => {
log(format!("app run callback ExitRequested {{ code: {code:?} }}"));
if std::env::var("MODE").as_deref() == Ok("lateprevent") && !prevented {
// ... 见下文
} else if !prevented {
prevented = true;
api.prevent_exit();
log("prevent_exit(); will exit(7) from another thread");
let h = handle.clone();
std::thread::spawn(move || {
std::thread::sleep(std::time::Duration::from_millis(200));
h.exit(7);
});
}
}
e => log(format!("app run callback {}", short(&e))),
};
页面的 JS 做完所有测试后调 report 命令,report 里 app.exit(0)。退出那一段的输出留给本节末尾的动脑筋。
把 report 里的 exit(0) 换成关掉唯一的窗口(MODE=close),走的是第二条路,code 是 None:
1020ms [main] closing the main window instead of exit(0)
1020ms [main] plugin on_event WindowEvent main CloseRequested { api: CloseRequestApi(Sender { .. }) }
1020ms [main] app run callback WindowEvent main CloseRequested { api: CloseRequestApi(Sender { .. }) }
1031ms [main] plugin on_event WindowEvent main Destroyed
1031ms [main] app run callback WindowEvent main Destroyed
1031ms [main] plugin on_event ExitRequested { code: None }
1031ms [main] app run callback ExitRequested { code: None }
1031ms [main] prevent_exit(); will exit(7) from another thread
1234ms [main] plugin on_event ExitRequested { code: Some(7) }
1234ms [main] app run callback ExitRequested { code: Some(7) }
1234ms [main] plugin on_event Resumed
1234ms [main] app run callback Resumed
...
1236ms [main] plugin on_event Exit
1236ms [main] app run callback Exit
exit=7
prevent_exit 之后进程在没有窗口的状态下继续活着,200 ms 后才由 exit(7) 结束。这正是托盘类应用「关掉所有窗口但不退出」的写法。(Resumed 是 tao 的 StartCause::Poll 映射过来的;这一次在 Exit 之前一共收到 78 个,上面省略了。)
MODE=lateprevent 把 api 克隆进一个全局变量,在另一个线程上 1 ms 后再调 prevent_exit:
881ms [main] plugin on_event ExitRequested { code: Some(0) }
881ms [main] app run callback ExitRequested { code: Some(0) }
881ms [main] plugin on_event Resumed
881ms [main] app run callback Resumed
883ms [<unnamed>] calling prevent_exit() after the callback returned
thread '<unnamed>' (2377323) panicked at ~/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tauri-2.12.5/src/app.rs:92:55:
called `Result::unwrap()` on an `Err` value: SendError { .. }
note: run with `RUST_BACKTRACE=1` environment variable to display a backtrace
885ms [main] plugin on_event Exit
885ms [main] app run callback Exit
exit=0
app.rs:92 正是 self.tx.send(..).unwrap() 那一行。ExitRequestApi 实现了 Clone(2.4.0 起),但克隆出去的副本过了这一轮回调就没用了。
退出前的收尾在 RuntimeRunEvent::Exit 分支:先把 RunEvent::Exit 交给回调,再 cleanup_before_exit(清空托盘图标和 app、窗口、webview 三级的 ResourceTable,不调用资源的 close),最后看 restart_on_exit 决定要不要重启。
exit() 以前不发 ExitRequested2.0.0-beta.0 之前,AppHandle::exit 的实现是「cleanup_before_exit(),然后 std::process::exit(exit_code)」,事件循环和 run 的回调都不知道应用要退出了。PR #8708(「refactor(core): exit() and restart() triggers ExitRequested and Exit」)在 tauri-runtime 里加了 request_exit,tauri-runtime-wry 里加了 Message::RequestExit,exit 改成本页看到的样子:先 request_exit,只有发送失败时才退回到 cleanup_before_exit 加 process::exit。CHANGELOG 的原话是「AppHandle::exit and AppHandle::restart now go triggers RunEvent::ExitRequested and RunEvent::Exit and cannot be executed on the event loop handler.」ExitRequested 的 code 字段因此有了 Some 的情况。第一个包含它的 tag 是预发布版 tauri-v2.0.0-beta.0,正式版是 2.0.0。
restart 也走这套机制,用的退出码是 RESTART_EXIT_CODE(i32::MAX),prevent_exit 对它无效(上面 if self.code != Some(RESTART_EXIT_CODE) 那一句)。restart 先比对 main_thread_id:在主线程上调用时直接 cleanup_before_exit 然后重启进程,不发事件(文档:「we cannot guarantee the delivery of those events, so we skip them」);在别的线程上调用时把 restart_on_exit 置位、request_exit(RESTART_EXIT_CODE),然后 loop { sleep(Duration::MAX) } 等事件循环在 Exit 分支里重启。想在主线程上也拿到 ExitRequested 和 Exit,用 request_restart:它只置位、发请求,不阻塞。
ExitRequested?就是上面的程序,MODE 不设:report 里 exit(0);回调第一次收到 ExitRequested 时 prevent_exit,200 ms 后另一个线程 exit(7)。macOS、debug 构建,用 run(不是 run_return)。
动脑筋答案
846ms [main] plugin on_event ExitRequested { code: Some(0) }
846ms [main] app run callback ExitRequested { code: Some(0) }
846ms [main] prevent_exit(); will exit(7) from another thread
1057ms [main] plugin on_event ExitRequested { code: Some(7) }
1057ms [main] app run callback ExitRequested { code: Some(7) }
1057ms [main] plugin on_event Resumed
1057ms [main] app run callback Resumed
1057ms [main] plugin on_event Exit
1057ms [main] app run callback Exit
exit=7
退出码 7,ExitRequested 两次。第一次 code: Some(0) 被拦下,事件循环继续;第二次 code: Some(7) 没人拦,ControlFlow::ExitWithCode(7),tao 结束事件循环,发 LoopDestroyed,变成 RunEvent::Exit,最后进程以 7 退出。两次都是插件的 on_event 先收到。exit(0) 的那个 0 没有留下任何痕迹:退出码只看最后一次没被拦下的请求。
小结
Builder的方法只存字段,唯一的例外是new里生成 invoke key 并渲染ipc-protocol.js。build建AppManager、R::new建事件循环、初始化内置插件和用户插件、manage三份内部状态,不建窗口。- 窗口在事件循环第一次
Ready时由setup()按配置建,然后才调用户的setup;setup出错会 panic。插件的setup早在build里就跑了。页面在Ready之后才加载,setup里emit的事件到不了页面。 App持有 tao 事件循环,!Send,run消耗它且不返回;AppHandle是Clone + Send + Sync的句柄。run_return自 2.12.0 起返回真实的退出码;run_iteration已弃用。exit经 proxy 发RequestExit,最后一个窗口销毁时发code: None的请求;回调返回后运行时只try_recv一次,prevent_exit必须同步调用,晚调会 panic 且拦不住。restart用i32::MAX作退出码,不能被拦。