トレイアイコンのメニューを作る

MenuBuilder(Rust)や Menu.new()(JS)で作ったメニューをトレイに付け、押された項目を ID で処理する。チェック項目・サブメニュー・実行中の文言変更と Linux の制約も示す。

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

トレイアイコンを右クリックして出るメニューは、常駐アプリの操作の入り口です。「ウィンドウを表示」「一時停止」「終了」のような項目を並べ、押された項目を ID で見分けて処理します。Rust では MenuBuilder で作って TrayIconBuilder::menu() に、JS では Menu.new() で作って TrayIcon.new() の menu に渡します。アイコン自体の作り方は システムトレイに常駐させる、メニューを開かずに左クリックで動かす処理は クリック時の処理 で扱います。

前提条件

src-tauri/Cargo.toml で tauri の tray-icon 機能を有効にしておきます。プラグインではないので npm run tauri add では入りません(詳しくは menu-009)。

[dependencies]
tauri = { version = "2", features = ["tray-icon"] }

メニューの作成と変更の権限(core:menu:default)とトレイの権限(core:tray:default)は core:default に含まれます。JS のメニューからウィンドウを前に出すなら show / unminimize / set-focus の権限を、アプリを終了するなら Process プラグイン(npm run tauri add process)の process:allow-exit を足します。

{
  "$schema": "../gen/schemas/desktop-schema.json",
  "identifier": "default",
  "description": "Capability for the main window",
  "windows": ["main"],
  "permissions": [
    "core:default",
    "core:app:allow-default-window-icon",
    "core:window:allow-show",
    "core:window:allow-unminimize",
    "core:window:allow-set-focus",
    "process:allow-exit"
  ]
}

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

Menu.new() の items には、{ id, text, action } の形のオブジェクトと、先に作った MenuItem / CheckMenuItem を混ぜて並べられます。区切り線は { item: 'Separator' } です。後から文言やチェックを変える項目だけ MenuItem.new() などで作って変数に持っておき、setText() や setEnabled() を呼びます。チェック付きの項目は押した時点でチェックが自動で切り替わるので、action では新しい状態を読むだけにします。

import { TrayIcon } from '@tauri-apps/api/tray';
import { Menu, MenuItem, CheckMenuItem } from '@tauri-apps/api/menu';
import { defaultWindowIcon } from '@tauri-apps/api/app';
import { getCurrentWindow } from '@tauri-apps/api/window';
import { exit } from '@tauri-apps/plugin-process';

const win = getCurrentWindow();
let paused = false;

// 後から文言を変える項目は変数に持っておく
const pause: MenuItem = await MenuItem.new({
  id: 'tray-pause',
  text: '同期を一時停止',
  action: async () => {
    paused = !paused;
    await pause.setText(paused ? '同期を再開' : '同期を一時停止');
  },
});

// チェックは押した時点で切り替わっている。ここでは読むだけ
const notify: CheckMenuItem = await CheckMenuItem.new({
  id: 'tray-notify',
  text: '通知を受け取る',
  checked: true,
  action: async () => console.log('通知:', await notify.isChecked()),
});

const menu = await Menu.new({
  items: [
    {
      id: 'tray-show',
      text: 'ウィンドウを表示',
      action: async () => {
        await win.unminimize();
        await win.show();
        await win.setFocus();
      },
    },
    pause,
    notify,
    { item: 'Separator' },
    { id: 'tray-quit', text: '終了', action: () => void exit(0) },
  ],
});

// 再読み込みでアイコンが増えないよう、同じ ID があれば消してから作る
if (await TrayIcon.getById('main')) await TrayIcon.removeById('main');
await TrayIcon.new({
  id: 'main',
  icon: (await defaultWindowIcon()) ?? undefined,
  tooltip: 'My App',
  menu,
});

作成済みのアイコンに後からメニューを付けるときは tray.setMenu(menu) を使います。

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

Rust では項目を MenuItem::with_id() などで作り、MenuBuilder で並べます。SubmenuBuilder で入れ子のメニューも作れます。TrayIconBuilder::on_menu_event() に渡した関数が、項目が押されるたびに呼ばれ、event.id() で項目を見分けます。後から書き換える項目は、作ったときのハンドルを app.manage() で State に入れておき、イベントの中で取り出して set_text() などを呼びます。

use std::sync::atomic::{AtomicBool, Ordering};
use tauri::menu::{CheckMenuItem, MenuBuilder, MenuEvent, MenuItem, SubmenuBuilder};
use tauri::tray::TrayIconBuilder;
use tauri::{AppHandle, Manager, Wry};

/// 実行中に書き換える項目と、アプリ側の状態
struct TrayMenuState {
    pause: MenuItem<Wry>,
    notify: CheckMenuItem<Wry>,
    paused: AtomicBool,
}

fn on_tray_menu(app: &AppHandle, event: MenuEvent) {
    let state = app.state::<TrayMenuState>();
    match event.id().as_ref() {
        "tray-show" => {
            if let Some(w) = app.get_webview_window("main") {
                let _ = w.unminimize();
                let _ = w.show();
                let _ = w.set_focus();
            }
        }
        "tray-pause" => {
            let paused = !state.paused.fetch_xor(true, Ordering::SeqCst); // 反転後の値
            let _ = state.pause.set_text(if paused { "同期を再開" } else { "同期を一時停止" });
        }
        "tray-notify" => {
            // チェックは押した時点で切り替わっている。新しい状態を読む
            println!("通知: {}", state.notify.is_checked().unwrap_or(false));
        }
        id if id.starts_with("tray-interval-") => println!("同期間隔: {id}"),
        "tray-quit" => app.exit(0),
        _ => {} // アプリのメニューバーなど、ほかのメニューのイベントもここに届く
    }
}

#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .setup(|app| {
            let show = MenuItem::with_id(app, "tray-show", "ウィンドウを表示", true, None::<&str>)?;
            let pause = MenuItem::with_id(app, "tray-pause", "同期を一時停止", true, None::<&str>)?;
            let notify =
                CheckMenuItem::with_id(app, "tray-notify", "通知を受け取る", true, true, None::<&str>)?;
            let interval = SubmenuBuilder::new(app, "同期の間隔")
                .text("tray-interval-5", "5 分ごと")
                .text("tray-interval-30", "30 分ごと")
                .build()?;
            let quit = MenuItem::with_id(app, "tray-quit", "終了", true, None::<&str>)?;

            let menu = MenuBuilder::new(app)
                .items(&[&show, &pause, &notify, &interval])
                .separator()
                .item(&quit)
                .build()?;

            // イベントの中で取り出す State を登録する
            app.manage(TrayMenuState { pause, notify, paused: AtomicBool::new(false) });

            TrayIconBuilder::with_id("main")
                .icon(tauri::include_image!("icons/32x32.png"))
                .menu(&menu)
                .on_menu_event(on_tray_menu)
                .build(app)?;
            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

on_menu_event() はこのトレイ専用ではなく、アプリ内のすべてのメニューの押下を受け取ります。メニューバー用に Builder::on_menu_event() も登録していると両方が呼ばれるので、トレイの項目は tray- のような接頭辞で ID を分けておくと取り違えません。「終了」は PredefinedMenuItem::quit() でも作れますが Linux では使えないため、自前の項目で app.exit(0) を呼ぶ方が確実です。

動作確認

npm run tauri dev で起動し、トレイのアイコンを右クリックするとメニューが開きます(Windows と macOS では既定で左クリックでも開きます)。「同期を一時停止」を押してからもう一度開くと、項目が「同期を再開」に変わっています。「通知を受け取る」を押すとチェックが外れ、ターミナル(JS 版は DevTools のコンソール)に次のように出ます。

通知: false

「終了」を押すとアプリが終了し、アイコンも消えます。

よくあるエラーと対処法

  • 項目を押しても何も起きない: Rust では on_menu_event() の登録漏れか、match の ID と作成時の ID の食い違いです。JS では、ページを再読み込みすると前のページで渡した action は呼ばれなくなります。メニューとアイコンを作り直します。
  • 1 回押しただけで処理が 2 回動く: on_menu_event() はトレイを作るたびにアプリ全体のハンドラーとして追加されます。トレイを 2 つ作って両方に同じ関数を渡した場合や、Builder::on_menu_event() でも同じ ID を処理している場合に起きます。処理は 1 か所にまとめます。
  • チェックが切り替わらない(元に戻る): 自動で切り替わった後に、自分でも set_checked(!…) で反転しています。イベントの中では is_checked() で読むだけにします。
  • 「state() called before manage() for …」でパニックする: app.state::<T>() の型が State に登録されていません。app.manage() の呼び忘れか、Mutex<TrayMenuState> で登録して TrayMenuState で取り出すような型の食い違いです。
  • 「window.show not allowed. Permissions associated with this command: core:window:allow-show」: JS 版の権限不足です。unminimize / set_focus も同じように個別の権限が要ります。

OS ごとの違いと注意点

  • Windows / macOS: 既定では左クリックでもメニューが開きます。左クリックに別の処理を割り当てるなら、show_menu_on_left_click(false)(JS は showMenuOnLeftClick: false)にします(menu-012)。
  • Linux: 左クリックの設定は効かず、アイコンのクリックイベントも届きません。ウィンドウを出す操作も必ずメニューに入れます。また、一度付けたメニューは外したり別のメニューに差し替えたりできません(setMenu(null) も効きません)。内容を変えるときは、項目の set_text() / set_enabled() や append() / remove() で中身を書き換えます。
  • 共通: 項目名の & は、次の文字をアクセスキーにする指定として扱われます。「保存 & 終了」のように & を表示したいときは && と書きます。
  • 共通: × でトレイに格納するアプリ(menu-013)では、このメニューの「ウィンドウを表示」と「終了」がウィンドウへの戻り道とアプリの終了手段になります。項目の無効化や文言の書き換えは 実行中のメニューの書き換え、チェック項目の扱いは チェックボックス付きの項目 で詳しく説明しています。

関連レシピ

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

Web Ninja

この記事を書いた人

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

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

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

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