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 で動作 |
2 | JavaScript/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
コールバックを設定します。
メニュー項目は以下の要素で構成されます。
# | プロパティ | 説明 | 必須 |
---|---|---|---|
1 | id | メニュー項目の一意識別子 | ○ |
2 | text | 表示されるテキスト | ○ |
3 | action | クリック時のコールバック関数 | × |
4 | enabled | 有効/無効の状態(デフォルト: true) | × |
5 | checked | チェック状態(チェック項目の場合) | × |
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
の引数は以下の通りです。
# | 引数 | 型 | 説明 |
---|---|---|---|
1 | app | &AppHandle | アプリケーションハンドル |
2 | id | &str | メニュー項目の ID |
3 | text | &str | 表示テキスト |
4 | enabled | bool | 有効/無効 |
5 | accelerator | Option<&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"
serde
と serde_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,
&[
¬ification_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 のシステムトレイ機能は、常駐型デスクトップアプリケーションの開発に必要な機能を十分に提供してくれます。本記事の実装例を参考に、ぜひ魅力的な常駐アプリケーションを作成してみてください。
関連リンク
- article
Tauri でシステムトレイ&メニューバー実装:常駐アプリの基本機能を作る
- article
Tauri アーキテクチャ設計指針:コマンド(Rust)と UI(Web)分離のベストプラクティス
- article
Tauri コマンド&CLI チートシート:init/build/dev/sign/notarize 早見表
- article
Tauri 開発環境の最速構築:Node・Rust・WebView ランタイムの完全セットアップ
- article
Tauri 性能検証レポート:起動時間・メモリ・ディスクサイズを主要 OS で実測
- article
Tauri 完全理解 2025:軽量デスクトップ開発の全知識(仕組み・用途・限界)
- article
NestJS 監視運用:SLI/SLO とダッシュボード設計(Prometheus/Grafana/Loki)
- article
WebRTC AV1/VP9/H.264 ベンチ比較 2025:画質・CPU/GPU 負荷・互換性を実測
- article
MySQL アラート設計としきい値:レイテンシ・エラー率・レプリカ遅延の基準
- article
Vitest フレーク検知技術の運用:`--retry` / シード固定 / ランダム順序で堅牢化
- article
Motion(旧 Framer Motion)デザインレビュー運用:Figma パラメータ同期と差分共有のワークフロー
- article
esbuild プリバンドルを理解する:Vite の optimizeDeps 深掘り
- blog
iPhone 17シリーズの発表!全モデルiPhone 16から進化したポイントを見やすく整理
- blog
Googleストアから訂正案内!Pixel 10ポイント有効期限「1年」表示は誤りだった
- blog
【2025年8月】Googleストア「ストアポイント」は1年表記はミス?2年ルールとの整合性を検証
- blog
Googleストアの注文キャンセルはなぜ起きる?Pixel 10購入前に知るべき注意点
- blog
Pixcel 10シリーズの発表!全モデル Pixcel 9 から進化したポイントを見やすく整理
- blog
フロントエンドエンジニアの成長戦略:コーチングで最速スキルアップする方法
- review
今の自分に満足していますか?『持たざる者の逆襲 まだ何者でもない君へ』溝口勇児
- review
ついに語られた業界の裏側!『フジテレビの正体』堀江貴文が描くテレビ局の本当の姿
- review
愛する勇気を持てば人生が変わる!『幸せになる勇気』岸見一郎・古賀史健のアドラー実践編で真の幸福を手に入れる
- review
週末を変えれば年収も変わる!『世界の一流は「休日」に何をしているのか』越川慎司の一流週末メソッド
- review
新しい自分に会いに行こう!『自分の変え方』村岡大樹の認知科学コーチングで人生リセット
- review
科学革命から AI 時代へ!『サピエンス全史 下巻』ユヴァル・ノア・ハラリが予見する人類の未来