T-CREATOR

Tauri でシステムトレイ&メニューバー実装:常駐アプリの基本機能を作る

Tauri でシステムトレイ&メニューバー実装:常駐アプリの基本機能を作る

デスクトップアプリケーションを開発する際、常駐アプリケーションとして動作させたい場面は多いですよね。Slack や Discord のように、システムトレイにアイコンを表示し、そこから素早くアプリケーションにアクセスできる仕組みは、ユーザビリティの向上に欠かせません。

本記事では、Tauri v2 を使ってシステムトレイとメニューバーを実装する方法を、初心者の方にもわかりやすく解説していきます。実際に動くコードを段階的に見ていくことで、常駐アプリの基本機能をマスターできるでしょう。

背景

デスクトップアプリにおける常駐機能の重要性

デスクトップアプリケーションの多くは、ウィンドウを閉じてもバックグラウンドで動作し続けます。これを実現するのがシステムトレイ機能です。

システムトレイは OS のタスクバーやメニューバーに小さなアイコンを表示し、アプリケーションへの素早いアクセスを提供します。特に以下のようなアプリケーションには必須の機能といえるでしょう。

  • チャットアプリケーション(通知を受け取りながら常駐)
  • 音楽プレイヤー(バックグラウンド再生)
  • クリップボード管理ツール
  • タイマーやリマインダー

下記の図は、デスクトップアプリケーションにおけるシステムトレイの位置づけを示したものです。

mermaidflowchart TB
  user["ユーザー"] -->|操作| window["メインウィンドウ"]
  user -->|クリック| tray["システムトレイ<br/>アイコン"]
  tray -->|メニュー表示| menu["トレイメニュー"]
  menu -->|アクション| app["アプリケーション<br/>本体"]
  window -->|最小化/閉じる| tray
  tray -->|ウィンドウ復元| window
  app -->|状態更新| tray

図で理解できる要点

  • システムトレイはユーザーとアプリケーションをつなぐ重要なインターフェース
  • ウィンドウを閉じてもトレイから操作可能
  • アプリケーションの状態をトレイアイコンで視覚的に表現できる

Tauri v2 におけるシステムトレイサポート

Tauri は Rust ベースの軽量なデスクトップアプリケーションフレームワークです。v2 ではシステムトレイ機能が大幅に改善され、より柔軟な実装が可能になりました。

主な特徴は以下の通りです。

#特徴説明
1クロスプラットフォーム対応Windows、macOS、Linux で動作
2JavaScript/Rust 両対応フロントエンドとバックエンドの両方から制御可能
3イベント駆動型クリックやホバーなどのイベントをハンドリング
4動的メニュー更新実行時にメニュー項目を変更可能
5アイコンカスタマイズテーマやステータスに応じてアイコンを切り替え

課題

システムトレイ実装時の主な課題

システムトレイを実装する際には、いくつかの技術的な課題が存在します。

1. プラットフォームごとの挙動の違い

Windows、macOS、Linux ではシステムトレイの仕様が異なります。例えば Linux では一部のイベントがサポートされていないなど、OS 固有の制約があります。

2. ウィンドウとトレイの状態管理

ウィンドウを閉じたときにアプリケーションを終了させるのか、それともトレイに格納するのか。この挙動を適切に制御する必要があります。

3. メニュー項目の動的更新

アプリケーションの状態に応じてメニュー項目を有効化・無効化したり、チェックマークを付けたりする処理は複雑になりがちです。

4. イベントハンドリング

トレイアイコンのクリック、右クリック、ダブルクリックなど、さまざまなイベントを適切に処理する必要があります。

下記の図は、システムトレイ実装における主要な課題とその関係性を表しています。

mermaidflowchart TD
  impl["システムトレイ実装"] --> challenge1["プラットフォーム差異"]
  impl --> challenge2["状態管理"]
  impl --> challenge3["メニュー更新"]
  impl --> challenge4["イベント処理"]

  challenge1 --> sol1["Tauri の抽象化層"]
  challenge2 --> sol2["ウィンドウライフサイクル<br/>制御"]
  challenge3 --> sol3["動的メニューAPI"]
  challenge4 --> sol4["イベントリスナー<br/>パターン"]

  sol1 --> result["クロスプラット<br/>フォーム対応"]
  sol2 --> result
  sol3 --> result
  sol4 --> result

図で理解できる要点

  • 4 つの主要課題がシステムトレイ実装を複雑にしている
  • Tauri が提供する機能により、各課題に対する解決策が用意されている
  • 最終的にクロスプラットフォーム対応のトレイ機能を実現できる

解決策

Tauri v2 によるシステムトレイ実装アプローチ

Tauri v2 では tray-icon 機能を使用することで、上記の課題を解決できます。実装は主に以下の 3 つのステップで行います。

ステップ 1:依存関係の設定

ステップ 2:トレイアイコンとメニューの作成

ステップ 3:イベントハンドリングの実装

それぞれを順番に見ていきましょう。

ステップ 1:依存関係の設定

まず、Tauri のシステムトレイ機能を有効化する必要があります。src-tauri​/​Cargo.toml ファイルに tray-icon フィーチャーを追加します。

Cargo.toml の設定

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

このシンプルな設定により、システムトレイ関連の API が利用可能になります。

フィーチャーフラグを使用することで、必要な機能だけをビルドに含めることができ、最終的なバイナリサイズを最適化できるのです。

ステップ 2:トレイアイコンとメニューの作成

システムトレイの実装方法には、JavaScript(フロントエンド)と Rust(バックエンド)の 2 つのアプローチがあります。それぞれの方法を見ていきましょう。

JavaScript からの実装

フロントエンドから直接トレイを制御する場合、@tauri-apps​/​api パッケージを使用します。

パッケージのインストール
bashyarn add @tauri-apps/api
基本的なトレイアイコンの作成
javascriptimport { TrayIcon } from '@tauri-apps/api/tray';

// シンプルなトレイアイコンの作成
const tray = await TrayIcon.new({
  tooltip: 'My Tauri App',
});

上記のコードでは、最小限の設定でシステムトレイアイコンを作成しています。tooltip プロパティはマウスホバー時に表示されるヒントテキストです。

メニュー付きトレイアイコンの作成
javascriptimport { TrayIcon } from '@tauri-apps/api/tray';
import { Menu } from '@tauri-apps/api/menu';

// メニューの作成
const menu = await Menu.new({
  items: [
    {
      id: 'show',
      text: 'ウィンドウを表示',
      action: async () => {
        console.log('ウィンドウを表示します');
        // ウィンドウ表示処理を実装
      },
    },
    {
      type: 'Separator',
    },
    {
      id: 'quit',
      text: '終了',
      action: async () => {
        console.log('アプリケーションを終了します');
        // 終了処理を実装
      },
    },
  ],
});

// メニュー付きトレイの作成
const tray = await TrayIcon.new({
  tooltip: 'My Tauri App',
  menu: menu,
});

このコードでは、2 つのメニュー項目とセパレーターを含むメニューを作成しています。各メニュー項目には一意の id と、クリック時に実行される action コールバックを設定します。

メニュー項目は以下の要素で構成されます。

#プロパティ説明必須
1idメニュー項目の一意識別子
2text表示されるテキスト
3actionクリック時のコールバック関数×
4enabled有効/無効の状態(デフォルト: true)×
5checkedチェック状態(チェック項目の場合)×

Rust からの実装

バックエンドから実装する場合は、より強力な制御が可能です。アプリケーション起動時にトレイを初期化する方法を見ていきましょう。

src-tauri/src/main.rs の基本実装
rustuse tauri::tray::TrayIconBuilder;

fn main() {
    tauri::Builder::default()
        .setup(|app| {
            // トレイアイコンの作成
            let tray = TrayIconBuilder::new()
                .tooltip("My Tauri App")
                .build(app)?;

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

setup クロージャ内でトレイアイコンをビルドします。この時点ではメニューは設定されていません。

Rust でのメニュー作成とイベントハンドリング
rustuse tauri::menu::{Menu, MenuItem};
use tauri::tray::TrayIconBuilder;

fn main() {
    tauri::Builder::default()
        .setup(|app| {
            // メニュー項目の作成
            let show_item = MenuItem::with_id(app, "show", "ウィンドウを表示", true, None::<&str>)?;
            let quit_item = MenuItem::with_id(app, "quit", "終了", true, None::<&str>)?;

            // メニューの構築
            let menu = Menu::with_items(app, &[
                &show_item,
                &quit_item,
            ])?;

            // トレイアイコンの作成
            let tray = TrayIconBuilder::new()
                .tooltip("My Tauri App")
                .menu(&menu)
                .build(app)?;

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

このコードでは、MenuItem::with_id を使用してメニュー項目を作成し、Menu::with_items でメニューを構築しています。

MenuItem::with_id の引数は以下の通りです。

#引数説明
1app&AppHandleアプリケーションハンドル
2id&strメニュー項目の ID
3text&str表示テキスト
4enabledbool有効/無効
5acceleratorOption<&str>キーボードショートカット(オプション)

ステップ 3:イベントハンドリングの実装

システムトレイの真価は、ユーザーのアクションに応じた動作を実装できる点にあります。

JavaScript でのイベントハンドリング

トレイアイコンのクリックイベントを処理するには、action コールバックを使用します。

javascriptimport { TrayIcon } from '@tauri-apps/api/tray';

const options = {
  tooltip: 'My Tauri App',
  action: (event) => {
    // イベントタイプによって処理を分岐
    switch (event.type) {
      case 'Click':
        console.log(
          `マウスボタン ${event.button} がクリックされました`
        );
        console.log(
          `位置: x=${event.rect.position.x}, y=${event.rect.position.y}`
        );
        break;

      case 'DoubleClick':
        console.log('ダブルクリックされました');
        // ウィンドウを表示する処理など
        break;

      case 'Enter':
        console.log('マウスがトレイアイコンに入りました');
        break;

      case 'Move':
        console.log(
          'マウスがトレイアイコン上で移動しました'
        );
        break;

      case 'Leave':
        console.log('マウスがトレイアイコンから離れました');
        break;
    }
  },
};

const tray = await TrayIcon.new(options);

イベントオブジェクトには、イベントタイプ、マウスボタン情報、座標などが含まれます。これらを活用することで、細かい制御が可能です。

注意点として、Linux ではシステムトレイのイベントサポートが限定的である点を覚えておきましょう。

Rust でのイベントハンドリング

Rust では on_menu_event を使用してメニュー項目のクリックイベントを処理します。

メニューイベントの処理
rustuse tauri::menu::{Menu, MenuItem};
use tauri::tray::TrayIconBuilder;
use tauri::Manager;

fn main() {
    tauri::Builder::default()
        .setup(|app| {
            // メニュー項目の作成
            let show_item = MenuItem::with_id(app, "show", "ウィンドウを表示", true, None::<&str>)?;
            let quit_item = MenuItem::with_id(app, "quit", "終了", true, None::<&str>)?;

            let menu = Menu::with_items(app, &[&show_item, &quit_item])?;

            let tray = TrayIconBuilder::new()
                .tooltip("My Tauri App")
                .menu(&menu)
                .build(app)?;

            Ok(())
        })
        .on_menu_event(|app_handle, event| {
            // メニュー項目の ID によって処理を分岐
            match event.id().0.as_str() {
                "show" => {
                    println!("ウィンドウを表示します");
                    // ウィンドウ表示処理
                    if let Some(window) = app_handle.get_webview_window("main") {
                        let _ = window.show();
                        let _ = window.set_focus();
                    }
                }
                "quit" => {
                    println!("アプリケーションを終了します");
                    app_handle.exit(0);
                }
                _ => {}
            }
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

on_menu_event ハンドラ内では、event.id() を使用してクリックされたメニュー項目を識別し、適切な処理を実行します。

上記の例では、show が選択されたときにメインウィンドウを表示し、quit が選択されたときにアプリケーションを終了しています。

下記の図は、イベントハンドリングのフローを示したものです。

mermaidsequenceDiagram
  participant User as ユーザー
  participant Tray as トレイアイコン
  participant Menu as メニュー
  participant Handler as イベント<br/>ハンドラ
  participant App as アプリケーション

  User->>Tray: 右クリック
  Tray->>Menu: メニュー表示
  Menu->>User: 項目一覧
  User->>Menu: 項目選択
  Menu->>Handler: イベント発火
  Handler->>Handler: ID 判定
  Handler->>App: アクション実行
  App-->>User: 結果反映

図で理解できる要点

  • ユーザーのアクションがイベントとして伝播する流れ
  • ID ベースでメニュー項目を識別する仕組み
  • イベントハンドラがアプリケーションのアクションを制御する役割

具体例

実用的な常駐アプリケーションの実装

ここでは、実際に動作する常駐型のタイマーアプリケーションを例に、システムトレイとメニューバーの実装を詳しく見ていきます。

要件定義

以下の機能を持つタイマーアプリケーションを作成します。

#機能説明
1タイマー開始/停止メニューからタイマーを制御
2ウィンドウ表示/非表示トレイアイコンクリックでウィンドウ切り替え
3ステータス表示トレイアイコンのツールチップでタイマー状態を表示
4アプリケーション終了メニューから安全に終了
5動的メニュー更新タイマー状態に応じてメニュー項目を変更

プロジェクト構成

プロジェクトのディレクトリ構成は以下のようになります。

plaintextmy-timer-app/
├── src/                    # フロントエンドソース
│   ├── main.js            # メインJSファイル
│   └── index.html         # HTMLファイル
├── src-tauri/             # バックエンドソース
│   ├── Cargo.toml         # Rust依存関係
│   ├── src/
│   │   └── main.rs        # メインRustファイル
│   └── icons/             # アイコンファイル
└── package.json           # Node.js依存関係

Rust バックエンドの実装

バックエンドでトレイアイコンとメニューを管理します。タイマーの状態を保持し、メニューを動的に更新する実装を見ていきましょう。

src-tauri/Cargo.toml の設定
toml[package]
name = "my-timer-app"
version = "0.1.0"
edition = "2021"

[dependencies]
tauri = { version = "2.0.0", features = ["tray-icon"] }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

serdeserde_json は、フロントエンドとバックエンド間のデータ通信に使用します。

src-tauri/src/main.rs の実装(初期化部分)
rustuse tauri::menu::{Menu, MenuItem, Submenu, PredefinedMenuItem};
use tauri::tray::{TrayIconBuilder, TrayIconEvent};
use tauri::{Manager, State};
use std::sync::Mutex;

// アプリケーションの状態を管理する構造体
struct AppState {
    timer_running: Mutex<bool>,
    elapsed_seconds: Mutex<u32>,
}

impl AppState {
    fn new() -> Self {
        Self {
            timer_running: Mutex::new(false),
            elapsed_seconds: Mutex::new(0),
        }
    }
}

AppState 構造体でタイマーの実行状態と経過時間を管理します。Mutex を使用することで、複数のスレッドから安全にアクセスできます。

メニューとトレイアイコンの構築
rustfn main() {
    tauri::Builder::default()
        .setup(|app| {
            // アプリケーション状態の初期化
            app.manage(AppState::new());

            // メニュー項目の作成
            let show_window = MenuItem::with_id(
                app,
                "show_window",
                "ウィンドウを表示",
                true,
                None::<&str>
            )?;

            let start_timer = MenuItem::with_id(
                app,
                "start_timer",
                "タイマー開始",
                true,
                None::<&str>
            )?;

            let stop_timer = MenuItem::with_id(
                app,
                "stop_timer",
                "タイマー停止",
                false,  // 初期状態では無効
                None::<&str>
            )?;

            let separator = PredefinedMenuItem::separator(app)?;

            let quit = MenuItem::with_id(
                app,
                "quit",
                "終了",
                true,
                Some("Cmd+Q")  // macOSのキーボードショートカット
            )?;

            // メニューの構築
            let menu = Menu::with_items(
                app,
                &[
                    &show_window,
                    &separator,
                    &start_timer,
                    &stop_timer,
                    &separator,
                    &quit,
                ]
            )?;

            // トレイアイコンの構築
            let tray = TrayIconBuilder::new()
                .tooltip("タイマーアプリ - 停止中")
                .icon(app.default_window_icon().unwrap().clone())
                .menu(&menu)
                .build(app)?;

            Ok(())
        })
        // 次のセクションでイベントハンドリングを実装
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

メニュー項目を個別に作成し、Menu::with_items で組み合わせています。セパレーターを使うことで、視覚的にグループ化されたメニューになります。

初期状態では「タイマー停止」メニューは無効(enabled: false)に設定されています。タイマー開始時に動的に有効化する仕組みを後ほど実装しましょう。

イベントハンドリングの実装
rust        .on_menu_event(|app_handle, event| {
            let state = app_handle.state::<AppState>();

            match event.id().0.as_str() {
                "show_window" => {
                    // メインウィンドウを表示してフォーカス
                    if let Some(window) = app_handle.get_webview_window("main") {
                        let _ = window.show();
                        let _ = window.set_focus();
                        let _ = window.unminimize();
                    }
                }

                "start_timer" => {
                    // タイマーを開始
                    let mut running = state.timer_running.lock().unwrap();
                    *running = true;
                    println!("タイマーを開始しました");

                    // メニューの状態を更新
                    // (実際の実装では、メニューアイテムの参照を保持して更新する必要があります)

                    // フロントエンドにイベントを通知
                    let _ = app_handle.emit("timer-started", ());
                }

                "stop_timer" => {
                    // タイマーを停止
                    let mut running = state.timer_running.lock().unwrap();
                    *running = false;
                    let mut elapsed = state.elapsed_seconds.lock().unwrap();
                    *elapsed = 0;
                    println!("タイマーを停止しました");

                    // フロントエンドにイベントを通知
                    let _ = app_handle.emit("timer-stopped", ());
                }

                "quit" => {
                    // アプリケーションを終了
                    println!("アプリケーションを終了します");
                    app_handle.exit(0);
                }

                _ => {}
            }
        })

各メニュー項目のクリックイベントに対応する処理を実装しています。app_handle.state::<AppState>() でアプリケーションの状態にアクセスし、タイマーの開始/停止を制御します。

また、app_handle.emit() を使用してフロントエンドにイベントを送信することで、UI を更新できるのです。

フロントエンドの実装

次に、JavaScript でフロントエンド側の実装を見ていきましょう。

src/main.js の実装(基本構造)
javascriptimport { listen } from '@tauri-apps/api/event';
import { invoke } from '@tauri-apps/api/core';

// DOM要素の取得
const timerDisplay =
  document.getElementById('timer-display');
const startButton = document.getElementById('start-button');
const stopButton = document.getElementById('stop-button');

let timerInterval = null;
let seconds = 0;

必要な Tauri API をインポートし、DOM 要素への参照を取得します。

イベントリスナーの設定
javascript// Rustからのイベントを受信
await listen('timer-started', () => {
  console.log('タイマーが開始されました');
  startTimer();
  updateUI(true);
});

await listen('timer-stopped', () => {
  console.log('タイマーが停止されました');
  stopTimer();
  updateUI(false);
});

// ボタンのクリックイベント
startButton.addEventListener('click', async () => {
  await invoke('start_timer_command');
});

stopButton.addEventListener('click', async () => {
  await invoke('stop_timer_command');
});

バックエンドから送信されたイベントを listen 関数で受信し、UI を更新します。また、ボタンクリック時には invoke 関数でバックエンドのコマンドを呼び出します。

タイマー処理の実装
javascript// タイマーを開始
function startTimer() {
  if (timerInterval) return;

  timerInterval = setInterval(() => {
    seconds++;
    updateTimerDisplay();
  }, 1000);
}

// タイマーを停止
function stopTimer() {
  if (timerInterval) {
    clearInterval(timerInterval);
    timerInterval = null;
  }
  seconds = 0;
  updateTimerDisplay();
}

// タイマー表示を更新
function updateTimerDisplay() {
  const hours = Math.floor(seconds / 3600);
  const minutes = Math.floor((seconds % 3600) / 60);
  const secs = seconds % 60;

  const display = [hours, minutes, secs]
    .map((val) => String(val).padStart(2, '0'))
    .join(':');

  timerDisplay.textContent = display;
}

// UIの状態を更新
function updateUI(isRunning) {
  startButton.disabled = isRunning;
  stopButton.disabled = !isRunning;
}

シンプルなタイマーロジックを実装しています。1 秒ごとにカウントアップし、HH:MM 形式で表示します。

動的メニュー更新の実装

タイマーの状態に応じてトレイメニューを更新する機能を追加しましょう。

Rust 側のコマンド実装
rustuse tauri::command;

// タイマー開始コマンド
#[command]
fn start_timer_command(app_handle: tauri::AppHandle, state: State<AppState>) {
    let mut running = state.timer_running.lock().unwrap();
    *running = true;

    // イベントを発火
    let _ = app_handle.emit("timer-started", ());
}

// タイマー停止コマンド
#[command]
fn stop_timer_command(app_handle: tauri::AppHandle, state: State<AppState>) {
    let mut running = state.timer_running.lock().unwrap();
    *running = false;

    let mut elapsed = state.elapsed_seconds.lock().unwrap();
    *elapsed = 0;

    // イベントを発火
    let _ = app_handle.emit("timer-stopped", ());
}

#[command] 属性を使用することで、JavaScript から呼び出し可能な関数を定義します。

コマンドの登録
rustfn main() {
    tauri::Builder::default()
        .setup(|app| {
            // ... 前述のセットアップコード
            Ok(())
        })
        .invoke_handler(tauri::generate_handler![
            start_timer_command,
            stop_timer_command
        ])
        .on_menu_event(|app_handle, event| {
            // ... 前述のイベントハンドリングコード
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

invoke_handler にコマンド関数を登録することで、フロントエンドから呼び出せるようになります。

下記の図は、フロントエンドとバックエンド間のデータフローを示しています。

mermaidflowchart LR
  ui["UI<br/>(JavaScript)"] -->|invoke| cmd["Command<br/>Handler"]
  cmd -->|状態更新| state["AppState<br/>(Rust)"]
  state -->|emit| event["Event"]
  event -->|listen| ui

  tray["Tray Menu<br/>(Rust)"] -->|クリック| menu_event["Menu Event<br/>Handler"]
  menu_event -->|状態更新| state
  menu_event -->|emit| event

  state -.->|反映| tray

図で理解できる要点

  • UI とバックエンド間で双方向通信が行われる
  • トレイメニューとフロントエンド UI が同じ状態を共有する
  • イベント駆動型のアーキテクチャにより疎結合を実現

ウィンドウクローズ時の挙動制御

ウィンドウを閉じたときにアプリケーションを終了せず、トレイに格納する実装を追加しましょう。

src-tauri/tauri.conf.json の設定
json{
  "tauri": {
    "windows": [
      {
        "title": "タイマーアプリ",
        "width": 400,
        "height": 300,
        "resizable": true,
        "fullscreen": false,
        "hiddenTitle": false,
        "closeOnEscape": false
      }
    ]
  }
}

ウィンドウの基本設定を行います。

ウィンドウクローズイベントの処理
rustuse tauri::Listener;

fn main() {
    tauri::Builder::default()
        .setup(|app| {
            // ... トレイとメニューのセットアップ

            // ウィンドウクローズイベントのリスナーを設定
            let window = app.get_webview_window("main").unwrap();

            window.on_window_event(move |event| {
                if let tauri::WindowEvent::CloseRequested { api, .. } = event {
                    // ウィンドウを閉じる代わりに非表示にする
                    api.prevent_close();

                    if let Some(win) = app.get_webview_window("main") {
                        let _ = win.hide();
                    }
                }
            });

            Ok(())
        })
        // ... その他の設定
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

on_window_event でウィンドウイベントを監視し、CloseRequested イベント時に api.prevent_close() を呼び出すことで、ウィンドウを閉じる動作をキャンセルします。

その後 win.hide() でウィンドウを非表示にすることで、トレイに格納されたような挙動を実現できるのです。

アイコンの動的変更(応用)

タイマーの実行状態に応じてトレイアイコンを変更することで、視覚的なフィードバックを提供できます。

src-tauri/src/main.rs のアイコン変更処理
rustuse tauri::image::Image;
use std::path::PathBuf;

// トレイアイコンを更新する関数
fn update_tray_icon(app_handle: &tauri::AppHandle, is_running: bool) {
    let icon_path = if is_running {
        "icons/tray-running.png"
    } else {
        "icons/tray-idle.png"
    };

    // アイコンファイルのパスを解決
    let resource_path = app_handle
        .path()
        .resource_dir()
        .unwrap()
        .join(icon_path);

    // アイコンを読み込んで設定
    if let Ok(icon) = Image::from_path(&resource_path) {
        if let Some(tray) = app_handle.tray_by_id("main") {
            let _ = tray.set_icon(Some(icon));
        }
    }
}

この関数は、タイマーの状態に応じて適切なアイコンファイルを読み込み、トレイアイコンを更新します。

メニューイベントハンドラでの使用
rust        .on_menu_event(|app_handle, event| {
            let state = app_handle.state::<AppState>();

            match event.id().0.as_str() {
                "start_timer" => {
                    let mut running = state.timer_running.lock().unwrap();
                    *running = true;

                    // アイコンを更新
                    update_tray_icon(&app_handle, true);

                    let _ = app_handle.emit("timer-started", ());
                }

                "stop_timer" => {
                    let mut running = state.timer_running.lock().unwrap();
                    *running = false;

                    // アイコンを更新
                    update_tray_icon(&app_handle, false);

                    let _ = app_handle.emit("timer-stopped", ());
                }

                // ... その他のイベント処理
            }
        })

タイマーの開始/停止時に update_tray_icon を呼び出すことで、リアルタイムでアイコンが変更されます。

サブメニューの実装

より複雑なメニュー構造が必要な場合、サブメニューを使用できます。

サブメニューの作成例
rustuse tauri::menu::Submenu;

fn main() {
    tauri::Builder::default()
        .setup(|app| {
            // 設定用のサブメニュー項目
            let notification_toggle = MenuItem::with_id(
                app,
                "toggle_notification",
                "通知を有効化",
                true,
                None::<&str>
            )?;

            let auto_start_toggle = MenuItem::with_id(
                app,
                "toggle_auto_start",
                "自動起動",
                true,
                None::<&str>
            )?;

            // サブメニューの作成
            let settings_submenu = Submenu::with_items(
                app,
                "settings_submenu",
                "設定",
                true,
                &[
                    &notification_toggle,
                    &auto_start_toggle,
                ]
            )?;

            // メインメニュー項目
            let show_window = MenuItem::with_id(
                app,
                "show_window",
                "ウィンドウを表示",
                true,
                None::<&str>
            )?;

            let quit = MenuItem::with_id(
                app,
                "quit",
                "終了",
                true,
                None::<&str>
            )?;

            // メインメニューの構築(サブメニューを含む)
            let menu = Menu::with_items(
                app,
                &[
                    &show_window,
                    &settings_submenu,  // サブメニューを追加
                    &quit,
                ]
            )?;

            // トレイアイコンの構築
            let tray = TrayIconBuilder::new()
                .tooltip("タイマーアプリ")
                .menu(&menu)
                .build(app)?;

            Ok(())
        })
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

Submenu::with_items を使用してサブメニューを作成し、メインメニューに組み込んでいます。これにより、階層的なメニュー構造を実現できます。

チェック項目の実装

メニュー項目にチェックマークを表示し、オン/オフを切り替える機能を実装しましょう。

src-tauri/src/main.rs のチェック項目実装
rustuse tauri::menu::CheckMenuItem;

// アプリケーション状態にチェック項目の状態を追加
struct AppState {
    timer_running: Mutex<bool>,
    elapsed_seconds: Mutex<u32>,
    notification_enabled: Mutex<bool>,  // 新規追加
}

impl AppState {
    fn new() -> Self {
        Self {
            timer_running: Mutex::new(false),
            elapsed_seconds: Mutex::new(0),
            notification_enabled: Mutex::new(true),
        }
    }
}

チェック項目の状態を管理するために、AppState に新しいフィールドを追加します。

チェックメニュー項目の作成
rust        .setup(|app| {
            app.manage(AppState::new());

            // チェックメニュー項目の作成
            let notification_check = CheckMenuItem::with_id(
                app,
                "notification_check",
                "通知を有効化",
                true,  // 初期状態:チェック済み
                true,  // enabled
                None::<&str>
            )?;

            // ... その他のメニュー項目とトレイの設定

            Ok(())
        })

CheckMenuItem::with_id を使用してチェック可能なメニュー項目を作成します。

チェック項目のイベントハンドリング
rust        .on_menu_event(|app_handle, event| {
            let state = app_handle.state::<AppState>();

            match event.id().0.as_str() {
                "notification_check" => {
                    // 現在の状態を取得して反転
                    let mut enabled = state.notification_enabled.lock().unwrap();
                    *enabled = !*enabled;

                    println!("通知が{}になりました", if *enabled { "有効" } else { "無効" });

                    // メニュー項目のチェック状態を更新
                    // (実装にはメニュー項目への参照保持が必要)
                }

                // ... その他のイベント処理
            }
        })

チェック項目がクリックされるたびに状態を反転させ、アプリケーションの動作を変更します。

まとめ

本記事では、Tauri v2 を使用してシステムトレイとメニューバーを実装する方法を解説しました。

実装のポイントを振り返ってみましょう。

主要な学習内容

#項目内容
1基本設定tray-icon フィーチャーの有効化
2トレイアイコン作成JavaScript と Rust の両方からの作成方法
3メニュー構築通常項目、セパレーター、サブメニュー、チェック項目
4イベントハンドリングクリック、メニュー選択などのイベント処理
5状態管理アプリケーション状態の保持と更新
6動的更新メニュー項目やアイコンのリアルタイム変更
7ウィンドウ制御クローズ時の挙動制御とトレイへの格納

システムトレイ実装のベストプラクティス

実装する際には以下の点に注意しましょう。

直感的なメニュー設計:ユーザーが迷わないよう、メニュー項目を論理的にグループ化し、適切にセパレーターで区切ります。

状態の視覚化:アイコンやツールチップでアプリケーションの現在の状態を明確に示すことで、ユーザビリティが向上します。

プラットフォームへの配慮:Linux ではイベントサポートが限定的である点など、OS ごとの制約を理解して実装しましょう。

適切なエラーハンドリング:メニュー項目の作成やアイコンの読み込みに失敗した場合の処理を適切に行います。

リソース管理:アプリケーション終了時には、トレイアイコンやイベントリスナーを適切にクリーンアップしましょう。

今後の発展

本記事で学んだ基礎をもとに、以下のような機能拡張が可能です。

  • グローバルホットキー:キーボードショートカットでアプリケーションを素早く呼び出す
  • 通知機能:システム通知と連携してユーザーに情報を伝える
  • 複数トレイアイコン:異なる機能ごとに複数のトレイアイコンを表示
  • テーマ対応:ライトモード/ダークモードに応じてアイコンを切り替え

Tauri のシステムトレイ機能は、常駐型デスクトップアプリケーションの開発に必要な機能を十分に提供してくれます。本記事の実装例を参考に、ぜひ魅力的な常駐アプリケーションを作成してみてください。

関連リンク