ウィンドウを閉じる前に確認ダイアログを出す

onCloseRequested() と dialog プラグインの 3 択で未保存時だけ確認する。core:window:allow-destroy が無いと × で閉じない理由と Rust の prevent_close() も示す。

ウィンドウ 対象: Tauri 2.x 更新日: 読了目安: 約8分 win-016
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. 未保存のときだけ 3 択で確認する
  4. 2. バックエンドから実装する (Rust)
  5. 動作確認
  6. よくあるエラーと対処法
  7. OS ごとの違いと注意点
  8. 関連レシピ

編集中の文書を保存せずに × を押された、長い処理の途中で閉じられた、という事故は確認ダイアログで防げます。閉じる操作は 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() の違いは 「はい・いいえ」の確認ダイアログを出す を参照してください。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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