Read OSS

main() からイベントループまで:Tauri アプリの起動と実行の仕組み

中級

前提知識

  • 第1回:アーキテクチャとクレートマップ
  • Rust のジェネリクスとトレイトオブジェクト
  • Arc/Mutex による並行処理パターンの理解
  • イベントループの基本的な概念

main() からイベントループまで:Tauri アプリの起動と実行の仕組み

典型的な Tauri の main.rs は、たった5行程度のシンプルなコードです。しかしその裏では、フロントエンド全体をバイナリに埋め込むコンパイル時マクロが動き、数十もの設定可能なコンポーネントを組み立てる Builder が走り、ウィンドウのライフサイクルイベントを処理するプラットフォームのイベントループが稼働しています。この記事では、そのすべてのステップを追っていきます。

コンパイル時のコンテキスト生成

Tauri アプリが実行される前に、まず Context を生成する必要があります。Context は、パース済みの設定、圧縮されたアセット、ウィンドウアイコン、ACL 権限、プラグインの初期化スクリプトを保持する構造体です。これはコンパイル時に生成されます。

エントリーポイントは generate_context! です。これは tauri-macros から再エクスポートされた proc マクロです。コンパイラがこのマクロを検出すると、tauri.conf.json(または任意のパス)を読み込んで Config 構造体にパースします。さらに frontendDist ディレクトリ内のフロントエンドアセットをすべて検出し、brotli で圧縮したうえで Context 構造体を構築するトークンストリームを生成します。

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"]

別の方法として、build.rstauri-buildcodegen フィーチャー付きで使う場合は、同じコードが生成されて $OUT_DIR/tauri-build-context.rs に書き出されます。その場合は generate_context!() の代わりに tauri_build_context!() でインクルードします。どちらの方法を使っても、得られる Context は同じです。

Context 構造体は、起動時に必要なすべての情報を保持しています。

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]>,
}

ヒント: Context 型には #[tauri_macros::default_runtime(Wry, wry)] が付いているため、wry フィーチャー(デフォルト)が有効な場合はジェネリクスパラメータを省略できます。このパターンはコードベース全体で使われており、エンドユーザーから R: Runtime のジェネリクスを隠す役割を果たしています。

Builder パターン:アプリケーションの組み立て

Builder は、Tauri アプリケーションの設定可能なあらゆる側面に対応するフィールドを持つ大きな構造体です。

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
    }

Builder::new() を呼び出すと、暗号学的な invoke キーが生成されます。これはランダムなトークンで、webview に注入され、すべての IPC 呼び出しで検証されます。また、InvokeInitializationScript も設定されます。これは window.__TAURI_INTERNALS__ ブリッジをブートストラップする JavaScript テンプレートで、IPC プロトコルハンドラ、OS 検出、invoke キーを含みます。

各 Builder メソッド(.invoke_handler().plugin().manage().setup() など)は、Rust のムーブベースの Builder パターンに従い、self を消費して変更された Self を返します。PluginStore にはプラグインが、StateManager には型付きの状態値が順次蓄積されていきます。

Builder::build() — 初期化シーケンス

核心となるのが Builder::build() です。約200行のこの関数が、初期化シーケンス全体を統括します。

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)

処理は以下の順序で行われます。

  1. AppManager の生成 — コンテキスト、プラグイン、ハンドラ、状態、各種イベントリスナーをひとつの Arc<AppManager> にまとめます。
  2. ランタイムの初期化R::new()(Windows/Linux では R::new_any_thread())を呼び出してプラットフォームのイベントループを生成します。Windows ではメニューアクセラレータ処理用のメッセージフックもインストールされます。
  3. App の構築 — ランタイム、マネージャ、AppHandle を組み合わせて App 構造体を組み立てます。
  4. コアプラグインの登録 — Tauri は自身の機能をプラグインとして実装しています(イベントシステム、ウィンドウ管理、webview 管理、アプリライフサイクル、リソース、メニュー、トレイ)。これは2337行目で呼び出されます。
  5. デフォルト状態の管理 — 環境情報、スコープ、チャネルデータキューを登録します。
  6. 全プラグインの初期化 — 登録済みの全プラグインに対して Plugin::initialize() を呼び出し、アプリハンドルと対応する設定セクションを渡します。

Manager トレイトの階層構造

第1回でも触れたとおり、ManagerListenerEmitter トレイトが公開 API の骨格を形成しています。それぞれの仕組みをより詳しく見ていきましょう。

Manager はアプリの設定、状態、ウィンドウ、webview、リソースへのアクセスを提供します。Listenerlistenoncelisten_anyunlisten によるイベントの購読機能を提供します。Emitter はターゲット指定やフィルタリングを伴うイベントの発行機能を提供します。

3つのトレイトはすべて sealed::ManagerBase<R> を要求します。これは 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>;
}

公開されているすべての型(AppAppHandleWindowWebviewWebviewWindow)は ManagerBase を実装しており、中央ハブとなる AppManager へのアクセス手段を得ています。このデザインにより、これらの型はまったく同じ機能を共有しつつ、runtime() が返す値(ランタイムへの参照、そのハンドル、またはディスパッチャ)だけが異なるという構造になっています。

状態管理:TypeId をキーとするストレージ

Tauri の状態管理は、特に洗練されたサブシステムのひとつです。StateManager は、カスタムの IdentHash ハッシャーを使った HashMap<TypeId, Pin<Box<dyn Any>>> で実装されています。

struct IdentHash(u64);

impl Hasher for IdentHash {
    fn finish(&self) -> u64 { self.0 }
    fn write_u64(&mut self, i: u64) { self.0 = i; }
    // ...
}

TypeId の値はすでにユニークな整数であるため、IdentHash はそれをそのままハッシュ値として使います。実際のハッシュ計算は一切行われないため、状態の検索は実質 O(1) でオーバーヘッドもありません。

State<'r, T> ガード&'r T の薄いラッパーで、Deref を実装しています。重要なのは、CommandArg も実装している点です(60行目)。これにより、コマンドハンドラ関数のパラメータに自動的に注入できます。デシリアライズのコードを一切書かなくても、フレームワークが StateManager から取り出して渡してくれます。

値は Pin<Box<...>> でピン留めされており、メモリの安定性が保証されます。一度状態がセットされると、マネージャを通じて移動させたり変更したりすることはできず、読み取りのみが可能です。クリーンアップ用の unsafe fn unmanage<T>() は存在しますが、pub(crate) であり、安全性の不変条件を破るとドキュメントに記載されています。

App::run() とイベントループ

build()App を返した後、開発者は App::run() を呼び出します。この関数は App からランタイムを取り出し(Option::take を使って)、プラットフォームのイベントループに入ります。

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));
}

make_run_event_loop_callback メソッドは、ユーザーのコールバックをフレームワークレベルのイベント処理でラップします。

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

主要なイベントは次のとおりです。

  • Ready — イベントループの起動後に一度だけ発火します。setup() が呼ばれるのはこのタイミングです(build() 時ではありません)。セットアップが失敗するとプロセスはパニックします。
  • ウィンドウ/Webview イベント — クローズリクエスト、フォーカスの変化、ドラッグ&ドロップ、リサイズなど。それぞれ RuntimeRunEvent から RunEvent に変換されます。
  • Exit — イベントループの終了時に発火します。Tauri は cleanup_before_exit() を呼び出し、restart_on_exit が設定されている場合は crate::process::restart() でプロセスを再起動します。

ヒント: セットアップフックが実行されるのは Ready イベントのタイミングです。build() の時点ではありません。つまり、セットアップコードが動くときにはイベントループがすでに動いています。そのため、セットアップフック内でウィンドウや webview を生成できるのです。ランタイムがすでに処理を受け付けられる状態にあるからです。

次の記事では、JavaScript の invoke() 呼び出しが IPC ブリッジをたどり、セキュリティ境界を越えて、Rust のコマンドハンドラに到達するまでの正確なパスを追っていきます。