アプリ起動・終了時の処理を書く (ライフサイクル)

setup で起動時の初期化を行い、run() のコールバックで RunEvent を受けて終了前に保存する。prevent_exit() で終了を止める条件と、Exit が届かない終わり方も示す。

Rust バックエンド 対象: Tauri 2.x 更新日: 読了目安: 約8分 rust-012
目次
  1. 前提条件
  2. 1. バックエンドから実装する (Rust)
  3. 起動時: setup
  4. 実行中と終了時: run() のコールバック
  5. 2. フロントエンドから呼び出す (TypeScript)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

「起動時に設定を読み込む」「データ用のフォルダを作る」「終了前に保存する」「子プロセスを止める」といった処理は、Rust 側のライフサイクルの入り口に書きます。起動時は Builder::setup()、実行中から終了までは App::run() に渡すコールバックで RunEvent を受けます。どの順番で何が呼ばれるか、終了を止める prevent_exit() の正しい条件、後始末が動かない終わり方を説明します。

前提条件

プラグインも権限も要りません。コードは src-tauri/src/lib.rs の run() に書きます。テンプレートの .run(tauri::generate_context!()) は「作って、何も受けずに動かす」の省略形なので、RunEvent を受けるには .build() と app.run(コールバック) に分けます。

起動から終了までは次の順に進みます。

順番起きること書く場所
1プラグインの初期化、manage() した値の登録Builder
2設定ファイルのウィンドウを作成("create": false のものを除く)自動
3setup を 1 回だけ実行.setup()
4RunEvent::Readyrun() のコールバック
5実行中の WindowEvent や MenuEvent同上
6最後のウィンドウが閉じた、または exit() で ExitRequested同上(prevent_exit() で取り消せる)
7RunEvent::Exit の後、プロセスが終わる同上

1. バックエンドから実装する (Rust)

例として、前回のメモを起動時に読み込み、終了時に書き戻すアプリを作ります。まず State とコマンド、保存の関数を用意します(State は State と Mutex でアプリの状態を管理する)。

use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::Mutex;
use tauri::{AppHandle, Emitter, Manager, RunEvent};

/// 起動時に読み込み、終了時に書き戻すメモ
#[derive(Default)]
struct Memo {
    text: Mutex<String>,
}

/// true のとき、ウィンドウをすべて閉じても終了しない(常駐)
#[derive(Default)]
struct Resident(AtomicBool);

#[tauri::command]
fn get_memo(memo: tauri::State<'_, Memo>) -> String {
    memo.text.lock().unwrap().clone()
}

#[tauri::command]
fn set_memo(memo: tauri::State<'_, Memo>, text: String) {
    *memo.text.lock().unwrap() = text;
}

#[tauri::command]
fn set_resident(resident: tauri::State<'_, Resident>, on: bool) {
    resident.0.store(on, Ordering::SeqCst);
}

#[tauri::command]
fn quit(app: AppHandle) {
    app.exit(0); // ExitRequested(code: Some(0))→ Exit の順に届く
}

/// メモをファイルに書き出す(終了時に呼ぶ)
fn save_memo(app: &AppHandle) -> Result<(), Box<dyn std::error::Error>> {
    let text = app.state::<Memo>().text.lock().map_err(|e| e.to_string())?.clone();
    let dir = app.path().app_config_dir()?;
    std::fs::write(dir.join("memo.txt"), text)?;
    Ok(())
}

起動時: setup

setup の引数 app からは、パス、State、ウィンドウ(ラベルで取る)を使えます。Err を返すとアプリは起動をやめるので、無くても続けられる処理は失敗を握りつぶして既定値で進めます。setup が終わるまでイベントの処理が止まり、画面の表示や応答が遅れるので、時間のかかる準備は別スレッドへ回します。

実行中と終了時: run() のコールバック

終了の流れは 2 段階です。ExitRequested は「終わろうとしている」合図で、prevent_exit() で取り消せます。code は、利用者が最後のウィンドウを閉じたときは None、AppHandle の exit() や JS の Process プラグインの exit() で終えたときは Some(コード) です。取り消しを繰り返せば何度でも届くので、保存は 1 回だけ届く Exit に書きます。

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    let app = tauri::Builder::default()
        .manage(Memo::default())
        .manage(Resident::default())
        .setup(|app| {
            // 1. 失敗したら起動をやめる処理は ? で返す
            let dir = app.path().app_config_dir()?;
            std::fs::create_dir_all(&dir)?;
            // 2. 無くても続けられるものは既定値で始める
            let saved = std::fs::read_to_string(dir.join("memo.txt")).unwrap_or_default();
            *app.state::<Memo>().text.lock().unwrap() = saved;
            // 3. 時間のかかる準備は別スレッドへ回し、setup はすぐ終える
            let handle = app.handle().clone();
            std::thread::spawn(move || {
                std::thread::sleep(std::time::Duration::from_secs(2)); // 準備の代わり
                let _ = handle.emit("warmed-up", ()); // この時点で listen しているページにだけ届く
            });
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![get_memo, set_memo, set_resident, quit])
        .build(tauri::generate_context!())
        .expect("error while building tauri application");

    app.run(|handle, event| match event {
        RunEvent::Ready => println!("Ready"),
        RunEvent::ExitRequested { code, api, .. } => {
            println!("ExitRequested: code = {code:?}");
            // 止めるのは、利用者がウィンドウを閉じたとき(None)だけ。exit() は止めない
            if code.is_none() && handle.state::<Resident>().0.load(Ordering::SeqCst) {
                api.prevent_exit();
            }
        }
        RunEvent::Exit => match save_memo(handle) {
            // ここは同期的に書く。戻るとすぐプロセスが終わる
            Ok(()) => println!("Exit: saved"),
            Err(e) => eprintln!("Exit: failed to save: {e}"),
        },
        _ => {}
    });
}

app.run() は戻らず、終了時にそのままプロセスを終えるので、後ろに書いたコードは実行されません。Exit の中で spawn した非同期処理も、終わる前にプロセスが終わります。非同期の関数しか無い後始末は tauri::async_runtime::block_on() で待ちます。デスクトップでは、終了コードを受け取って後ろの処理を続けられる run_return() もあります。

常駐させるなら、戻る手段(トレイなど)を必ず用意します(ウィンドウを持たない常駐アプリを作る)。ウィンドウ単位で閉じるのを止める場合は、prevent_exit() ではなく 閉じる前の確認 の方法を使います。

2. フロントエンドから呼び出す (TypeScript)

setup で emit() しても、ページの JS がまだ listen() していなければ届きません。起動時に用意した値は State に置き、ページの読み込み時に invoke で取りに行きます。

import { invoke } from '@tauri-apps/api/core';
import { listen } from '@tauri-apps/api/event';

// setup で読み込んだ値は、イベントを待たずに取りに行く
const textarea = document.querySelector('textarea');
if (textarea) {
  textarea.value = await invoke<string>('get_memo');
  textarea.addEventListener('input', () => {
    void invoke('set_memo', { text: textarea.value });
  });
}

// 準備の完了は、listen が間に合ったときだけ届く
await listen('warmed-up', () => console.log('準備完了'));

document.querySelector('#quit')?.addEventListener('click', () => {
  void invoke('quit');
});

動作確認

npm run tauri dev で起動し、テキストエリアに入力してからウィンドウを閉じると、ターミナルに次のように出ます。もう一度起動すると入力した文字が戻ります。終了ボタン(quit)で終えた場合は code = Some(0) になります。

Ready
ExitRequested: code = None
Exit: saved

invoke('set_resident', { on: true }) を呼んでから閉じると、ExitRequested だけが出てプロセスが残ります。確かめたらターミナルで Ctrl+C を押して止めます(外から止めることになるので、Exit: saved は出ません)。

よくあるエラーと対処法

  • 「Failed to setup app: error encountered during setup hook: ...」で落ちる: setup が Err を返しました。後ろに原因が続きます。リリースビルドでは何も表示されずに終わるので、利用者に知らせたい失敗は ? で返さず、ログや画面で伝えます。
  • 終了しても保存されない: Exit が届かない終わり方です。タスクマネージャーなどでの強制終了、パニック、std::process::exit() では届かず、開発中にターミナルの Ctrl+C で止めた場合も同じです。restart() も、メインスレッドから呼ぶと終了のイベントを飛ばすので、後始末が要るなら request_restart() を使います(JS の Process プラグインの relaunch() はこちらと同じ動きです)。
  • 「終了」を選んでも終わらない: code を見ずに毎回 prevent_exit() を呼んでいます。None のときだけにします。
  • setup で送ったイベントが届かない: ページがまだ listen() していません。上の例のように、値は invoke で取りに行きます。

OS ごとの違いと注意点

  • macOS: Dock のアイコンが押されると RunEvent::Reopen が、関連付けたファイルや URL で開かれると RunEvent::Opened が届きます(Opened は iOS / Android でも届きます)。他の OS には無い値なので、match の腕を #[cfg(target_os = "macos")] で囲みます。
  • iOS: run_return() は使えず、run() と同じ動きになります。
  • 共通: Exit の処理が終わるまでプロセスは終わらないので、重い処理を書くと終了が遅く見えます。保存は必要な分だけにします。

関連レシピ

参考リンク(公式ドキュメント)

Web Ninja

この記事を書いた人

Web Ninja ウェブエンジニア (Web Engineer)

会社員ネットワークエンジニアから独立してかれこれ 25 年以上 Web エンジニアとして活動中。普段は JavaScript と Node.js を自在に操り、時には C++ や Perl といった古流の技も嗜みます。近年は Tauri × Rust という新たな武器を手に、デスクトップアプリ開発の最前線を駆け抜けています。「作りたい」を「作れる」に変えるための、実践的な「技」をお届けします。

お問い合わせ: tauri.ninja@gmail.com

内容の誤り・動かないコードを報告する