zudo-tauri-wisdom

Type to search...

to open search from anywhere

ローディング画面

作成2026年3月29日更新2026年4月22日Takeshi Takatsudo

バックグラウンドプロセスの起動中にローディングページを即座に表示するパターン

ローディング画面パターン

Tauri アプリが開発サーバーをラップしたり sidecar プロセスを起動したりする場合、起動に遅延が生じる — サーバーのコンパイルと配信開始までに通常 5〜30 秒かかる。ローディング画面がなければ、この間ユーザーには空白のウィンドウか、あるいはウィンドウすら表示されない。

ローディング画面パターンは、軽量な HTML ページを即座に表示し、サーバーの準備が完了したら実際のコンテンツにナビゲートすることで、この問題を解決する。

最重要ルール

⚠️ Warning

setup() 内でビルド完了を待ってはならない。 ウィンドウ生成前に setup() 内で wait_for_build() を呼ぶと、アプリがハングしたように見える — ウィンドウなし、Dock インジケータなし、何もなし。ユーザーはアプリが起動中であることを認識できない。これは Tauri デスクトップアプリにおける最悪の UX ミスである。

以下は間違った方法:

// 悪い例: setup() をブロックし、30秒以上ウィンドウが表示されない
.setup(|app| {
    wait_for_build(Duration::from_secs(120));  // ここでブロック!

    let url = format!("http://localhost:{PORT}/");
    WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url.parse().unwrap()))
        .build()?;

    Ok(())
})

そして以下が正しい方法:

// 良い例: ローディングページとともにウィンドウが即座に表示される
.setup(|app| {
    // ローディングページで今すぐウィンドウを表示
    WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
        .title("My App")
        .inner_size(1200.0, 800.0)
        .build()?;

    // バックグラウンドでポーリングし、準備完了時にナビゲート
    let handle = app.handle().clone();
    thread::spawn(move || {
        wait_for_ready(Duration::from_secs(120));
        if let Some(w) = handle.get_webview_window("main") {
            let url: tauri::Url = server_url().parse().unwrap();
            let _ = w.navigate(url);
        }
    });

    Ok(())
})

WebviewUrl::default() の動作

WebviewUrl::default()tauri.conf.jsonfrontendDist で指定されたディレクトリから index.html を読み込む:

{
  "build": {
    "frontendDist": "./frontend"
  }
}

つまり ./frontend/index.html がローディングページとなる。これはコンパイル時にアプリ binary にバンドルされ、即座に読み込まれる — サーバーは不要である。

ローディングページの HTML

ローディングページは最小限で、自己完結し(外部依存なし)、視覚的に心地よいものであるべきだ:

<!DOCTYPE html>
<html>
<head>
<style>
  * { margin: 0; padding: 0; box-sizing: border-box; }
  body {
    background: #181818;
    color: #b8b8b8;
    font-family: system-ui, sans-serif;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    height: 100vh;
    gap: 1.5rem;
  }
  .spinner {
    width: 32px;
    height: 32px;
    border: 3px solid #383838;
    border-top-color: #d69a66;
    border-radius: 50%;
    animation: spin 0.8s linear infinite;
  }
  @keyframes spin { to { transform: rotate(360deg); } }
  .text { font-size: 1.1rem; color: #888; }
  .sub { font-size: 0.85rem; color: #555; }
</style>
</head>
<body>
  <div class="spinner"></div>
  <div class="text">Starting documentation server...</div>
  <div class="sub">This may take a moment on first launch</div>
</body>
</html>

主な設計判断:

  • ダーク背景#181818) — 一般的な開発ツールの美観に合わせ、眩しい白のフラッシュを防ぐ
  • 純粋な CSS スピナー — JavaScript や外部リソース不要
  • システムフォント — 即座に読み込まれ、フォントのダウンロード不要
  • 中央配置レイアウト — どのウィンドウサイズでも機能する
  • 情報提供メッセージ — 何かが進行中であることをユーザーに伝える

完全なパターン: 開発 vs プロダクション

ローディング画面はプロダクションモードでのみ必要である。開発モードでは、beforeDevCommand により WebView が開く前にサーバーがすでに起動している。

const IS_DEV: bool = cfg!(debug_assertions);

// setup() 内:
if IS_DEV {
    // 開発: beforeDevCommand によりサーバーはすでに起動済み
    let url: tauri::Url = server_url().parse().unwrap();
    WebviewWindowBuilder::new(app, "main", WebviewUrl::External(url))
        .title("My App")
        .inner_size(1200.0, 800.0)
        .build()?;
} else {
    // プロダクション: ローディングページを表示し、サーバー準備完了時にナビゲート
    WebviewWindowBuilder::new(app, "main", WebviewUrl::default())
        .title("My App")
        .inner_size(1200.0, 800.0)
        .build()?;

    let handle = app.handle().clone();
    thread::spawn(move || {
        wait_for_ready(Duration::from_secs(120));
        if let Some(w) = handle.get_webview_window("main") {
            let url: tauri::Url = server_url().parse().unwrap();
            let _ = w.navigate(url);
        }
    });
}

バックグラウンドスレッドによるポーリング

準備状態の確認は、サーバーがエラーでない HTTP ステータスを返すまでポーリングする:

fn check_ready() -> String {
    Command::new("/usr/bin/curl")
        .args([
            "-s",
            "-o", "/dev/null",
            "-w", "%{http_code}",
            &format!("http://localhost:{PORT}/"),
        ])
        .output()
        .map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
        .unwrap_or_else(|_| "err".to_string())
}

fn wait_for_ready(timeout: Duration) {
    log("wait_for_ready: start");
    let start = Instant::now();
    while start.elapsed() < timeout {
        let code = check_ready();
        log(&format!("curl: {code} ({}s)", start.elapsed().as_secs()));
        if code != "000" && code != "err" {
            log("wait_for_ready: ready");
            thread::sleep(Duration::from_secs(1));
            return;
        }
        thread::sleep(Duration::from_secs(1));
    }
    log("wait_for_ready: TIMEOUT");
}

📝 Note

ここでは絶対パスで /usr/bin/curl を使用している。これは意図的である — /usr/bin/curl は macOS で PATH 環境に関係なく常に利用可能であるため、開発モードとプロダクションモードの両方で動作する。

なぜ Rust HTTP クライアントではなく curl なのか?

reqwestureq を使って準備状態の確認を純粋に Rust で行うことも可能である。curl アプローチが選ばれたのは実用的な理由による:

  • 追加の依存関係が不要
  • /usr/bin/curl は macOS に必ず存在する
  • チェック内容は HTTP ステータスコードの取得だけという些細なもの
  • バックグラウンドスレッドで実行するため、プロセスの起動はパフォーマンス上の懸念にならない

シーケンス図

起動シーケンスの全体像は以下のとおりである:

sequenceDiagram participant User as ユーザー participant Rust as Rust メイン participant Window as WebView ウィンドウ participant Sidecar as sidecar プロセス participant Server as 開発サーバー User->>Rust: .app を起動 Rust->>Sidecar: spawn_sidecar() Rust->>Window: WebviewUrl::default() で生成 Window-->>User: ローディングページが即座に表示 Rust->>Rust: thread::spawn(ポーリングループ) loop 1秒ごと Rust->>Server: curl http://localhost:PORT/ Server-->>Rust: HTTP ステータスコード end Sidecar->>Server: サーバーがリッスン開始 Rust->>Server: curl が 200 を返す Rust->>Window: navigate(server_url) Window-->>User: 実際のコンテンツが表示

適切な起動パターンの選択

アプリごとに起動時のニーズは異なる。この表は適切なアプローチを選ぶ手助けとなる:

シナリオ推奨パターンドキュメント
開発サーバー / sidecar のラップで 5〜30 秒の起動遅延があるWebviewUrl::default() によるバンドル済みローディング HTML + バックグラウンドポーリングこのページ
起動時の白フラッシュを回避したい SPA非表示ウィンドウ + PageLoadEvent::Finishedshow()ウィンドウの作成とライフサイクル
ブロッキング初期化がある(DB マイグレーション、認証、初回セットアップ)別のスプラッシュウィンドウ(フレームなし、透明)ウィンドウの作成とライフサイクル
開発モードのインラインローディングページdata: URL(webview-data-url cargo feature が必要)ウィンドウの作成とライフサイクル

💡 Tip

公式の Tauri v2 スプラッシュスクリーンガイドでは、可能な限り、別のスプラッシュウィンドウを使用するよりも、メインウィンドウを素早く表示してアプリ内のローディング状態を見せることを推奨している。

リフレッシュパターン

リフレッシュコマンド(Cmd+R)を実装する際に、同じローディング→ナビゲートのパターンを再利用できる:

fn do_refresh(app_handle: &AppHandle) {
    if !IS_DEV {
        let state = app_handle.state::<AppState>();
        if let Some(ref pnpm_path) = state.pnpm_path {
            let pnpm_path = pnpm_path.clone();
            let mut guard = state.sidecar.lock().unwrap();
            if let Some(mut old) = guard.take() {
                kill_sidecar(&mut old);
            }
            kill_port();
            *guard = Some(spawn_sidecar(&pnpm_path));
            drop(guard);
            wait_for_ready(Duration::from_secs(15));
        }
    }

    if let Some(w) = app_handle.get_webview_window("main") {
        let _ = w.navigate(server_url().parse().unwrap());
    }
}

この処理は、古い sidecar を kill し、ポートをクリーンアップし、新しい sidecar を起動し、準備完了を待ち、その後ナビゲートする。リフレッシュのタイムアウト(15秒)は初回起動のタイムアウト(120秒)より短い。これはサーバーが2回目以降の起動ではより速く起動することが期待されるためである。

エラー状態とリトライ

「スピナー+テキスト」だけの最小ローディングページはハッピーパスでは機能するが、sidecar が既に死んでいたり、ビルドがストールしている状況ではユーザーに嘘をつくことになる。sidecar が健全にビルドしているのか、クラッシュして戻ってこないのか、どちらの場合も同じスピナーが見える。

次のレベルのパターンは、ローディングページをエラーパネルも兼ねるものにすることだ。スピナーはハッピーパスで、非表示のエラーパネルがバックエンドから発火される launch-error イベントをリスンし、リトライボタン付きの操作可能な状態に切り替わる。

バックエンドの契約

Rust 側は準備完了ポーリングで sidecar の死亡やタイムアウトを検出した際に、ナビゲートする代わりに launch-error イベントを発火する:

// イベントペイロードの形
// { reason: "timeout" | "sidecar_exited", logPath: "/path/to/sidecar.log" }

対応する retry_launch コマンドがリスタートをトリガーするので、フロントエンドはリトライボタンを用意できる:

#[tauri::command]
fn retry_launch(app_handle: AppHandle) {
    // バックグラウンドスレッドで実行する -- IPC スレッドを絶対にブロックしない。
    // do_refresh() は失敗時に launch-error を再発火するため、
    // リトライが再度失敗した場合も自然に UI がエラー状態へ戻る。
    thread::spawn(move || {
        do_refresh(&app_handle);
    });
}

⚠️ Warning

長時間かかるリスタートを IPC スレッド上でインラインに走らせてはならない。Tauri の IPC ランタイムは小さなスレッドプールでコマンドをディスパッチする。sidecar を起動して 15 秒の準備完了を待ってから return する retry_launch は、その時間帯、他のコマンドをすべてブロックする。処理はバックグラウンドの std::thread にスポーンし、すぐに return すること。

フロントエンドのパネル

ローディング HTML に非表示のエラーパネルを追加する。インライン CSS と JS のみで構成する — このページはバンドル済みでバンドラがなく、import は選択肢にない:

<!-- frontend/index.html (省略版) -->
<body>
  <div id="spinner" class="spinner"></div>
  <div id="long-hint" class="sub" hidden>Taking longer than usual…</div>

  <div id="error-panel" hidden>
    <h2>Could not start the documentation server</h2>
    <p id="error-reason"></p>
    <p class="log"><code id="error-log-path"></code></p>
    <button id="retry">Retry</button>
    <button id="copy-log">Copy log path</button>
  </div>

  <script>
    const { event, core } = window.__TAURI__;

    event.listen("launch-error", ({ payload }) => {
      document.getElementById("spinner").hidden = true;
      document.getElementById("long-hint").hidden = true;
      document.getElementById("error-reason").textContent =
        payload.reason === "sidecar_exited"
          ? "The background server exited before becoming ready."
          : "The server took too long to respond.";
      document.getElementById("error-log-path").textContent = payload.logPath;
      document.getElementById("error-panel").hidden = false;
    });

    document.getElementById("retry").addEventListener("click", () => {
      document.getElementById("error-panel").hidden = true;
      document.getElementById("spinner").hidden = false;
      core.invoke("retry_launch");
    });

    document.getElementById("copy-log").addEventListener("click", () => {
      navigator.clipboard.writeText(
        document.getElementById("error-log-path").textContent,
      );
    });

    // ベルト・アンド・サスペンダーズ: 20 秒経ってもローディングページのまま
    // だったら、通常より時間がかかっていることをほのめかす。エラーとは別物 --
    // スピナーは回ったままで、パネルは非表示のまま。
    setTimeout(() => {
      if (document.getElementById("error-panel").hidden) {
        document.getElementById("long-hint").hidden = false;
      }
    }, 20_000);
  </script>
</body>

📝 Note

20 秒の「時間がかかっています」ヒントは、エラーパネルとは視覚的に区別する。初回の遅いビルド(大きな依存関係インストール、cargo キャッシュが冷えている等)のユーザーに、失敗シグナルを誤って送らずに状況を伝えるのが狙いだ。エラーパネルはバックエンドが実際に launch-error を発火したときだけ表示する。

バンドルページの 2 つの前提条件

frontend/index.html はバンドル済みでバンドラがないため、Tauri API に到達する手段は window.__TAURI__ しかない。設定で 2 つのディテールを揃える必要がある:

  1. tauri.conf.jsonwithGlobalTauri: true — Tauri v2 ではデフォルトが false で、window.__TAURI__ そのものが存在しない状態になる。これを忘れると event.listencore.invoke は無言で何もせず、パネルの配線が成立しない。
  2. core:default ケイパビリティevent:listen とカスタムコマンドの invoke の両方がこれでカバーされている。追加のケイパビリティ付与は不要。

設定の詳細は IPC → バンドル済みローディングページからの IPC 呼び出しを参照。

シーケンス

sequenceDiagram participant User as ユーザー participant Rust as Rust メイン participant Window as ローディングページ participant Sidecar Rust->>Sidecar: spawn_sidecar() Rust->>Window: ローディングページ表示 Note over Window: スピナー + "Starting…" alt sidecar が早期に死亡 Sidecar--xRust: /___ready が 200 を返す前に終了 Rust->>Window: emit("launch-error", { reason: "sidecar_exited" }) Window-->>User: エラーパネル + リトライボタン User->>Window: リトライをクリック Window->>Rust: invoke("retry_launch") Rust->>Sidecar: バックグラウンドスレッドで do_refresh() else 健全だが遅い Note over Window: 20秒後、"Taking longer than usual…" Sidecar-->>Rust: /___ready → 200 Rust->>Window: navigate(server_url) Window-->>User: 実コンテンツ end