How Meetily's System Tray Integration Manages Recording State and Quick Actions
Meetily implements a native system tray using Tauri that enables users to control meeting recordings—start, pause, resume, and stop—directly from the desktop without opening the main window, while maintaining strict state synchronization between the tray menu, backend recording commands, and the React frontend.
Meetily, an open-source meeting transcription tool built by Zackriya-Solutions, leverages Tauri's system tray capabilities to provide instant recording controls. The implementation in frontend/src-tauri/src/tray.rs establishes a state-driven menu system that reflects real-time recording status, from idle through active transcription to paused states. This integration ensures users can trigger quick actions while the backend handles complex audio processing asynchronously.
Tray Initialization and State Model
When the application launches, create_tray initializes the system tray icon with a default Stopped state and a placeholder allowing recording (can_record = true). This immediate initialization ensures the tray is visible before the full application state is determined.
pub fn create_tray<R: Runtime>(app: &AppHandle<R>) -> tauri::Result<()> {
let menu = build_menu(app, RecordingState::Stopped, true)?;
TrayIconBuilder::with_id("main-tray")
.menu(&menu)
.tooltip("Meetily")
.icon(app.default_window_icon().unwrap().clone())
.on_menu_event(|app, event| handle_menu_event(app, event.id.as_ref()))
.build(app)?;
update_tray_menu(app); // refresh with the real state later
Ok(())
}
Source: tray.rs:L19-L34
The RecordingState enum defines every UI-visible phase to prevent invalid transitions:
pub enum RecordingState {
Stopped,
Starting,
Recording,
Pausing,
Paused,
Resuming,
Stopping,
}
Source: tray.rs:L8-L17
Routing Menu Events to Action Handlers
The tray registers a single callback handle_menu_event that matches the clicked menu-item ID and forwards requests to specific handlers. This centralization ensures consistent state validation before executing recording commands.
fn handle_menu_event<R: Runtime>(app: &AppHandle<R>, item_id: &str) {
match item_id {
"toggle_recording" => toggle_recording_handler(app),
"pause_recording" => pause_recording_handler(app),
"resume_recording" => resume_recording_handler(app),
"stop_recording" => stop_recording_handler(app),
"open_window" => focus_main_window(app),
"settings" => { … },
"check_updates" => check_updates_handler(app),
"quit" => app.exit(0),
_ => {}
}
}
Source: tray.rs:L37-L53
The mapping covers core recording actions (toggle, pause, resume, stop) alongside utility functions (open_window, settings, check_updates, quit).
Async Recording Handlers and State Transitions
Each quick-action handler follows a three-step pattern: update the tray UI immediately with an intermediate state, invoke the appropriate Tauri command asynchronously, and handle completion or errors.
Toggle Recording Handler
The toggle_recording_handler checks the current recording status via crate::is_recording().await and branches accordingly. When starting, it sets the intermediate Starting state and focuses the main window; when stopping, it transitions to Stopping and initiates the save process.
fn toggle_recording_handler<R: Runtime>(app: &AppHandle<R>) {
focus_main_window(app);
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
if crate::is_recording().await {
// Stop case
set_tray_state(&app_clone, RecordingState::Stopping);
// … generate save path, call stop_recording (see recording_commands.rs) …
} else {
// Start case
set_tray_state(&app_clone, RecordingState::Starting);
// Let the UI navigate to the recording page; the actual start happens in the
// front‑end which calls `start_recording` via Tauri.
}
});
}
Source: tray.rs:L55-L73
Pause and Stop Handlers
The pause_recording_handler immediately sets the Pausing state before spawning an async task to call pause_recording from recording_commands.rs. If the command fails, it logs the error and triggers a menu refresh.
fn pause_recording_handler<R: Runtime>(app: &AppHandle<R>) {
set_tray_state(app, RecordingState::Pausing);
let app_clone = app.clone();
tauri::async_runtime::spawn(async move {
if let Err(e) = crate::audio::recording_commands::pause_recording(app_clone.clone()).await {
log::error!("Failed to pause recording from tray: {}", e);
update_tray_menu_async(&app_clone).await;
}
});
}
Source: tray.rs:L17-L31
Similarly, stop_recording_handler transitions to Stopping, focuses the window, and calls stop_recording with constructed RecordingArgs, then emits recording-stop-complete upon success.
Source: tray.rs:L52-L73
The heavy lifting occurs in recording_commands.rs, where stop_recording (lines 842-885) performs graceful shutdown, forces final audio flush, waits for transcription workers, unloads models, and emits recording-stopped for frontend persistence.
Dynamic Menu Reconstruction
After state changes, the tray must reflect new available actions. Meetily uses two functions for this:
set_tray_state: Immediately updates the menu to show intermediate states (e.g., "🔄 Starting Recording…") with disabled items to prevent double-clicks.update_tray_menu_async: Queries the actual recording status (is_recording,is_recording_paused) and rebuilds the menu with correct enabled/disabled states.
pub fn set_tray_state<R: Runtime>(app: &AppHandle<R>, state: RecordingState) {
if let Ok(menu) = build_menu(app, state, true) {
if let Some(tray) = app.tray_by_id("main-tray") {
tray.set_menu(Some(menu));
}
}
}
Source: tray.rs:L22-L35
pub async fn update_tray_menu_async<R: Runtime>(app: &AppHandle<R>) {
let recording_state = get_current_recording_state().await;
let can_record = check_can_record(app).await;
if let Ok(menu) = build_menu(app, recording_state, can_record) {
if let Some(tray) = app.tray_by_id("main-tray") {
tray.set_menu(Some(menu));
}
}
}
Source: tray.rs:L93-L114
State-Specific Menu Layout
The build_menu function constructs different menu items based on the current RecordingState:
| State | Available Actions |
|---|---|
| Stopped | Start Recording |
| Starting | 🔄 Starting Recording… (disabled) |
| Recording | ⏸ Pause Recording, ⏹ Stop Recording |
| Pausing | ⏸ Pausing… (disabled), ⏹ Stop Recording |
| Paused | ▶ Resume Recording, ⏹ Stop Recording |
| Resuming | ▶ Resuming… (disabled), ⏹ Stop Recording |
| Stopping | ⏹ Stopping… (disabled) |
Below the state-specific group, static items always appear: Open Main Window, Settings, Check for Updates, and Quit.
Source: tray.rs:L31-L90
Summary
Meetily's system tray integration provides a responsive, state-aware control layer that operates independently of the main application window:
- State Machine Enforcement: The
RecordingStateenum prevents invalid transitions by disabling menu items during intermediate states (Starting, Pausing, etc.). - Async Command Routing: Tray handlers use
tauri::async_runtime::spawnto invoke recording commands without blocking the UI thread. - Bidirectional Synchronization:
set_tray_stateprovides immediate visual feedback, whileupdate_tray_menu_asyncensures the menu eventually reflects ground truth fromrecording_commands.rs. - Direct Recording Control: The tray invokes
start_recording,pause_recording,resume_recording, andstop_recordingfromfrontend/src-tauri/src/audio/recording_commands.rs, which manage the actual audio pipeline and model lifecycle.
Frequently Asked Questions
How does Meetily's system tray integration handle state transitions during recording operations?
The tray uses intermediate states like Starting and Pausing to immediately update the menu via set_tray_state, disabling user interaction during async operations. Once the underlying Tauri command in recording_commands.rs completes, update_tray_menu_async rebuilds the menu to reflect the final state (e.g., Recording or Paused), ensuring the UI never shows conflicting options.
What is the difference between set_tray_state and update_tray_menu_async in Meetily's tray implementation?
set_tray_state applies a temporary menu for immediate feedback (e.g., showing "🔄 Starting…" with disabled items), while update_tray_menu_async performs an asynchronous check against the actual recording globals (IS_RECORDING, RECORDING_MANAGER) and rebuilds the menu based on the real backend state. The former handles optimistic UI updates; the latter ensures eventual consistency.
How does the tray menu prevent user actions during intermediate states like Starting or Stopping?
The build_menu function constructs menu items with disabled states for intermediate phases (Starting, Pausing, Resuming, Stopping). When set_tray_state sets these phases, the resulting menu contains non-clickable items, preventing duplicate commands while the backend processes the initial request. Users can only trigger new actions once the state machine reaches a stable phase (Stopped, Recording, or Paused).
Which Tauri commands does the system tray invoke to control the actual recording process?
The tray handlers invoke four main commands from frontend/src-tauri/src/audio/recording_commands.rs: start_recording (via the toggle handler when idle), pause_recording, resume_recording, and stop_recording. These commands manage the audio capture pipeline, VAD processing, and transcription model loading/unloading, emitting events like recording-started and recording-stopped that synchronize the React frontend.
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 →