Read OSS

From main() to Event Loop: How a Tauri App Boots and Runs

Intermediate

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 Context type uses #[tauri_macros::default_runtime(Wry, wry)], so when the wry feature is enabled (the default), you don't need to specify the generic parameter. This pattern is used throughout the codebase to hide the R: Runtime generic 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:

  1. Create the AppManager — wraps the context, plugins, handlers, state, and all event listeners into a single Arc<AppManager>.
  2. Initialize the runtime — calls R::new() (or R::new_any_thread() on Windows/Linux) to create the platform event loop. On Windows, a message hook is installed for menu accelerator handling.
  3. Construct the App — assembles the App struct with the runtime, manager, and an AppHandle.
  4. 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.
  5. Manage default state — environment info, scopes, and the channel data queue.
  6. 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 where setup() is called (note: not during build()). If setup fails, the process panics.
  • Window/Webview events — Close requested, focus changes, drag-drop, resize, etc. Each is translated from a RuntimeRunEvent to a RunEvent.
  • Exit — Fires when the event loop terminates. Tauri calls cleanup_before_exit() and, if restart_on_exit is set, restarts the process using crate::process::restart().

Tip: The setup hook runs during the Ready event, not during build(). 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.