メニューの項目が選ばれたときの処理は、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に届きます(トレイアイコンのメニューを作る)。
