How Meetily Implements System Tray Integration in Its Tauri Desktop App

Meetily implements system tray integration using Tauri's native TrayIconBuilder API, constructing a dynamic menu in tray.rs that handles recording state changes and dispatches events to both Rust commands and the React frontend.

Meetily is an open-source AI meeting assistant built with a Tauri desktop architecture. The system tray integration provides users with persistent access to recording controls and navigation without keeping the main window focused. This implementation leverages Tauri's cross-platform tray APIs to create a context-aware menu that reflects real-time recording states.

Tray Creation and Initialization

The system tray is instantiated during the application bootstrap phase in frontend/src-tauri/src/lib.rs. Inside the .setup() closure, the application calls tray::create_tray with the AppHandle:

// frontend/src-tauri/src/lib.rs
builder.setup(|_app| {
    if let Err(e) = tray::create_tray(_app.handle()) {
        log::error!("Failed to create system tray: {}", e);
    }
    Ok(())
})

The create_tray function in frontend/src-tauri/src/tray.rs (lines 19-32) performs four critical operations:

  1. Builds an initial menu using the default RecordingState::Stopped state
  2. Instantiates the tray icon with the application's default window icon
  3. Registers the menu event callback (on_menu_event) that forwards item IDs to handle_menu_event
  4. Triggers an immediate menu refresh via update_tray_menu to ensure the UI reflects the actual recording state after startup

Dynamic Menu Generation Based on Recording State

The menu construction is handled by build_menu in frontend/src-tauri/src/tray.rs (lines 16-93). This function accepts three parameters:

  • app – The AppHandle for menu construction
  • state – The current RecordingState enum value
  • can_record – A boolean flag that disables recording controls during onboarding or while downloading transcription models

Based on these inputs, the function constructs a tauri::menu::Menu with context-specific items:

  • State-specific controls: Start, Pause, Resume, Stop, and transitional labels like "…Starting…"
  • Standard navigation: Open Main Window, Settings, Check for Updates, and Quit
  • Disabled placeholders: A ⏳ Downloading transcription model… item when can_record is false

The menu rebuilds dynamically whenever the recording state changes through update_tray_menu_async, which queries the current state via get_current_recording_state and check_can_record before constructing the new menu.

Event Handling and User Actions

User interactions are processed in handle_menu_event (lines 37-53 in tray.rs), which matches the item_id against specific handlers:

Menu ID Handler Function Action
toggle_recording toggle_recording_handler Starts recording by setting a flag in sessionStorage or stops via audio::recording_commands::stop_recording
pause_recording pause_recording_handler Invokes audio::recording_commands::pause_recording
resume_recording resume_recording_handler Invokes audio::recording_commands::resume_recording
stop_recording stop_recording_handler Stops and saves the current recording file
open_window focus_main_window Brings the Tauri window to the foreground
settings focus_main_window + JS eval Opens the Settings page via window.location.assign('/settings')
check_updates check_updates_handler Dispatches a custom DOM event check-updates-from-tray
quit app.exit(0) Terminates the application process

All handlers run asynchronously on Tauri's async_runtime and emit events (such as recording-stop-complete) enabling the React frontend to react without polling.

State Synchronization Mechanisms

The tray menu remains synchronized with the Rust recording subsystem through three key functions:

set_tray_state – Provides immediate UI feedback for transitional states (e.g., "Starting Recording…"). It rebuilds the menu instantly with the supplied state before the actual recording command completes.

update_tray_menu – A debounced wrapper that schedules an async task with a 100ms delay before calling update_tray_menu_async. This prevents excessive menu rebuilds during rapid state changes.

update_tray_menu_async – Queries the ground truth of the recording state using crate::audio::recording_commands::is_recording and is_recording_paused, checks check_can_record, and replaces the existing tray menu via tray.set_menu.

Practical Implementation Examples

Adding a Custom Menu Item

To extend the tray with a new "Show Log" option, modify the builder in tray.rs:

// In frontend/src-tauri/src/tray.rs
builder
    .item(&PredefinedMenuItem::separator(app)?)
    .item(&MenuItemBuilder::with_id("show_log", "Show Log").build(app)?)
    .item(&PredefinedMenuItem::separator(app)?)
    // existing items...
    .build()

Then handle the click in handle_menu_event:

match item_id {
    "show_log" => {
        focus_main_window(app);
        if let Some(window) = app.get_webview_window("main") {
            let _ = window.eval("window.location.assign('/log')");
        }
    }
    _ => {}
}

Manually Refreshing the Tray

Force a menu update after custom state mutations:

use crate::tray;

// After state change
tray::update_tray_menu(&app_handle);

Emitting Frontend Events

Dispatch custom events to the React frontend from tray actions:

if let Some(window) = app.get_webview_window("main") {
    let _ = window.eval(
        "window.dispatchEvent(new CustomEvent('custom-tray-action'))"
    );
}

Summary

  • Initialization: The tray is created in lib.rs via tray::create_tray, which sets up the icon, initial menu, and event handlers.
  • Dynamic Menus: build_menu constructs context-aware menus based on RecordingState and can_record flags, disabling controls during onboarding.
  • Event Dispatch: handle_menu_event routes clicks to specific handlers that invoke Rust commands in audio/recording_commands.rs or execute JavaScript via window.eval.
  • State Sync: update_tray_menu_async and set_tray_state ensure the tray reflects the actual recording status without polling.

Frequently Asked Questions

How does Meetily handle tray menu updates when recording starts?

Meetily uses update_tray_menu_async to rebuild the menu dynamically. When a recording starts, the handler calls this function, which queries is_recording() and check_can_record() before constructing a new menu with Pause/Stop options instead of Start. A 100ms debounce in update_tray_menu prevents excessive rebuilds during rapid state transitions.

Can the system tray controls work when the main window is closed?

Yes, the tray operates independently of the main window visibility. The handlers use app.get_webview_window("main") to lazily access the window only when needed (such as for navigation or focus), while recording commands in audio/recording_commands.rs function regardless of window state.

Where is the tray icon configured in the Meetily codebase?

The tray icon is set during create_tray in frontend/src-tauri/src/tray.rs using the application's default icon resources. The implementation uses TrayIconBuilder with the icon path configured through Tauri's asset protocol, typically referencing the same icon files used for the application window.

How does the tray communicate with the React frontend?

The tray communicates via JavaScript evaluation using window.eval() on the main webview window. For example, the settings handler executes window.location.assign('/settings'), while the check updates handler dispatches a CustomEvent that React components can listen for using standard DOM event listeners.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →