zudo-tauri

Type to search...

to open search from anywhere

ウィンドウの作成とライフサイクル

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

スプラッシュスクリーン、開発サーバーポーリング、macOS 固有の調整、ウィンドウ破棄時のクリーンアップのパターン。

なぜ Rust でウィンドウを作成するのか

Tauri v2 は tauri.conf.json でウィンドウを定義できるが、Rust でプログラム的に作成することで以下を制御できる:

  • スプラッシュスクリーンのタイミング
  • 開発サーバーの準備完了ポーリング
  • 条件付きウィンドウ作成
  • 複数ウィンドウインスタンス
  • プラットフォーム固有の設定

スプラッシュスクリーンパターン

アプリの初期化中に軽量なスプラッシュウィンドウを表示し、その後メインウィンドウに置き換える:

.setup(|app| {
    // 1. Create splash window (non-fatal if it fails)
    if let Err(e) = native::window::create_splash_window(app.handle()) {
        eprintln!("Failed to create splash window: {e}");
    }

    // 2. Do initialization work
    let project_root = resolve_project_root();
    let app_state = Arc::new(AppState::new(project_root));
    app.manage(app_state);

    // Start file watchers, etc.

    // 3. Close splash, create main window
    native::window::close_splash_window(app.handle());
    native::window::create_main_window(app.handle())?;

    Ok(())
})

スプラッシュウィンドウの作成

スプラッシュウィンドウはフレームなし、透明、常に最前面で表示される:

pub fn create_splash_window(
    handle: &tauri::AppHandle,
) -> tauri::Result<()> {
    let url = WebviewUrl::App("../frontend/splash.html".into());

    let _window = WebviewWindowBuilder::new(handle, "splash", url)
        .title("myapp")
        .inner_size(400.0, 200.0)
        .decorations(false)
        .transparent(true)
        .always_on_top(true)
        .resizable(false)
        .build()?;

    Ok(())
}

スプラッシュの閉じ方

pub fn close_splash_window(handle: &tauri::AppHandle) {
    if let Some(window) = handle.get_webview_window("splash") {
        let _ = window.close();
    }
}

📝 Note

プログラムでウィンドウを作成する場合、tauri.conf.json"windows" 配列は空にしておく:"windows": []。両方の場所でウィンドウを定義すると、重複が発生する。

開発サーバーポーリング付きメインウィンドウ

開発時には、ウィンドウ作成時に Vite 開発サーバーがまだ準備できていない場合がある。パターンは:ローディングページを即座に表示し、サーバーが応答したら開発サーバーにナビゲートする。

const DEV_SERVER_URL: &str = "http://localhost:37461";

pub fn create_main_window(
    handle: &tauri::AppHandle,
) -> tauri::Result<()> {
    // Unique label for multiple windows
    let label = if handle.get_webview_window("main").is_some() {
        format!("main-{}", std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_millis())
    } else {
        "main".to_string()
    };

    let url = if cfg!(debug_assertions) {
        // Inline loading page as data: URL
        WebviewUrl::External(
            loading_page_data_url().parse().unwrap(),
        )
    } else {
        WebviewUrl::default() // Bundled frontend
    };

    let window = WebviewWindowBuilder::new(handle, &label, url)
        .title("myapp")
        .inner_size(1400.0, 800.0)
        .visible(false) // Start hidden
        .on_navigation(move |url: &tauri::Url| {
            let url_str = url.as_str();
            // Allow local and tauri URLs
            if url_str.starts_with("http://localhost")
                || url_str.starts_with("tauri://")
                || url_str.starts_with("data:")
            {
                return true;
            }
            // Open external URLs in default browser
            if url_str.starts_with("http://")
                || url_str.starts_with("https://")
            {
                let owned = url_str.to_string();
                tauri::async_runtime::spawn_blocking(move || {
                    let _ = open::that(owned);
                });
                return false;
            }
            false // Block unknown schemes
        })
        .build()?;

    // In dev: poll Vite, navigate when ready
    if cfg!(debug_assertions) {
        let win = window.clone();
        std::thread::spawn(move || {
            if wait_for_dev_server(DEV_SERVER_URL, 120) {
                let url: tauri::Url =
                    DEV_SERVER_URL.parse().unwrap();
                let _ = win.navigate(url);
            }
        });
    }

    // Show after a short delay
    let win = window.clone();
    std::thread::spawn(move || {
        std::thread::sleep(std::time::Duration::from_millis(200));
        let _ = win.show();
    });

    Ok(())
}

開発サーバーの準備完了チェック

HTTP リクエストの代わりに TCP 接続試行を使用してより高速にポーリングする:

fn wait_for_dev_server(url: &str, timeout_secs: u64) -> bool {
    let addr = url
        .strip_prefix("http://")
        .or_else(|| url.strip_prefix("https://"))
        .and_then(|h| h.split('/').next())
        .expect("invalid URL");

    let start = std::time::Instant::now();
    let timeout = std::time::Duration::from_secs(timeout_secs);

    while start.elapsed() < timeout {
        if std::net::TcpStream::connect(addr).is_ok() {
            return true;
        }
        std::thread::sleep(std::time::Duration::from_millis(500));
    }
    false
}

💡 Tip

TcpStream::connect は準備完了チェックにおいて HTTP リクエストより高速である。完全な HTTP レスポンスを待たずに、ポートが開いた時点で即座に返る。

macOS:長押しアクセント文字メニューの抑制

macOS はキーを長押しするとアクセント文字ポップアップを表示する。これはキーリピートを使用するアプリ(テキストエディタ、ターミナルエミュレーターなど)に干渉する。setup() 内で抑制する:

#[cfg(target_os = "macos")]
{
    use objc2_foundation::{NSUserDefaults, NSString};
    let defaults = NSUserDefaults::standardUserDefaults();
    unsafe {
        let key = NSString::from_str("ApplePressAndHoldEnabled");
        defaults.setBool_forKey(false, &key);
    }
}

📝 Note

これは現在のプロセスにのみ影響する。システム全体の設定は変更しない。他のアプリケーションは通常通りアクセントメニューを表示し続ける。

外部リンクの処理

on_navigation コールバックは、WebView が読み込みを許可する URL を制御する。外部リンクをデフォルトブラウザで開くために使用する:

.on_navigation(move |url: &tauri::Url| {
    let url_str = url.as_str();

    // Allow local dev server and tauri asset URLs
    if url_str.starts_with("http://localhost")
        || url_str.starts_with("https://localhost")
        || url_str.starts_with("tauri://")
        || url_str.starts_with("asset://")
        || url_str.starts_with("data:")
    {
        return true;
    }

    // External HTTP(S) links -> default browser
    if url_str.starts_with("http://")
        || url_str.starts_with("https://")
    {
        let owned = url_str.to_string();
        tauri::async_runtime::spawn_blocking(move || {
            let _ = open::that(owned);
        });
        return false;
    }

    // Block unknown schemes (javascript:, file:, etc.)
    false
})

⚠️ Warning

javascript: および file: スキームは常にブロックする。これらを許可すると、WebView にセキュリティ脆弱性が生じる。

ウィンドウ破棄イベントの処理

ウィンドウが破棄された際にリソースをクリーンアップするために on_window_event を使用する:

.on_window_event(|window, event| {
    if let tauri::WindowEvent::Destroyed = event {
        if window.label() == "main" {
            if let Some(state) = window.try_state::<Arc<AppState>>() {
                // Kill all PTY sessions
                commands::terminal::kill_all_ptys(&state);
            }
        }
    }
})

サイドカープロセスのクリーンアップ

サイドカープロセスを生成するアプリでは、run コールバックを使用してウィンドウ破棄時にクリーンアップする:

.build(tauri::generate_context!())
.expect("error while building tauri application")
.run(move |app_handle, event| match &event {
    tauri::RunEvent::WindowEvent {
        event: tauri::WindowEvent::Destroyed,
        ..
    } => {
        if let Ok(mut guard) = sidecar_arc.lock() {
            if let Some(mut sidecar) = guard.take() {
                kill_sidecar(&mut sidecar);
            }
        }
        app_handle.exit(0);
    }
    _ => {}
});

⚠️ Warning

アプリが子プロセス(PTY セッション、サイドカーサーバー)を生成する場合、ウィンドウ破棄時に明示的に kill する必要がある。そうしなければ、アプリ終了後もオーファンプロセスとして実行し続ける。

ウィンドウライフサイクルの概要

flowchart TD A[アプリ起動] --> B[スプラッシュウィンドウを作成] B --> C[AppState を初期化] C --> D[ファイルウォッチャーを開始] D --> E[スプラッシュウィンドウを閉じる] E --> F[メインウィンドウを作成 - 非表示] F --> G{開発モード?} G -->|はい| H[ローディングページを表示] H --> I[バックグラウンドスレッドで開発サーバーをポーリング] I --> J[開発サーバー URL にナビゲート] G -->|いいえ| K[バンドル済みフロントエンドを読み込み] J --> L[ウィンドウを表示] K --> L L --> M[アプリ実行中] M --> N[ウィンドウ破棄] N --> O[PTY セッションを kill] O --> P[サイドカープロセスを kill] P --> Q[終了]

重要なポイント

  1. プログラムでウィンドウを作成する場合、tauri.conf.json"windows": [] を空にする
  2. ウィンドウは非表示で開始する — コンテンツが準備できてから表示し、白いフラッシュを回避する
  3. ローディングページには data: URL を使用する — バンドルされた HTML ファイルは不要
  4. TCP 接続で開発サーバーをポーリングする — HTTP リクエストより高速
  5. 子プロセスはウィンドウ破棄時に必ずクリーンアップする
  6. on_navigation でリンクの動作を制御する — 外部リンクはデフォルトブラウザで開く
  7. プラットフォーム固有のコード#[cfg(target_os = "macos")] ガードの背後に配置する