メニューがクリックされた時の処理を書く

JS の action か Rust の on_menu_event でメニューが選ばれた時の処理を書く。id での振り分け、Rust からページへの通知、対象ウィンドウの決め方、固まらない書き方も示す。

メニュー・トレイ 対象: Tauri 2.x 更新日: 読了目安: 約9分 menu-005
目次
  1. 前提条件
  2. 1. フロントエンドから実装する (TypeScript)
  3. action で受け取る
  4. Rust から届いた通知を受け取る
  5. 2. バックエンドから実装する (Rust)
  6. 動作確認
  7. よくあるエラーと対処法
  8. OS ごとの違いと注意点
  9. 関連レシピ

メニューの項目が選ばれたときの処理は、JS で作った項目なら action、Rust なら on_menu_event に書きます。どちらにも届くのは「どの id の項目が選ばれたか」だけなので、id で処理を振り分けるのが基本です。ショートカットキーで選ばれたときも同じ経路で届きます。メニューの作り方は アプリケーションのメニューバーを作る、選ばれた項目を無効化したりチェックを切り替えたりする方法は menu-006 と menu-003 で扱います。

前提条件

プラグインは不要です。JS の action を受け取るのに追加の権限は要りません(メニューを作る core:menu:default があれば届きます)。Rust から送った通知を JS の listen() で受ける権限は core:event:default に含まれ、どちらも core:default に含まれます。Rust の on_menu_event は権限と関係ありません。

処理を書く場所は、処理の中身で決めます。

処理の中身書く場所
ページの状態を使う(エディターの保存など)JS の action、または Rust から通知して JS で処理
終了・ウィンドウ操作など Rust で完結するRust の on_menu_event
Rust で作ったメニューon_menu_event(JS の action は付けられない)

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

action で受け取る

action の引数には選ばれた項目の id が入ります。全項目に同じ関数を渡し、id から処理を引く表にしておくと、ボタンやショートカットからも同じ処理を呼べます。action の戻り値は使われないので、async の処理が失敗しても誰も受け取りません。中で catch しておきます。

import { Menu } from '@tauri-apps/api/menu';

// id → 処理 の表
const commands: Record<string, () => void | Promise<void>> = {
  'file:new': () => {
    const editor = document.querySelector('textarea');
    if (editor) editor.value = '';
  },
  'file:save': async () => {
    // 保存処理(例: invoke('save_file', { ... }))
  },
};

function runCommand(id: string) {
  const command = commands[id];
  if (!command) {
    console.warn(`未対応のメニュー: ${id}`);
    return;
  }
  Promise.resolve()
    .then(command)
    .catch((e) => console.error(`メニュー ${id} の処理に失敗`, e));
}

const menu = await Menu.new({
  items: [
    {
      text: 'ファイル(&F)',
      items: [
        { id: 'file:new', text: '新規作成(&N)', action: runCommand },
        { id: 'file:save', text: '保存(&S)', action: runCommand },
      ],
    },
  ],
});
await menu.setAsAppMenu();

action は、その項目を作ったページの中で呼ばれます。Windows と Linux ではアプリのメニューが全ウィンドウに付きますが、どのウィンドウのメニューバーから選んでも、動くのはメニューを作ったページの関数です。そのウィンドウを閉じたり、作り直さずに再読み込みしたりすると呼ばれなくなります。また、同じ id の項目を 2 つ作ると、どちらを選んでも後から作った方の action が呼ばれます。

Rust から届いた通知を受け取る

Rust で作ったメニューには action を付けられないので、2 章のように on_menu_event からページへイベントを送って受け取ります。特定のウィンドウ宛てに送られたものは、各ページで getCurrentWebviewWindow().listen() を使って登録すると、送り先のウィンドウだけで動きます。@tauri-apps/api/event の listen() は宛先に関係なく受け取るので、こちらで登録すると全ウィンドウで動いてしまいます。

import { listen } from '@tauri-apps/api/event';
import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';

const handlers: Record<string, () => void> = {
  'file:new': () => console.log('新規作成'),
  'file:save': () => console.log('保存'),
};

// emit_to() で自分のウィンドウ宛てに送られた id だけを受け取る
await getCurrentWebviewWindow().listen<string>('menu', (event) => {
  handlers[event.payload]?.();
});
// emit() で全体に送られたものは、どのウィンドウでも受け取れる
await listen<string>('update-checked', (event) => {
  console.log(event.payload);
});

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

Builder::on_menu_event に登録した関数は、アプリのメニュー、ウィンドウごとのメニュー、トレイのメニューのすべての項目で呼ばれます。JS で作って action を付けた項目も例外ではなく、同じ id を両方で処理すると 2 回動きます。WebviewWindowBuilder::on_menu_event のようにウィンドウに登録する版も同じで、自分のウィンドウ以外のメニューのイベントも届きます(登録したウィンドウを閉じると呼ばれなくなります)。アプリ全体で重ならない id を付けておくことが前提です。

この関数はメインスレッドで呼ばれ、終わるまでウィンドウもメニューも反応しません。時間のかかる処理は別スレッドに渡します。また、イベントにはどのウィンドウのメニューから選ばれたかが含まれないので、ページに任せる処理はフォーカスのあるウィンドウに送ります。

use tauri::menu::{MenuBuilder, MenuEvent, SubmenuBuilder};
use tauri::{AppHandle, Emitter, Manager, WebviewWindow};

/// フォーカスのあるウィンドウを操作対象にする(無ければ main)
fn target_window(app: &AppHandle) -> Option<WebviewWindow> {
    let windows = app.webview_windows();
    windows
        .values()
        .find(|w| w.is_focused().unwrap_or(false))
        .or_else(|| windows.get("main"))
        .cloned()
}

fn handle_menu(app: &AppHandle, event: MenuEvent) {
    match event.id().as_ref() {
        // Rust で完結する処理
        "app:quit" => app.exit(0),
        "view:fullscreen" => {
            if let Some(w) = target_window(app) {
                let on = w.is_fullscreen().unwrap_or(false);
                let _ = w.set_fullscreen(!on);
            }
        }
        // 時間のかかる処理は別スレッドへ(ここはメインスレッド)
        "help:check" => {
            let app = app.clone();
            tauri::async_runtime::spawn_blocking(move || {
                std::thread::sleep(std::time::Duration::from_secs(2)); // 実際の確認処理の代わり
                let _ = app.emit("update-checked", "最新版です");
            });
        }
        // 残りはページの状態が要るので、フォーカスのあるウィンドウに任せる
        id => match target_window(app) {
            Some(w) => {
                let _ = app.emit_to(w.label(), "menu", id);
            }
            None => println!("[menu] {id}: 送り先のウィンドウが無い"),
        },
    }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .menu(|app| {
            let file = SubmenuBuilder::new(app, "ファイル(&F)")
                .text("file:new", "新規作成(&N)")
                .text("file:save", "保存(&S)")
                .separator()
                .text("app:quit", "終了(&X)")
                .build()?;
            let view = SubmenuBuilder::new(app, "表示(&V)")
                .text("view:fullscreen", "全画面表示")
                .build()?;
            let help = SubmenuBuilder::new(app, "ヘルプ(&H)")
                .text("help:check", "更新を確認(&U)")
                .build()?;
            MenuBuilder::new(app).items(&[&file, &view, &help]).build()
        })
        .on_menu_event(handle_menu)
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

emit_to() の第 1 引数は送り先のウィンドウのラベルです。全ウィンドウに知らせたいなら emit() を使います。

動作確認

npm run tauri dev で起動し、「表示 → 全画面表示」を選ぶと、フォーカスのあるウィンドウが全画面になり、もう一度選ぶと戻ります。「ヘルプ → 更新を確認」ではメニューがすぐ閉じて操作を続けられ、2 秒後にコンソールへ「最新版です」と出ます。spawn_blocking を外して直接 sleep すると、その 2 秒間はウィンドウを動かすこともできなくなり、メインスレッドで重い処理をしてはいけない理由が確かめられます。「ファイル → 新規作成」では、フォーカスのあるウィンドウのコンソールに「新規作成」と出ます。

よくあるエラーと対処法

  • 選んでも何も起きない: Rust で作った項目なら on_menu_event の登録漏れか、id の綴り違いで最後の分岐に落ちています。届いた id を println!("{:?}", event.id()) で出して確かめます。JS で作った項目なら action の付け忘れです。
  • トレイや別のメニューの項目で、意図しない処理が動く: id が重なっています。どのハンドラーにも全メニューのイベントが届くので、file:・tray: のような接頭辞で分けます。
  • ウィンドウを閉じたら JS の処理が動かなくなった: action はメニューを作ったページで動きます。メニューはメインウィンドウのページで作り、ページの起動処理で毎回作り直します。
  • emit_to() で 1 つのウィンドウに送ったのに、全ウィンドウで動く: 受け取る側を @tauri-apps/api/event の listen() で登録しています。getCurrentWebviewWindow().listen() に替えます。
  • 選んだ後にアプリが固まる: on_menu_event の中で重い処理や待ち合わせをしています。tauri::async_runtime::spawn_blocking(async の処理なら spawn)で別に実行します。
  • 「終了」で未保存の確認が出ない: app.exit(0) はウィンドウを閉じる要求(onCloseRequested)を通りません。確認が必要なら、ページに知らせて確認してから終了します(ウィンドウを閉じる前に確認ダイアログを出す)。

OS ごとの違いと注意点

  • Windows / Linux: アプリのメニューは各ウィンドウに付きますが、どのウィンドウのメニューバーから選んでも同じ id で届き、区別は付きません。
  • macOS: メニューはアプリで 1 つです。ウィンドウをすべて隠している常駐アプリなどでは、フォーカスのあるウィンドウが無いまま選ばれることがあるので、target_window() が None の場合も扱います。
  • Linux: 標準の「終了」「ウィンドウを閉じる」項目は非対応なので、終了は自前の項目と app.exit(0) で作ります(標準項目は menu-007)。
  • iOS / Android: メニューの API はありません。
  • トレイのメニューも同じ on_menu_event に届きます(トレイアイコンのメニューを作る)。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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