新しいサブウィンドウを開く (Vite Multi-page)

Vite の Multi-page で専用 HTML を作り、new WebviewWindow()(core:webview:allow-create-webview-window)か Rust で開く。本番でメイン画面が出る原因も示す。

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

設定画面やヘルプのように、メインとは別のウィンドウを開く方法です。サブウィンドウ専用の HTML を Vite の Multi-page 機能で用意し、JS の new WebviewWindow() か Rust の WebviewWindowBuilder で開きます。つまずきやすいのは「本番ビルドだけサブウィンドウにメイン画面が出る」問題と、新しいウィンドウ側の権限です。

ここでは開くところまでを扱います。続きは 親ウィンドウに対してモーダル表示する、別のウィンドウ作成時にデータを渡す、ウィンドウを閉じる前に確認ダイアログを出す を参照してください。

前提条件

プラグインは不要です。JS からウィンドウを作る core:webview:allow-create-webview-window は core:default に含まれないので追加します。既に開いているウィンドウを前に出す setFocus() / unminimize() と、サブウィンドウ自身の「閉じる」ボタンで使う close() の権限も足します。

権限は呼び出したウィンドウのラベルで判定されます。サブウィンドウから close() などを呼ぶなら、そのラベル(ここでは settings)も windows に入れます。入れ忘れると、メインでは動くコードがサブウィンドウでだけ失敗します。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main and settings windows",
  "windows": ["main", "settings"],
  "permissions": [
    "core:default",
    "core:webview:allow-create-webview-window",
    "core:window:allow-set-focus",
    "core:window:allow-unminimize",
    "core:window:allow-close"
  ]
}

この書き方ではサブウィンドウにも作成の権限が付くので、避けたいなら main 用と settings 用に capability ファイルを分けます。同じ画面を複数開くならラベルを note-1、note-2 のように変え、windows に "note-*" と書きます。

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

サブウィンドウ用のページを Vite に追加する

サブウィンドウ用の HTML をプロジェクトの直下に置き、専用のスクリプトを読み込ませます。

.
├── index.html        # メインウィンドウ
├── settings.html     # サブウィンドウ
├── src/
│   ├── main.ts
│   └── settings.ts
├── src-tauri/
└── vite.config.ts
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <title>設定</title>
  </head>
  <body>
    <h1>設定</h1>
    <button id="close">閉じる</button>
    <script type="module" src="/src/settings.ts"></script>
  </body>
</html>

開発サーバーは置いた HTML をそのまま配信するので npm run tauri dev ではこれだけで開けますが、本番ビルドで出力されるのは既定で index.html だけです。vite.config.ts のビルド入力に 2 つ目のページを加えます。

import { defineConfig } from 'vite';

export default defineConfig({
  // clearScreen や server など、テンプレートにある既存の設定はそのまま残す
  build: {
    // Vite 7 以前は rollupOptions(Vite 8 でも別名として使える)
    rolldownOptions: {
      input: {
        main: 'index.html',
        settings: 'settings.html',
      },
    },
  },
});

Vite のドキュメントの例は resolve(__dirname, 'settings.html') のような絶対パスですが、@types/node の無いテンプレートではエディターが node:path の行を型エラーにします(ビルドは通ります)。npm スクリプトはプロジェクトの直下で動くので、相対パスなら Node の型は要りません。

ウィンドウを開く

new WebviewWindow() はウィンドウの作成を依頼するだけで、成否は tauri://created / tauri://error イベントで届きます。await で待てるように Promise に包みます。url はアプリ内のパスで、開発中は開発サーバー、本番ではアプリに埋め込んだファイルが読まれます。

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

const LABEL = 'settings';

// 作成の成否を Promise で受け取る(登録は new の直後に同期で行う)
function waitCreated(win: WebviewWindow): Promise<void> {
  return new Promise((resolve, reject) => {
    void win.once('tauri://created', () => resolve());
    void win.once<string>('tauri://error', (e) => reject(new Error(String(e.payload))));
  });
}

export async function openSettings(): Promise<WebviewWindow> {
  // 開いていれば新しく作らず、最小化を戻して前に出す
  const existing = await WebviewWindow.getByLabel(LABEL);
  if (existing) {
    await existing.unminimize();
    await existing.setFocus();
    return existing;
  }
  const win = new WebviewWindow(LABEL, {
    url: '/settings.html',
    title: '設定',
    width: 600,
    height: 400,
    resizable: false,
    center: true,
  });
  await waitCreated(win);
  return win;
}

document.querySelector('#open-settings')?.addEventListener('click', () => {
  openSettings().catch((e) => console.error('サブウィンドウを開けません:', e));
});

tauri://created はウィンドウができた時点で届き、サブウィンドウのページの読み込みはまだ終わっていません。直後にイベントでデータを送ると取りこぼすので、受け渡しは win-019 の手順で行います。

サブウィンドウ側のコード

settings.ts はメインとは別の JS として動くので、変数や状態は共有されません。自分自身は getCurrentWindow() で取れます。

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

const win = getCurrentWindow();
console.log(`label: ${win.label}`); // "settings"

// ボタンか Esc キーで閉じる(core:window:allow-close が必要)
document.querySelector('#close')?.addEventListener('click', () => void win.close());
window.addEventListener('keydown', (e) => {
  if (e.key === 'Escape') void win.close();
});

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

Rust のコマンドで開けば、JS にウィンドウ作成の権限を渡さずに済みます。この権限があるとスクリプトから任意の URL のウィンドウを開けるので、開く画面が決まっているならこちらが安全です。

ウィンドウを作るコマンドは async にします(理由は「OS ごとの違い」を参照)。大きさなどを設定ファイルで管理したいなら、app.windows に "create": false を付けて定義し、WebviewWindowBuilder::from_config() で作る方法もあります。

メインを閉じても、サブウィンドウが 1 つでも残っていればアプリは終了しません。まとめて終わらせたい場合は、main の Destroyed イベントで exit() を呼びます。

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

#[tauri::command]
async fn open_settings(app: tauri::AppHandle) -> Result<(), String> {
    if let Some(existing) = app.get_webview_window("settings") {
        existing.unminimize().map_err(|e| e.to_string())?;
        return existing.set_focus().map_err(|e| e.to_string());
    }
    WebviewWindowBuilder::new(&app, "settings", WebviewUrl::App("settings.html".into()))
        .title("設定")
        .inner_size(600.0, 400.0)
        .resizable(false)
        .center()
        .build()
        .map_err(|e| e.to_string())?;
    Ok(())
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .on_window_event(|window, event| {
            // main が閉じられたら、残っているサブウィンドウごと終了する
            if window.label() == "main" {
                if let WindowEvent::Destroyed = event {
                    window.app_handle().exit(0);
                }
            }
        })
        .invoke_handler(tauri::generate_handler![open_settings])
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}
import { invoke } from '@tauri-apps/api/core';

await invoke('open_settings'); // この場合 main の capability に作成の権限は要らない

動作確認

npm run tauri dev で起動し、#open-settings のボタンを押すと 600 x 400 の「設定」ウィンドウが画面中央に開きます。もう一度押しても 2 つ目は開かず、既存のウィンドウが前に出ます。開いた状態でメイン側から次のコードを実行すると、ラベルの一覧が確かめられます。

import { getAllWebviewWindows } from '@tauri-apps/api/webviewWindow';

console.log((await getAllWebviewWindows()).map((w) => w.label)); // ["main", "settings"]

最後に npm run tauri build で作ったアプリでも、サブウィンドウに設定画面が出ることを確かめます。

よくあるエラーと対処法

  • 「webview.create_webview_window not allowed. Permissions associated with this command: core:webview:allow-create-webview-window」: JS から作る権限がありません。この文面は開発ビルドのもので、リリースビルドでは「Command plugin:webview|create_webview_window not allowed by ACL」になります。
  • サブウィンドウの「閉じる」だけが効かない: DevTools に window.close not allowed on window "settings", webview "settings", URL: local で始まるエラーが出ていれば、settings が capability の windows に入っていません。
  • 本番ビルドだけサブウィンドウにメイン画面が出る: settings.html がビルドされていません。Tauri は見つからないページの代わりに index.html を表示するため、エラーにならずメイン画面が出ます。vite.config.ts の input を確かめます。
  • 「a window with label settings already exists」: 同じラベルで 2 回作ろうとしています。ボタンの連打でも起こるので、getByLabel() で確かめるか、開く処理の間はボタンを無効にします。
  • 「runtime error: Window labels must only include alphanumeric characters, -, /, : and _.」: ラベルに . や空白が入っています。使えるのは英数字と - / : _ だけです。

OS ごとの違いと注意点

  • Windows: Rust の同期コマンドやイベントハンドラーの中でウィンドウを作ると固まることが知られています。コマンドは async にするか、別スレッドで作ります。JS の new WebviewWindow() は内部で非同期のコマンドを使うので、この問題は起きません。
  • 共通: 起動時に作って隠しておき、必要なときに表示する方法もあります。開くたびにページを読み込まないので速く、入力途中の状態も残ります(ウィンドウを表示・非表示にする)。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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