親ウィンドウに対してモーダル表示する (Vite Multi-page)

サブウィンドウを parent 付きで開き、親を setEnabled(false)(権限 core:window:allow-set-enabled)で止め、閉じたら戻す。どの閉じ方でも復帰させる方法と OS ごとの違いも示す。

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

入力フォームや確認画面をサブウィンドウで出し、閉じるまで親ウィンドウを操作させたくないことがあります。Tauri のウィンドウには「モーダルにする」という 1 つの設定は無いので、parent で親子関係を付けて親より手前に保つことと、親を setEnabled(false) で操作できなくすることを組み合わせます。大事なのは、どんな閉じ方をされても親を元に戻すことです。

サブウィンドウ用のページ(ここでは modal.html)の用意と Vite の設定は 新しいサブウィンドウを開く と同じです。ネイティブのダイアログや HTML の <dialog> で足りる場面も多いので、どれを使うかは 操作をブロックするモーダルダイアログにする で比べてください。

前提条件

プラグインは不要です。親(main)がウィンドウを作る権限、自分を無効・有効にする core:window:allow-set-enabled、閉じた後にフォーカスを戻す core:window:allow-set-focus を追加します。どれも core:default には含まれません。モーダル側は自分の「閉じる」ボタンで close() を呼ぶので、そのラベルも windows に入れます。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main and modal windows",
  "windows": ["main", "modal"],
  "permissions": [
    "core:default",
    "core:webview:allow-create-webview-window",
    "core:window:allow-set-enabled",
    "core:window:allow-set-focus",
    "core:window:allow-close"
  ]
}
使うもの役割
作成時の parent親より手前に保つ(意味は OS ごとに違う。後述)
親の setEnabled(false)親へのクリックやキー入力を受け付けなくする
モーダルの tauri://destroyedどの閉じ方でも親を有効に戻す合図にする

1. フロントエンドから実装する (TypeScript)

親ウィンドウ側

親を無効にするのは作成に成功してからにします。先に無効にすると、作成に失敗したときに親が操作できないまま残ります。復帰は「閉じるボタン」ではなく、モーダルが破棄されたときの tauri://destroyed で行います。× ボタンや Alt+F4 で閉じられても、ここは必ず通ります。

関数はモーダルが閉じたときに解決する Promise を返すので、呼び出し側は await openModal() でダイアログのように待てます。

import { WebviewWindow } from '@tauri-apps/api/webviewWindow';
import { getCurrentWindow } from '@tauri-apps/api/window';

const LABEL = 'modal';

// モーダルが閉じられたら解決する
export async function openModal(): Promise<void> {
  const parent = getCurrentWindow();
  const existing = await WebviewWindow.getByLabel(LABEL);
  if (existing) {
    await existing.setFocus();
    return;
  }

  const modal = new WebviewWindow(LABEL, {
    url: '/modal.html',
    title: '確認',
    width: 420,
    height: 220,
    resizable: false,
    minimizable: false,
    center: true,
    parent, // 親子関係を付ける(alwaysOnTop は付けない)
  });

  await new Promise<void>((resolve, reject) => {
    void modal.once('tauri://created', () => resolve());
    void modal.once<string>('tauri://error', (e) => reject(new Error(String(e.payload))));
  });

  // 閉じられ方に関係なく、破棄されたら親を戻す(登録を待ってから親を止める)
  let onClosed!: () => void;
  const closed = new Promise<void>((resolve) => (onClosed = resolve));
  await modal.once('tauri://destroyed', async () => {
    await parent.setEnabled(true);
    await parent.setFocus(); // 別のアプリが前に出ることがあるので親に戻す
    onClosed();
  });
  await parent.setEnabled(false);
  return closed;
}

document.querySelector('#open-modal')?.addEventListener('click', async () => {
  try {
    await openModal();
    console.log('モーダルが閉じられました');
  } catch (e) {
    console.error('モーダルを開けません:', e);
  }
});

alwaysOnTop: true は付けません。これは親だけでなく他のアプリより手前にも居座る設定で、ブラウザに切り替えてもモーダルが上に残ります。親より手前に保つだけなら parent で足ります。

モーダル側

モーダル側は普通のサブウィンドウと同じで、閉じるだけです。入力内容を親に返すには、閉じる前に emitTo() で親へ送ります(別のウィンドウ作成時にデータを渡す)。

import { getCurrentWindow } from '@tauri-apps/api/window';

const win = getCurrentWindow();

for (const id of ['ok', 'cancel']) {
  document.getElementById(id)?.addEventListener('click', () => void win.close());
}
window.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') void win.close(); // Esc でも閉じられるようにする
});

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

JS の後始末は親のページが動いていることが前提です。開発中にファイルを保存してページが再読み込みされると、tauri://destroyed の処理ごと消え、親が無効のまま残ります。Rust で開いてウィンドウのイベントで戻すと、ページの状態に左右されません。JS 側のウィンドウ作成・setEnabled・setFocus の権限も要らなくなります。

コマンドの引数に tauri::WebviewWindow を書くと呼び出し元(親)が渡されます。Windows ではウィンドウを作るコマンドを同期にすると固まるので async にします。

use tauri::{Manager, WebviewUrl, WebviewWindowBuilder, WindowEvent};

#[tauri::command]
async fn open_modal(parent: tauri::WebviewWindow) -> Result<(), String> {
    let app = parent.app_handle().clone();
    if let Some(existing) = app.get_webview_window("modal") {
        return existing.set_focus().map_err(|e| e.to_string());
    }
    let modal = WebviewWindowBuilder::new(&app, "modal", WebviewUrl::App("modal.html".into()))
        .title("確認")
        .inner_size(420.0, 220.0)
        .resizable(false)
        .minimizable(false)
        .center()
        .parent(&parent)
        .map_err(|e| e.to_string())?
        .build()
        .map_err(|e| e.to_string())?;

    // 作成に成功してから親を止め、モーダルが破棄されたら戻す
    parent.set_enabled(false).map_err(|e| e.to_string())?;
    let owner = parent.clone();
    modal.on_window_event(move |event| {
        if let WindowEvent::Destroyed = event {
            let _ = owner.set_enabled(true);
            let _ = owner.set_focus();
        }
    });
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .invoke_handler(tauri::generate_handler![open_modal])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

await invoke('open_modal'); // 作成が終わった時点で戻る(閉じるまでは待たない)

動作確認

npm run tauri dev で #open-modal のボタンを押すと、画面中央に「確認」ウィンドウが開きます。この間は親をクリックしても反応せず、別のアプリに切り替えるとモーダルも親と一緒に後ろへ回ります。OK・キャンセル・Esc・× のどれで閉じても親が操作できる状態に戻り、親のコンソールに「モーダルが閉じられました」と出ます。

親が止まっていることは、モーダル側のスクリプトに次を足すと確かめられます。isEnabled() の権限は core:default に含まれます。

import { Window } from '@tauri-apps/api/window';

const main = await Window.getByLabel('main');
console.log('main enabled:', await main?.isEnabled()); // モーダルを開いている間は false

よくあるエラーと対処法

  • 「window.set_enabled not allowed. Permissions associated with this command: core:window:allow-set-enabled」: 権限の追加漏れです。リリースビルドでは「Command plugin:window|set_enabled not allowed by ACL」になります。
  • 親が無効のまま戻らない: 親を無効にした後で例外が起きたか、親のページが再読み込みされて後始末が消えています。開発中に固まったらアプリを再起動し、確実さが必要なら Rust 版にします。
  • 「window not found」でモーダルが開かない: parent に渡したラベルのウィンドウがありません。ラベルの綴りか、親が既に閉じていないかを確かめます。
  • モーダルが他のアプリより手前に残る: alwaysOnTop: true が付いています。外して parent だけにします。
  • モーダルを閉じても親にフォーカスが戻らない: setFocus() を呼んでいないか、core:window:allow-set-focus がありません。

OS ごとの違いと注意点

parent の意味は OS ごとに違います。

  • Windows: 親が「オーナー」になります。モーダルは常に親より手前に表示され、親が最小化されると一緒に隠れ、親が破棄されると一緒に破棄されます。
  • macOS: 親の子ウィンドウとして追加されます。
  • Linux: 親に対する一時的なウィンドウ(transient)として扱われます。また minimizable: false は Linux では効きません。
  • 共通: モーダル側で閉じる前の確認をする場合は ウィンドウを閉じる前に確認ダイアログを出す と組み合わせます。確認でキャンセルされたときは破棄されないので、親も無効のまま保たれます。
  • 共通: 画面の一部を覆うだけで足りるなら、ウィンドウを増やさない HTML/CSS の自作ダイアログ の方が手軽です。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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