編集中の文書を保存せずに × を押された、長い処理の途中で閉じられた、という事故は確認ダイアログで防げます。閉じる操作は JS の onCloseRequested() か、Rust の on_window_event に届く CloseRequested で受け取り、止めるかどうかを決めます。確認の表示には Dialog プラグインを使い、「保存して閉じる / 保存せずに閉じる / キャンセル」の 3 択にします。仕組みを知らずに書くと「× を押しても閉じなくなった」になりやすいので、その理由から説明します。
前提条件
確認ダイアログ用に Dialog プラグインを追加します。
npm run tauri add dialog
JS で onCloseRequested() を登録すると、Tauri は閉じる操作をいったん止めてハンドラーを呼びます。ハンドラーの処理が終わって preventDefault() が呼ばれていなければ、API が代わりに destroy() を呼んでウィンドウを閉じます。そのため core:window:allow-destroy が要ります。これは core:default に含まれないので、忘れると × を押しても閉じなくなります。ダイアログの message() は dialog:default で使えます。アプリ内の「終了」ボタンから close() を呼ぶなら core:window:allow-close も足します。
{
"$schema": "../gen/schemas/desktop-schema.json",
"identifier": "default",
"description": "Capability for the main window",
"windows": ["main"],
"permissions": [
"core:default",
"core:window:allow-destroy",
"core:window:allow-close",
"dialog:default"
]
}
1. フロントエンドから実装する (TypeScript)
未保存のときだけ 3 択で確認する
API がハンドラーの完了を待ってから判定するので、await でダイアログの結果を待った後に preventDefault() を呼んでも間に合います。変更が無ければ何もせずに戻れば、そのまま閉じます。
message() の buttons にラベルを渡すと、押されたボタンのラベルがそのまま返ります。Esc などで閉じられた場合も含め、想定外の値はキャンセル扱いにします。ダイアログは呼び出したウィンドウに結び付けて表示されます。
import { getCurrentWindow } from '@tauri-apps/api/window';
import { message } from '@tauri-apps/plugin-dialog';
const SAVE = '保存して閉じる';
const DISCARD = '保存せずに閉じる';
let dirty = false; // 未保存の変更があるか
document.querySelector('textarea')?.addEventListener('input', () => {
dirty = true;
});
async function save(): Promise<void> {
// 実際の保存処理。失敗したら例外を投げる
dirty = false;
}
// 戻り値はコンポーネントの破棄時などに呼んで登録を外す関数
export const unlistenClose = await getCurrentWindow().onCloseRequested(async (event) => {
if (!dirty) return; // 変更が無ければそのまま閉じる
try {
const choice = await message('変更が保存されていません。', {
title: '閉じる前の確認',
kind: 'warning',
buttons: { yes: SAVE, no: DISCARD, cancel: 'キャンセル' },
});
if (choice === SAVE) {
await save();
} else if (choice !== DISCARD) {
event.preventDefault(); // キャンセル
}
} catch (e) {
console.error(e);
event.preventDefault(); // 保存に失敗したら閉じない
}
});
アプリ内の「終了」ボタンやメニューからは getCurrentWindow().close() を呼びます。× ボタンと同じくこの確認を通ります。確認を飛ばして閉じるのが destroy() です。未保存マークをタイトルに出す方法は ウィンドウのタイトルを動的に変える で扱っています。
2. バックエンドから実装する (Rust)
「バックグラウンドの処理が走っている」のように判断材料が Rust 側にあるなら、on_window_event で CloseRequested を受けて api.prevent_close() で止めます。ここは Rust のメインスレッドで呼ばれるので、結果を待って止まる blocking_show() は使えません(アプリが固まります)。show() のコールバックで結果を受け取り、閉じてよければ destroy() を呼びます。close() を呼ぶと再び CloseRequested が届き、確認が繰り返されます。
use std::sync::atomic::{AtomicBool, Ordering};
use tauri::{Manager, WindowEvent};
use tauri_plugin_dialog::{DialogExt, MessageDialogButtons, MessageDialogKind};
struct Busy(AtomicBool);
#[tauri::command]
fn set_busy(state: tauri::State<'_, Busy>, busy: bool) {
state.0.store(busy, Ordering::SeqCst);
}
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
tauri::Builder::default()
.plugin(tauri_plugin_dialog::init())
.manage(Busy(AtomicBool::new(false)))
.on_window_event(|window, event| {
if let WindowEvent::CloseRequested { api, .. } = event {
if window.label() != "main" || !window.state::<Busy>().0.load(Ordering::SeqCst) {
return; // 確認が要らなければ止めない
}
api.prevent_close();
let target = window.clone();
window
.dialog()
.message("処理が終わっていません。中断して閉じますか?")
.title("閉じる前の確認")
.kind(MessageDialogKind::Warning)
.buttons(MessageDialogButtons::OkCancelCustom(
"中断して閉じる".into(),
"続ける".into(),
))
.parent(window)
.show(move |ok| {
if ok {
let _ = target.destroy(); // close() だと確認が繰り返される
}
});
}
})
.invoke_handler(tauri::generate_handler![set_busy])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';
await invoke('set_busy', { busy: true }); // 処理の開始時。終わったら false に戻す
同じウィンドウで JS の onCloseRequested() も登録していると、JS 側が「止められていない」と判断して destroy() を呼ぶため、Rust で止めても閉じてしまいます。1 つのウィンドウではどちらか一方にします。
動作確認
npm run tauri dev で起動し、何も入力せずに × を押すとすぐ閉じます。テキストエリアに入力してから × を押すと、3 つのボタンの警告ダイアログが出ます。「キャンセル」では何も起きず、「保存せずに閉じる」ではウィンドウが閉じ、最後のウィンドウならアプリも終了します。Rust 版は set_busy で true にしてから × を押すと「中断して閉じる / 続ける」の確認が出ます。
よくあるエラーと対処法
- × を押しても閉じない(「閉じる」を選んでも閉じない):
core:window:allow-destroyがありません。コンソールに「window.destroy not allowed. Permissions associated with this command: core:window:allow-destroy」が出ます。リリースビルドでは「Command plugin:window|destroy not allowed by ACL」です。 - ハンドラーで例外が起きるとウィンドウが閉じない: 捕まえていない例外が起きると、API は
destroy()まで進みません。上の例のようにtry/catchで囲み、閉じるか止めるかを必ず決めます。 - 確認ダイアログが 2 回出る:
onCloseRequested()を 2 回登録しています。開発中の再読み込みや、SPA の画面を作り直すたびに登録していないかを確かめ、戻り値の関数で古い登録を外します。 - Rust で止めたのに閉じてしまう: 同じウィンドウに JS の
onCloseRequested()もあります。前の節のとおり、どちらか一方にします。 - 確認して閉じたのに、もう一度確認が出る: 確認後に
close()を呼んでいます。destroy()に替えます。
OS ごとの違いと注意点
- 確認を通らない終了:
destroy()、Process プラグインのexit()、Rust のapp.exit()はCloseRequestedを出さずに閉じます。これらを呼ぶ前には、アプリ側で未保存の有無を確かめます。 - アプリ全体の終了: 最後のウィンドウが閉じるとアプリは終了します。ウィンドウ単位ではなくアプリの終了そのものを止めたいときは、Rust の
RunEvent::ExitRequestedでprevent_exit()を呼びます(アプリ起動・終了時の処理を書く)。 - 閉じるボタンを無効にする: 処理中は確認ではなく × 自体を押せなくする
setClosable(false)(権限core:window:allow-set-closable)もあります。ただし Linux では、表示中のウィンドウに呼ぶと環境によっては効かず、iOS / Android では使えません。 - サブウィンドウ: 確認はウィンドウごとに登録します。サブウィンドウで使うなら、そのラベルも capability の
windowsに入れます(新しいサブウィンドウを開く)。モーダルのサブウィンドウで確認がキャンセルされた場合、親は無効のまま保たれます(win-018)。 - はい・いいえだけの確認や
ask()/confirm()の違いは 「はい・いいえ」の確認ダイアログを出す を参照してください。
