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:
- Builds an initial menu using the default
RecordingState::Stoppedstate - Instantiates the tray icon with the application's default window icon
- Registers the menu event callback (
on_menu_event) that forwards item IDs tohandle_menu_event - Triggers an immediate menu refresh via
update_tray_menuto 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– TheAppHandlefor menu constructionstate– The currentRecordingStateenum valuecan_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 whencan_recordisfalse
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.rsviatray::create_tray, which sets up the icon, initial menu, and event handlers. - Dynamic Menus:
build_menuconstructs context-aware menus based onRecordingStateandcan_recordflags, disabling controls during onboarding. - Event Dispatch:
handle_menu_eventroutes clicks to specific handlers that invoke Rust commands inaudio/recording_commands.rsor execute JavaScript viawindow.eval. - State Sync:
update_tray_menu_asyncandset_tray_stateensure 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:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →