Tauriの基礎 - メニューとシステムトレイ

提供: MochiuWiki : SUSE, EC, PCB

概要

Tauriのメニューとシステムトレイ機能は、デスクトップアプリケーションに不可欠なユーザーインターフェース要素を提供する。
アプリケーションメニューは、MacOSのメニューバー、Windows/Linuxのウインドウメニューとして表示され、ファイル操作、編集機能、設定へのアクセス等を提供する。

システムトレイ (またはタスクトレイ) は、アプリケーションをバックグラウンドで実行しながら、ステータス表示やクイックアクションへのアクセスを可能にする。

Tauri v2では、メニューとシステムトレイのAPIが大幅に改善され、RustとJavaScriptの両方から一貫した方法で操作できる。
メニュー項目は階層構造をサポートし、サブメニューやセパレーター、チェックボックス項目等、ネイティブアプリケーションと同等の機能を実現できる。

コンテキストメニュー (右クリックメニュー) もサポートされており、ユーザーが特定の要素を右クリックした時に適切なアクションを提供できる。


アプリケーションメニューの基本概念

メニューの構成要素

Tauriのメニューは、以下に示す要素で構成される。

Tauriのメニューの構成要素
要素 説明
Menu メニュー全体を表すコンテナ
複数のMenuItemを含む。
MenuItem 個別のメニュー項目
クリック時のアクションを定義
Submenu サブメニュー
別のMenuをネスト
PredefinedMenuItem 定義済みの標準メニュー項目
Copy、Paste、Quit等
CheckMenuItem チェックボックス付きメニュー項目
オン / オフ状態を管理する。
IconMenuItem アイコン付きメニュー項目
MenuItemKind 全てのメニュー項目タイプを統一的に扱う列挙型


プラットフォームごとの違い

プラットフォーム別メニュー表示
プラットフォーム 表示場所 特徴
MacOS 画面上部のメニューバー アプリケーション全体で共有
Windows ウインドウ内のタイトルバー下 ウインドウごとに独立
Linux ウインドウ内 デスクトップ環境による



メニューの定義

Rustでのメニュー定義

 // src-tauri/src/lib.rs
 use tauri::{
    menu::{Menu, MenuBuilder, MenuItem, MenuItemBuilder, Submenu, SubmenuBuilder},
    Builder, Manager,
 };
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    Builder::default()
       .setup(|app| {
          // ファイルメニューの項目を作成
          let new_item = MenuItemBuilder::with_id("new", "New").build(app)?;
          let open_item = MenuItemBuilder::with_id("open", "Open...").build(app)?;
          let save_item = MenuItemBuilder::with_id("save", "Save").build(app)?;
          let quit_item = MenuItemBuilder::with_id("quit", "Quit").build(app)?;
 
          // ファイルメニューを作成
          let file_menu = SubmenuBuilder::new(app, "File")
             .item(&new_item)
             .item(&open_item)
             .item(&save_item)
             .separator()
             .item(&quit_item)
             .build()?;
 
          // 編集メニューの項目
          let copy_item = MenuItemBuilder::with_id("copy", "Copy").build(app)?;
          let paste_item = MenuItemBuilder::with_id("paste", "Paste").build(app)?;
          let cut_item = MenuItemBuilder::with_id("cut", "Cut").build(app)?;
 
          let edit_menu = SubmenuBuilder::new(app, "Edit")
             .item(&copy_item)
             .item(&paste_item)
             .item(&cut_item)
             .build()?;
 
          // メインメニューを作成
          let menu = MenuBuilder::new(app)
             .item(&file_menu)
             .item(&edit_menu)
             .build()?;
 
          // メニューバーに設定
          app.set_menu(&menu)?;
 
          Ok(())
       })
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }


JavaScript / TypeScript でのメニュー定義

 // src/utils/menu.ts
 import { Menu, Submenu, MenuItem } from '@tauri-apps/api/menu';
 
 export async function setupMenu() {
   // ファイルメニューの項目を作成
   const fileMenu = await Submenu.new({
     text: 'File',
     items: [
       { id: 'new', text: 'New' },
       { id: 'open', text: 'Open...' },
       { id: 'save', text: 'Save' },
       { type: 'separator' },
       { id: 'quit', text: 'Quit' },
     ],
   });
 
   // 編集メニューを作成
   const editMenu = await Submenu.new({
     text: 'Edit',
     items: [
       { id: 'copy', text: 'Copy' },
       { id: 'paste', text: 'Paste' },
       { id: 'cut', text: 'Cut' },
     ],
   });
 
   // ヘルプメニューを作成
   const helpMenu = await Submenu.new({
     text: 'Help',
     items: [
       { id: 'about', text: 'About' },
       { id: 'docs', text: 'Documentation' },
     ],
   });
 
   // メインメニューを作成
   const menu = await Menu.new({
     items: [fileMenu, editMenu, helpMenu],
   });
 
   // メニューバーに設定
   await menu.setAsAppMenu();
 
   return menu;
 }



カスタムメニュー項目

様々なメニュー項目タイプ

 use tauri::{
    menu::{
       Menu, MenuBuilder, MenuItem, MenuItemBuilder, 
       CheckMenuItem, CheckMenuItemBuilder,
       PredefinedMenuItem,
       IconMenuItem, IconMenuItemBuilder,
    },
    Builder, Manager,
    image::Image,
 };
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    Builder::default()
       .setup(|app| {
          // 通常のメニュー項目
          let normal_item = MenuItemBuilder::with_id("normal", "Normal Item")
             .enabled(true)
             .build(app)?;
 
          // チェックボックス付きメニュー項目
          let check_item = CheckMenuItemBuilder::with_id("check", "Enable Feature")
             .checked(true)
             .build(app)?;
 
          // アイコン付きメニュー項目
          // let icon_item = IconMenuItemBuilder::with_id("icon", "Icon Item")
          //    .icon(Image::from_bytes(include_bytes!("icon.png"))?)
          //    .build(app)?;
 
          // 定義済みメニュー項目
          let copy_item = PredefinedMenuItem::copy(app, None)?;
          let paste_item = PredefinedMenuItem::paste(app, None)?;
          let separator = PredefinedMenuItem::separator(app)?;
 
          let menu = MenuBuilder::new(app)
             .item(&normal_item)
             .item(&check_item)
             // .item(&icon_item)
             .items(&[&separator, &copy_item, &paste_item])
             .build()?;
 
          app.set_menu(&menu)?;
 
          Ok(())
       })
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }


アクセラレーター (ショートカットキー)

 use tauri::menu::{MenuItemBuilder, MenuBuilder};
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    Builder::default()
       .setup(|app| {
          // ショートカットキー付きのメニュー項目
          let new_item = MenuItemBuilder::with_id("new", "New")
             .accelerator("CmdOrCtrl+N")  // MacOS: Cmd+N, Windows/Linux: Ctrl+N
             .build(app)?;
 
          let open_item = MenuItemBuilder::with_id("open", "Open...")
             .accelerator("CmdOrCtrl+O")
             .build(app)?;
 
          let save_item = MenuItemBuilder::with_id("save", "Save")
             .accelerator("CmdOrCtrl+S")
             .build(app)?;
 
          let quit_item = MenuItemBuilder::with_id("quit", "Quit")
             .accelerator("CmdOrCtrl+Q")
             .build(app)?;
 
          let menu = MenuBuilder::new(app)
             .items(&[&new_item, &open_item, &save_item, &quit_item])
             .build()?;
 
          app.set_menu(&menu)?;
 
          Ok(())
       })
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }


動的メニューの更新

 use tauri::{AppHandle, Manager};
 use tauri::menu::{Menu, MenuItem, CheckMenuItem};
 
 #[tauri::command]
 async fn toggle_menu_item(app: AppHandle, item_id: String) -> Result<(), String> {
    // メニュー項目を取得して状態を変更
    if let Some(menu) = app.menu() {
       if let Some(item) = menu.get(&item_id) {
          // チェックボックス項目の場合
          if let Some(check_item) = item.as_check_menuitem() {
             let current = check_item.is_checked().map_err(|e| e.to_string())?;
             check_item.set_checked(!current).map_err(|e| e.to_string())?;
          }
          // 通常項目の有効/無効
          else if let Some(menu_item) = item.as_menu_item() {
             let current = menu_item.is_enabled().map_err(|e| e.to_string())?;
             menu_item.set_enabled(!current).map_err(|e| e.to_string())?;
          }
       }
    }
    Ok(())
 }
 
 #[tauri::command]
 async fn update_menu_text(app: AppHandle, item_id: String, new_text: String) -> Result<(), String> {
    if let Some(menu) = app.menu() {
       if let Some(item) = menu.get(&item_id) {
          if let Some(menu_item) = item.as_menu_item() {
             menu_item.set_text(&new_text).map_err(|e| e.to_string())?;
          }
       }
    }
    Ok(())
 }



コンテキストメニュー

基本的なコンテキストメニュー

 use tauri::{
    menu::{Menu, MenuBuilder, MenuItemBuilder},
    webview::WindowBuilder,
    Builder, Manager, WebviewUrl,
 };
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    Builder::default()
       .setup(|app| {
          // コンテキストメニューを作成
          let context_menu = MenuBuilder::new(app)
             .item(&MenuItemBuilder::with_id("ctx-copy", "Copy").build(app)?)
             .item(&MenuItemBuilder::with_id("ctx-paste", "Paste").build(app)?)
             .item(&MenuItemBuilder::with_id("ctx-cut", "Cut").build(app)?)
             .build()?;
 
          // ウインドウにコンテキストメニューを設定
          if let Some(window) = app.get_webview_window("main") {
             window.set_menu(&context_menu)?;
          }
 
          Ok(())
       })
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }


TypeScriptでのコンテキストメニュー

 // src/hooks/useContextMenu.ts
 import { Menu } from '@tauri-apps/api/menu';
 import { useCallback, useState } from 'react';
 
 interface ContextMenuOptions {
   items: Array<{
     id: string;
     text: string;
     enabled?: boolean;
   }>;
 }
 
 export function useContextMenu() {
   const [menu, setMenu] = useState<Menu | null>(null);
 
   const createMenu = useCallback(async (options: ContextMenuOptions) => {
     const newMenu = await Menu.new({
       items: options.items.map(item => ({
         id: item.id,
         text: item.text,
         enabled: item.enabled ?? true,
       })),
     });
     setMenu(newMenu);
     return newMenu;
   }, []);
 
   const show = useCallback(async (x: number, y: number) => {
     if (menu) {
       await menu.popup({ x, y });
     }
   }, [menu]);
 
   return { createMenu, show, menu };
 }
 
 // 使用例
 function MyComponent() {
   const { createMenu, show } = useContextMenu();
 
   const handleContextMenu = async (e: React.MouseEvent) => {
     e.preventDefault();
 
     const menu = await createMenu({
       items: [
         { id: 'edit', text: 'Edit' },
         { id: 'delete', text: 'Delete' },
         { id: 'copy', text: 'Copy' },
       ],
     });
 
     await show(e.clientX, e.clientY);
   };
 
   return (
     <div onContextMenu={handleContextMenu}>
       Right-click me!
     </div>
   );
 }



システムトレイアイコンとメニュー

基本的なシステムトレイ設定

 use tauri::{
    menu::{Menu, MenuBuilder, MenuItemBuilder},
    tray::{TrayIcon, TrayIconBuilder},
    Builder, Manager,
 };
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    Builder::default()
       .setup(|app| {
          // トレイメニューを作成
          let tray_menu = MenuBuilder::new(app)
             .item(&MenuItemBuilder::with_id("show", "Show Window").build(app)?)
             .item(&MenuItemBuilder::with_id("hide", "Hide Window").build(app)?)
             .separator()
             .item(&MenuItemBuilder::with_id("settings", "Settings...").build(app)?)
             .item(&MenuItemBuilder::with_id("quit", "Quit").build(app)?)
             .build()?;
 
          // システムトレイを作成
          let _tray = TrayIconBuilder::new()
             .icon(app.default_window_icon().unwrap().clone())
             .menu(&tray_menu)
             .menu_on_left_click(true)
             .on_menu_event(|app, event| {
                match event.id.as_ref() {
                   "show" => {
                      if let Some(window) = app.get_webview_window("main") {
                         let _ = window.show();
                         let _ = window.set_focus();
                      }
                   }
                   "hide" => {
                      if let Some(window) = app.get_webview_window("main") {
                         let _ = window.hide();
                      }
                   }
                   "settings" => {
                      // 設定ウインドウを開く
                   }
                   "quit" => {
                      app.exit(0);
                   }
                   _ => {}
                }
             })
             .build(app)?;
 
          Ok(())
       })
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }


TypeScriptでのシステムトレイ

 // src/utils/tray.ts
 import { TrayIcon } from '@tauri-apps/api/tray';
 import { Menu } from '@tauri-apps/api/menu';
 
 export async function setupTray() {
   // トレイメニューを作成
   const menu = await Menu.new({
     items: [
       { id: 'show', text: 'Show Window' },
       { id: 'hide', text: 'Hide Window' },
       { type: 'separator' },
       { id: 'settings', text: 'Settings...' },
       { id: 'quit', text: 'Quit' },
     ],
   });
 
   // システムトレイを作成
   const tray = await TrayIcon.new({
     menu,
     menuOnLeftClick: true,
     action: (event) => {
       console.log('Tray event:', event);
     },
   });
 
   return tray;
 }
 
 // メニュー項目のクリックイベントを処理
 export async function handleTrayMenuClick(
   itemId: string
 ): Promise<void> {
   switch (itemId) {
     case 'show':
       // ウインドウを表示
       break;
     case 'hide':
       // ウインドウを非表示
       break;
     case 'quit':
       // アプリを終了
       break;
   }
 }


トレイアイコンの動的変更

 use tauri::{
    tray::TrayIconBuilder,
    image::Image,
    Manager,
 };
 
 #[tauri::command]
 async fn set_tray_icon(app: tauri::AppHandle, status: String) -> Result<(), String> {
    let tray = app.tray_by_id("main")
       .ok_or("Tray not found")?;
     
    // ステータスに応じてアイコンを変更
    let icon_data = match status.as_str() {
       "active" => include_bytes!("../icons/active.png"),
       "inactive" => include_bytes!("../icons/inactive.png"),
       "error" => include_bytes!("../icons/error.png"),
       _ => include_bytes!("../icons/default.png"),
    };
 
    let icon = Image::from_bytes(icon_data)
       .map_err(|e| e.to_string())?;
 
    tray.set_icon(Some(icon))
       .map_err(|e| e.to_string())?;
     
    Ok(())
 }
 
 #[tauri::command]
 async fn set_tray_tooltip(app: tauri::AppHandle, tooltip: String) -> Result<(), String> {
    let tray = app.tray_by_id("main")
       .ok_or("Tray not found")?;
 
    tray.set_tooltip(Some(&tooltip))
       .map_err(|e| e.to_string())?;
 
     Ok(())
 }



メニューイベントハンドラ

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

 use tauri::{
    menu::{Menu, MenuBuilder, MenuItemBuilder},
    Builder, Manager,
 };
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    Builder::default()
       .setup(|app| {
          let menu = MenuBuilder::new(app)
             .item(&MenuItemBuilder::with_id("new", "New").build(app)?)
             .item(&MenuItemBuilder::with_id("open", "Open").build(app)?)
             .item(&MenuItemBuilder::with_id("save", "Save").build(app)?)
             .build()?;
 
          app.set_menu(&menu)?;
 
          Ok(())
       })
       .on_menu_event(|app, event| {
          // メニューイベントを処理
          match event.id.as_ref() {
             "new" => {
                println!("New menu item clicked");
                // 新規ファイル作成処理
             }
             "open" => {
                println!("Open menu item clicked");
                // ファイルを開く処理
             }
             "save" => {
                println!("Save menu item clicked");
                // 保存処理
             }
             _ => {
                println!("Unknown menu item: {}", event.id);
             }
          }
       })
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }


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

 // src/hooks/useMenuEvents.ts
 import { listen } from '@tauri-apps/api/event';
 import { useEffect } from 'react';
 
 interface MenuEvent {
   id: string;
 }
 
 export function useMenuEvents(
   handlers: Record<string, () => void>
 ) {
   useEffect(() => {
     let unlisten: (() => void) | undefined;
 
     const setup = async () => {
       unlisten = await listen<MenuEvent>('tauri://menu', (event) => {
         const handler = handlers[event.payload.id];
         if (handler) {
           handler();
         }
       });
     };
 
     setup();
 
     return () => {
       if (unlisten) {
         unlisten();
       }
     };
   }, [handlers]);
 }
 
 // 使用例
 function App() {
   useMenuEvents({
     'new': () => console.log('New file'),
     'open': () => console.log('Open file'),
     'save': () => console.log('Save file'),
     'quit': () => window.close(),
   });
 
   return <div>App</div>;
 }


トレイイベントのハンドリング

 use tauri::{
    tray::{TrayIconBuilder, TrayIconEvent, MouseButton, MouseButtonState},
    menu::{MenuBuilder, MenuItemBuilder},
    Builder, Manager,
 };
 
 #[cfg_attr(mobile, tauri::mobile_entry_point)]
 pub fn run() {
    Builder::default()
       .setup(|app| {
          let tray_menu = MenuBuilder::new(app)
             .item(&MenuItemBuilder::with_id("quit", "Quit").build(app)?)
             .build()?;
 
          let _tray = TrayIconBuilder::new()
             .icon(app.default_window_icon().unwrap().clone())
             .menu(&tray_menu)
             // メニュークリックイベント
             .on_menu_event(|app, event| {
                if event.id.as_ref() == "quit" {
                   app.exit(0);
                }
             })
             // トレイアイコンクリックイベント
             .on_tray_icon_event(|tray, event| {
                match event {
                   TrayIconEvent::Click {
                      button: MouseButton::Left,
                      button_state: MouseButtonState::Up,
                      ..
                   } => {
                      // 左クリックでウインドウを表示
                      let app = tray.app_handle();
                      if let Some(window) = app.get_webview_window("main") {
                         let _ = window.unminimize();
                         let _ = window.show();
                         let _ = window.set_focus();
                      }
                   }
                   TrayIconEvent::DoubleClick {
                      button: MouseButton::Left,
                      ..
                   } => {
                      // ダブルクリックでアプリをアクティブ化
                      let app = tray.app_handle();
                      if let Some(window) = app.get_webview_window("main") {
                         let _ = window.set_focus();
                      }
                   }
                   _ => {}
                }
             })
             .build(app)?;
 
          Ok(())
       })
       .run(tauri::generate_context!())
       .expect("error while running tauri application");
 }



React + TypeScriptでのサンプルコード

メニュー管理カスタムフック

 // src/hooks/useAppMenu.ts
 import { useEffect, useState, useCallback } from 'react';
 import { Menu, Submenu } from '@tauri-apps/api/menu';
 import { listen } from '@tauri-apps/api/event';
 
 interface MenuItem {
   id: string;
   text: string;
   accelerator?: string;
   enabled?: boolean;
   handler?: () => void;
 }
 
 interface MenuConfig {
   [menuName: string]: MenuItem[];
 }
 
 export function useAppMenu(config: MenuConfig) {
   const [menu, setMenu] = useState<Menu | null>(null);
   const [isLoading, setIsLoading] = useState(false);
 
   const createMenu = useCallback(async () => {
     setIsLoading(true);
     try {
       const submenus = await Promise.all(
         Object.entries(config).map(async ([name, items]) => {
           const submenu = await Submenu.new({
             text: name,
             items: items.map(item => ({
               id: item.id,
               text: item.text,
               accelerator: item.accelerator,
               enabled: item.enabled ?? true,
             })),
           });
           return submenu;
         })
       );
 
       const newMenu = await Menu.new({
         items: submenus,
       });
 
       await newMenu.setAsAppMenu();
       setMenu(newMenu);
     }
     catch (error) {
       console.error('Failed to create menu:', error);
     }
     finally {
       setIsLoading(false);
     }
   }, [config]);
 
   // メニューイベントをリッスン
   useEffect(() => {
     let unlisten: (() => void) | undefined;
 
     const setup = async () => {
       unlisten = await listen<{ id: string }>('tauri://menu', (event) => {
         // 登録されたハンドラを探して実行
         Object.values(config).forEach(items => {
           const item = items.find(i => i.id === event.payload.id);
           if (item?.handler) {
             item.handler();
           }
         });
       });
     };
 
     setup();
 
     return () => {
       if (unlisten) {
         unlisten();
       }
     };
   }, [config]);
 
   // メニューを作成
   useEffect(() => {
     createMenu();
   }, [createMenu]);
 
   return { menu, isLoading, recreate: createMenu };
 }


メニューコンポーネント

 // src/components/MenuProvider.tsx
 import React, { ReactNode } from 'react';
 import { useAppMenu } from '../hooks/useAppMenu';
 
 interface MenuProviderProps {
   children: ReactNode;
   onNewFile: () => void;
   onOpenFile: () => void;
   onSaveFile: () => void;
   onQuit: () => void;
   onCopy: () => void;
   onPaste: () => void;
   onSettings: () => void;
   onAbout: () => void;
 }
 
 export function MenuProvider({
   children,
   onNewFile,
   onOpenFile,
   onSaveFile,
   onQuit,
   onCopy,
   onPaste,
   onSettings,
   onAbout,
 }: MenuProviderProps) {
   const { isLoading } = useAppMenu({
     'File': [
       { id: 'new', text: 'New', accelerator: 'CmdOrCtrl+N', handler: onNewFile },
       { id: 'open', text: 'Open...', accelerator: 'CmdOrCtrl+O', handler: onOpenFile },
       { id: 'save', text: 'Save', accelerator: 'CmdOrCtrl+S', handler: onSaveFile },
       { id: 'quit', text: 'Quit', accelerator: 'CmdOrCtrl+Q', handler: onQuit },
     ],
     'Edit': [
       { id: 'copy', text: 'Copy', accelerator: 'CmdOrCtrl+C', handler: onCopy },
       { id: 'paste', text: 'Paste', accelerator: 'CmdOrCtrl+V', handler: onPaste },
     ],
     'Help': [
       { id: 'settings', text: 'Settings...', handler: onSettings },
       { id: 'about', text: 'About', handler: onAbout },
     ],
   });
 
   if (isLoading) {
     return <div>Loading menu...</div>;
   }
 
   return <>{children}</>;
 }
 
 // App.tsxでの使用
 function App() {
   const handleNewFile = () => {
     console.log('New file');
   };
 
   const handleOpenFile = () => {
     console.log('Open file');
   };
 
   // ...その他のハンドラ
 
   return (
     <MenuProvider
       onNewFile={handleNewFile}
       onOpenFile={handleOpenFile}
       onSaveFile={() => console.log('Save')}
       onQuit={() => window.close()}
       onCopy={() => console.log('Copy')}
       onPaste={() => console.log('Paste')}
       onSettings={() => console.log('Settings')}
       onAbout={() => console.log('About')}
     >
       <MainContent />
     </MenuProvider>
   );
 }


システムトレイ管理フック

 // src/hooks/useSystemTray.ts
 import { useEffect, useState, useCallback } from 'react';
 import { TrayIcon } from '@tauri-apps/api/tray';
 import { Menu } from '@tauri-apps/api/menu';
 import { listen } from '@tauri-apps/api/event';
 import { getCurrentWebviewWindow } from '@tauri-apps/api/webviewWindow';
 
 interface TrayConfig {
   onShowWindow?: () => void;
   onHideWindow?: () => void;
   onQuit?: () => void;
 }
 
 export function useSystemTray(config: TrayConfig = {}) {
   const [tray, setTray] = useState<TrayIcon | null>(null);
   const [isInitialized, setIsInitialized] = useState(false);
 
   const initialize = useCallback(async () => {
     try {
       const menu = await Menu.new({
         items: [
           { id: 'show', text: 'Show Window' },
           { id: 'hide', text: 'Hide Window' },
           { type: 'separator' },
           { id: 'quit', text: 'Quit' },
         ],
       });
 
       const newTray = await TrayIcon.new({
         menu,
         menuOnLeftClick: true,
         action: (event) => {
           if (event.type === 'Click' && event.button === 'Left') {
             // 左クリックでウインドウをトグル
             getCurrentWebviewWindow().then(window => {
               window.isVisible().then(visible => {
                 if (visible) {
                   window.hide();
                 } else {
                   window.show();
                   window.setFocus();
                 }
               });
             });
           }
         },
       });
 
       setTray(newTray);
       setIsInitialized(true);
     } catch (error) {
       console.error('Failed to initialize tray:', error);
     }
   }, []);
 
   // メニュークリックイベントをリッスン
   useEffect(() => {
     let unlisten: (() => void) | undefined;
 
     const setup = async () => {
       unlisten = await listen<{ id: string }>('tauri://menu', (event) => {
         switch (event.payload.id) {
           case 'show':
             config.onShowWindow?.();
             break;
           case 'hide':
             config.onHideWindow?.();
             break;
           case 'quit':
             config.onQuit?.();
             break;
         }
       });
     };
 
     setup();
 
     return () => {
       if (unlisten) {
         unlisten();
       }
     };
   }, [config]);
 
   // 初期化
   useEffect(() => {
     initialize();
   }, [initialize]);
 
   const setTooltip = useCallback(async (tooltip: string) => {
     if (tray) {
       await tray.setToolTip(tooltip);
     }
   }, [tray]);
 
   return {
     tray,
     isInitialized,
     setTooltip,
   };
 }



トラブルシューティング

メニューが表示されない

  • 原因
    メニューが正しく設定されていない、または、プラットフォーム固有の問題
  • 解決方法
     // メニューを正しく設定
     app.set_menu(&menu)?;
     
     // MacOSでアプリメニューを設定
     #[cfg(target_os = "macos")]
     {
        app.set_menu(&menu)?;
     }
    


ショートカットキーが機能しない

  • 原因
    アクセラレーターの設定が正しくない、または、フォーカスの問題
  • 解決方法
     // クロスプラットフォームのアクセラレーター
     MenuItemBuilder::with_id("save", "Save")
        .accelerator("CmdOrCtrl+S")  // MacOS: Cmd, 他: Ctrl
        .build(app)?;
    


システムトレイアイコンが表示されない

  • 原因
    アイコンファイルが見つからない、または、形式が不正
  • 解決方法
     // デフォルトアイコンを使用
     TrayIconBuilder::new()
        .icon(app.default_window_icon().unwrap().clone())
        .build(app)?;
     
     // カスタムアイコンを使用
     let icon = Image::from_bytes(include_bytes!("../icons/tray.png"))?;
     TrayIconBuilder::new()
        .icon(icon)
        .build(app)?;
    


Linuxでシステムトレイが動作しない

  • 原因
    デスクトップ環境がシステムトレイをサポートしていない。
  • 解決方法
    • GTKベースの環境 (GNOME, XFCE) を使用
    • システムトレイ拡張機能をインストール
    • フォールバックとしてウインドウベースのUIを提供


メニューイベントが重複して発生する

  • 原因
    イベントリスナーが複数回登録されている。
  • 解決方法
     // useEffectでクリーンアップを正しく実装
     useEffect(() => {
       let unlisten: (() => void) | undefined;
     
       const setup = async () => {
         unlisten = await listen('tauri://menu', handler);
       };
     
       setup();
     
       return () => {
         if (unlisten) {
           unlisten();
         }
       };
     }, []);
    



関連情報