「起動時に設定を読み込む」「データ用のフォルダを作る」「終了前に保存する」「子プロセスを止める」といった処理は、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 のものを除く) | 自動 |
| 3 | setup を 1 回だけ実行 | .setup() |
| 4 | RunEvent::Ready | run() のコールバック |
| 5 | 実行中の WindowEvent や MenuEvent | 同上 |
| 6 | 最後のウィンドウが閉じた、または exit() で ExitRequested | 同上(prevent_exit() で取り消せる) |
| 7 | RunEvent::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の処理が終わるまでプロセスは終わらないので、重い処理を書くと終了が遅く見えます。保存は必要な分だけにします。
