Rust in Everything
基于 tauri 2.12.5

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_handlerinvoke_handler: Box<InvokeHandler<R>>,默认 |_| falseAppManager.webview.invoke_handler
setupsetup: SetupHook<R>,默认 |_| Ok(())App.setup,Ready 时调用
plugin / plugin_boxedplugins: PluginStore<R>AppManager.plugins
managestate: StateManagerAppManager.state
on_page_load、on_window_event、on_webview_event、register_uri_scheme_protocol …各自的 Option<Arc<..>>、Vec<..>、HashMap<..>AppManager 里对应的管理器
invoke_system、append_invoke_initialization_scriptinvoke_initialization_script: StringAppManager.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 in Builder::setup fails」。
  • 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 决定要不要重启。

📝NOTE
冷知识:exit() 以前不发 ExitRequested

2.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:它只置位、发请求,不阻塞。

❗IMPORTANT
动脑筋:进程的退出码是几?回调一共收到几次 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 作退出码,不能被拦。

💬 关联讨论

发起讨论
加载中...