From main() to Event Loop: How a Tauri App Boots and Runs
Prerequisites
- ›Article 1: Architecture and Crate Map
- ›Rust generics and trait objects
- ›Understanding of Arc/Mutex concurrency patterns
- ›Basic familiarity with event loop concepts
From main() to Event Loop: How a Tauri App Boots and Runs
A typical Tauri main.rs is deceptively simple — often just five lines of code. But behind those five lines, there's a compile-time macro embedding your entire frontend, a builder assembling dozens of configurable components, and a platform event loop handling window lifecycle events. This article traces every step.
Compile-Time Context Generation
Before your Tauri app can run, it needs a Context — a struct carrying the parsed config, compressed assets, window icons, the ACL authority, and plugin initialization scripts. This is generated at compile time.
The entry point is generate_context!, a proc macro reexported from tauri-macros. When the compiler encounters it, the macro reads tauri.conf.json (or an alternative path you supply), parses it into the Config struct, discovers all frontend assets in the configured frontendDist directory, compresses them with brotli, and generates a token stream that constructs a Context struct.
flowchart LR
SRC["tauri.conf.json"] --> MACRO["generate_context! macro"]
ASSETS["Frontend dist/"] --> MACRO
ICONS["App icons"] --> MACRO
MACRO --> CTX["Context struct<br/>(embedded in binary)"]
CTX --> |"contains"| CFG["Config"]
CTX --> |"contains"| EA["EmbeddedAssets"]
CTX --> |"contains"| RA["RuntimeAuthority"]
CTX --> |"contains"| PAT["Pattern"]
Alternatively, if you use tauri-build in your build.rs with the codegen feature, it generates the same code and writes it to $OUT_DIR/tauri-build-context.rs. You then include it with tauri_build_context!() instead of generate_context!(). Either way, you get the same Context.
The Context struct holds everything the application needs at startup:
pub struct Context<R: Runtime> {
pub(crate) config: Config,
pub assets: Box<dyn Assets<R>>,
pub(crate) default_window_icon: Option<image::Image<'static>>,
pub(crate) app_icon: Option<Vec<u8>>,
pub(crate) package_info: PackageInfo,
pub(crate) pattern: Pattern,
pub(crate) runtime_authority: RuntimeAuthority,
pub(crate) plugin_global_api_scripts: Option<&'static [&'static str]>,
}
Tip: The
Contexttype uses#[tauri_macros::default_runtime(Wry, wry)], so when thewryfeature is enabled (the default), you don't need to specify the generic parameter. This pattern is used throughout the codebase to hide theR: Runtimegeneric from end users.
The Builder Pattern: Assembling the Application
The Builder is a large struct with fields for every configurable aspect of a Tauri application:
classDiagram
class Builder {
-invoke_handler: Box~InvokeHandler~
-invoke_initialization_script: String
-channel_interceptor: Option
-setup: SetupHook
-on_page_load: Option~Arc~OnPageLoad~~
-plugins: PluginStore
-uri_scheme_protocols: HashMap
-state: StateManager
-menu: Option~Box~dyn FnOnce~~
-window_event_listeners: Vec
-webview_event_listeners: Vec
-device_event_filter: DeviceEventFilter
-invoke_key: String
+new() Self
+invoke_handler(handler) Self
+plugin(plugin) Self
+manage~T~(state) Self
+setup(hook) Self
+build(context) Result~App~
+run(context) Result
}
When you call Builder::new(), it generates a cryptographic invoke key — a random token that will be injected into webviews and verified on every IPC call. It also sets up the InvokeInitializationScript, a JavaScript template that bootstraps the window.__TAURI_INTERNALS__ bridge including the IPC protocol handler, OS detection, and the invoke key.
Each builder method (.invoke_handler(), .plugin(), .manage(), .setup(), etc.) follows Rust's move-based builder pattern — consuming self and returning a modified Self. The PluginStore accumulates plugins, and the StateManager accumulates typed state values.
Builder::build() — The Initialization Sequence
The real action happens in Builder::build(). This is a ~200-line function that orchestrates the entire initialization sequence:
sequenceDiagram
participant Dev as Developer
participant Builder as Builder
participant AM as AppManager
participant RT as Runtime (WRY)
participant App as App
Dev->>Builder: build(context)
Builder->>AM: AppManager::with_handlers(context, plugins, ...)
Builder->>RT: R::new(runtime_args)
Note over RT: Creates platform event loop
Builder->>App: Construct App { runtime, manager, handle }
App->>App: register_core_plugins()
App->>App: manage(Env, Scopes, ChannelDataIpcQueue)
App->>AM: initialize_plugins(handle)
Note over AM: Calls Plugin::initialize() for all plugins
App-->>Dev: Ok(App)
The sequence is carefully ordered:
- Create the AppManager — wraps the context, plugins, handlers, state, and all event listeners into a single
Arc<AppManager>. - Initialize the runtime — calls
R::new()(orR::new_any_thread()on Windows/Linux) to create the platform event loop. On Windows, a message hook is installed for menu accelerator handling. - Construct the App — assembles the
Appstruct with the runtime, manager, and anAppHandle. - Register core plugins — Tauri implements its own functionality as plugins (event system, window management, webview management, app lifecycle, resources, menu, tray). This is called at line 2337.
- Manage default state — environment info, scopes, and the channel data queue.
- Initialize all plugins — calls
Plugin::initialize()on every registered plugin, passing the app handle and its config section.
The Manager Trait Hierarchy
As we introduced in Part 1, the Manager, Listener, and Emitter traits form the public API surface. Let's look more closely at how they work.
Manager provides access to app configuration, state, windows, webviews, and resources. Listener provides event subscription with listen, once, listen_any, and unlisten. Emitter provides event emission with targeting and filtering.
All three traits require sealed::ManagerBase<R>, which is defined at line 1059:
pub trait ManagerBase<R: Runtime> {
fn manager(&self) -> &AppManager<R>;
fn manager_owned(&self) -> Arc<AppManager<R>>;
fn runtime(&self) -> RuntimeOrDispatch<'_, R>;
fn managed_app_handle(&self) -> &AppHandle<R>;
}
Every public type (App, AppHandle, Window, Webview, WebviewWindow) implements ManagerBase, which provides access to the AppManager — the central hub. This design means all these types share the exact same capabilities but may differ in what runtime() returns (a reference to the runtime, a handle to it, or a dispatcher).
State Management: TypeId-Keyed Storage
Tauri's state management is one of its most elegant subsystems. The StateManager uses a HashMap<TypeId, Pin<Box<dyn Any>>> with a custom IdentHash hasher:
struct IdentHash(u64);
impl Hasher for IdentHash {
fn finish(&self) -> u64 { self.0 }
fn write_u64(&mut self, i: u64) { self.0 = i; }
// ...
}
Since TypeId values are already unique integers, IdentHash simply passes them through as the hash — no actual hashing computation needed. This makes state lookups effectively O(1) with zero overhead.
The State<'r, T> guard is a thin wrapper around &'r T that implements Deref. Critically, it also implements CommandArg (line 60), which means it can be automatically injected into command handler function parameters — the framework extracts it from the StateManager without the developer writing any deserialization code.
Values are pinned (Pin<Box<...>>) to guarantee memory stability. Once state is set, it can never be moved or mutated through the manager — only read. The unsafe fn unmanage<T>() exists for cleanup but is pub(crate) and documented as breaking the safety invariant.
App::run() and the Event Loop
After build() returns an App, the developer calls App::run(). This function takes the runtime out of the App (via Option::take) and enters the platform event loop:
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));
}
The make_run_event_loop_callback method wraps the user's callback with framework-level event handling:
stateDiagram-v2
[*] --> Ready: Runtime starts
Ready --> Running: setup() called
Running --> Running: Window/Webview events
Running --> ExitRequested: Close requested
ExitRequested --> Running: api.prevent_exit()
ExitRequested --> Exit: Default behavior
Exit --> Restart: restart_on_exit flag set
Exit --> [*]: Normal shutdown
Restart --> [*]: Process restarts
The key events are:
Ready— Fires once the event loop is running. This is wheresetup()is called (note: not duringbuild()). If setup fails, the process panics.- Window/Webview events — Close requested, focus changes, drag-drop, resize, etc. Each is translated from a
RuntimeRunEventto aRunEvent. Exit— Fires when the event loop terminates. Tauri callscleanup_before_exit()and, ifrestart_on_exitis set, restarts the process usingcrate::process::restart().
Tip: The setup hook runs during the
Readyevent, not duringbuild(). This means the event loop is already running when your setup code executes, which is why you can create windows and webviews in the setup hook — the runtime is ready to process them.
In the next article, we'll follow the exact path that a JavaScript invoke() call takes through the IPC bridge, across the security boundary, and into your Rust command handler.