13 KiB
Tauri Rust Backend Development Guide
Overview
The Tauri backend (client/src-tauri/src/) is the Rust layer bridging the React frontend with the Python gRPC server. It handles audio capture, IPC commands, state management, and event emission.
Architecture: Tauri 2.0 + Tokio async runtime + tonic gRPC client
Module Structure
src/
├── commands/ # 60+ Tauri IPC command handlers
├── grpc/ # gRPC client + types + streaming
├── state/ # Thread-safe runtime state (AppState)
├── audio/ # Capture, playback, mixing, drift compensation
├── events/ # Event emission to TypeScript frontend
├── triggers/ # Trigger detection (audio activity, foreground app)
├── identity/ # Auth keychain storage
├── crypto/ # Audio encryption (AES-256-GCM)
├── cache/ # Memory caching
├── error/ # Error types and classification
├── config.rs # Environment-based configuration
├── constants.rs # Centralized constants (273 lines)
├── helpers.rs # Utilities (timestamps, IDs, formatting)
├── lib.rs # Tauri setup + command registration
└── main.rs # Entry point (thin wrapper)
Key Architectural Patterns
1. State Management (AppState)
All mutable state is wrapped in Arc<RwLock<T>> for thread-safe sharing:
pub struct AppState {
pub grpc_client: RwLock<Option<GrpcClient>>,
pub recording_session: RwLock<Option<RecordingSession>>,
pub playback_position: RwLock<f64>,
pub transcript_segments: RwLock<Vec<Segment>>,
pub crypto_manager: RwLock<Option<CryptoManager>>,
pub identity_manager: RwLock<Option<IdentityManager>>,
pub preferences: RwLock<UserPreferences>,
// ...
}
Key Pattern: Use parking_lot::RwLock instead of std::sync::RwLock for performance.
Lazy Initialization: Crypto and identity managers defer keychain access until first use to avoid OS permission dialogs at app startup.
2. Command Pattern
Commands are async functions decorated with #[tauri::command]:
#[tauri::command(rename_all = "snake_case")]
pub async fn connect(
state: State<'_, Arc<AppState>>,
app: AppHandle,
server_url: Option<String>,
) -> Result<ServerInfo> {
let grpc_client = GrpcClient::connect(server_url).await?;
*state.grpc_client.write() = Some(grpc_client);
app.emit(event_names::CONNECTION_CHANGE, ConnectionChangeEvent { connected: true })?;
Ok(server_info)
}
Registration: All commands must be added to app_invoke_handler! macro in lib.rs.
3. Event System
Events are emitted through a broadcast channel to the frontend:
pub enum AppEvent {
TranscriptUpdate(TranscriptUpdateEvent),
AudioLevel(AudioLevelEvent),
PlaybackPosition(PlaybackPositionEvent),
ConnectionChange(ConnectionChangeEvent),
MeetingDetected(MeetingDetectedEvent),
RecordingTimer(RecordingTimerEvent),
SummaryProgress(SummaryProgressEvent),
Error(ErrorEvent),
// ...
}
Event Names: Must match TypeScript TauriEvents constants in client/src/api/tauri-constants.ts.
4. Error Classification
All errors are classified for intelligent frontend handling:
pub fn classify(&self) -> ErrorClassification {
ErrorClassification {
grpc_status: Some(status_code),
category: "network".to_string(), // auth, validation, timeout, not_found, etc.
retryable: true,
}
}
Command Categories
| Category | Count | Key Commands |
|---|---|---|
| Connection | 5 | connect, disconnect, is_connected, get_server_info |
| Identity | 8 | get_current_user, switch_workspace, logout |
| Projects | 11 | CRUD + members, set_active_project |
| Meeting | 5 | create_meeting, list_meetings, stop_meeting |
| Recording | 5 | start_recording, stop_recording, send_audio_chunk |
| Diarization | 5 | refine_diarization, rename_speaker |
| Audio | 14 | Device selection, test input/output, dual capture |
| Playback | 5 | start_playback, pause_playback, seek |
| Summary | 8 | generate_summary, templates, consent |
| Annotations | 5 | CRUD operations |
| Calendar | 7 | Events, OAuth, webhooks |
| OIDC | 8 | Provider management |
| Preferences | 4 | Get, save, sync |
| Triggers | 6 | Enable, snooze, status |
gRPC Client (grpc/)
Module Organization
grpc/
├── client/
│ ├── core.rs # Connection, ClientConfig, IdentityInterceptor
│ ├── meetings.rs # Meeting operations
│ ├── annotations.rs # Annotation CRUD
│ ├── calendar.rs # Calendar + OAuth
│ ├── converters.rs # Protobuf → domain types
│ └── ...
├── types/ # Domain types for frontend
├── streaming/ # Bidirectional audio streaming
└── noteflow.rs # Generated from proto (build.rs)
Streaming Architecture
pub struct StreamManager {
tx: mpsc::Sender<AudioChunk>,
activity: Arc<AtomicBool>,
inactivity_timeout: Duration,
max_duration: Duration,
}
Features:
- Audio chunk buffering (channel capacity: 512)
- Backpressure signaling via
StreamHealthevent - Inactivity timeout (5 min default)
- Max stream duration (4 hours default)
Audio Subsystem (audio/)
Capture (capture.rs)
- Uses
cpalfor cross-platform audio input - RMS level callback for VU meter display
- Channel sender for streaming to gRPC
Playback (playback.rs)
- Uses
rodiowith symphonia decoder - Thread-based playback handle
- Position tracking via samples played
Dual Capture (mixer.rs, drift_compensation/)
- Microphone + System Audio mixing
- Gain controls (0.0–1.0)
- Adaptive resampling for drift compensation
// Drift compensation detector
pub struct DriftDetector {
ema_alpha: f64, // 0.02
drift_threshold: f64, // 10 ppm
history: VecDeque<f64>,
}
Recording Flow
start_recording()
│
├── Create RecordingSession
├── Start cpal audio capture
├── Start conversion task (async)
│
▼ (audio frames via callback)
│
send_audio_chunk()
│
├── Queue chunks for gRPC streaming
├── Apply drift compensation (if dual capture)
├── Encrypt if required
│
▼
stop_recording()
│
├── Stop capture threads
├── Flush audio buffers
└── Close session
Constants (constants.rs)
| Category | Constant | Value |
|---|---|---|
| Events | EVENT_CHANNEL_CAPACITY | 100 |
| gRPC | CONNECTION_TIMEOUT | 5 seconds |
| gRPC | REQUEST_TIMEOUT | 300 seconds |
| Audio | DEFAULT_SAMPLE_RATE | 16000 Hz |
| Audio | BUFFER_SIZE_FRAMES | 1600 (100ms) |
| Audio | VU_METER_UPDATE_RATE | 20 Hz |
| Recording | TIMER_TICK | 1 second |
| Streaming | CHANNEL_CAPACITY | 512 |
| Streaming | INACTIVITY_TIMEOUT | 5 minutes |
| Streaming | MAX_DURATION | 4 hours |
| Drift | EMA_ALPHA | 0.02 |
| Drift | THRESHOLD_PPM | 10 |
TypeScript Integration
Command Invocation (TS → Rust)
// client/src/api/tauri-adapter.ts
await invoke<ServerInfo>('connect', { server_url: 'localhost:50051' });
// src/commands/connection.rs
#[tauri::command(rename_all = "snake_case")]
pub async fn connect(...) -> Result<ServerInfo>
Event Subscription (Rust → TS)
// Rust emits
app.emit(event_names::TRANSCRIPT_UPDATE, event)?;
// TypeScript receives
listen('TRANSCRIPT_UPDATE', (event) => handleUpdate(event.payload));
Proto Synchronization
When protobuf changes:
- Update
src/noteflow/grpc/proto/noteflow.proto(Python) - Run
npm run tauri:build(regeneratesgrpc/noteflow.rs) - Update
src/grpc/types/*.rs(Rust domain types) - Update
client/src/api/types/(TypeScript types) - Update converters in
grpc/client/converters.rs
Adding New Commands
1. Implement Command
// src/commands/my_feature.rs
#[tauri::command(rename_all = "snake_case")]
pub async fn my_command(
state: State<'_, Arc<AppState>>,
app: AppHandle,
my_param: String,
) -> Result<MyResponse> {
// Implementation
}
2. Export from Module
// src/commands/mod.rs
pub mod my_feature;
pub use my_feature::my_command;
3. Register in lib.rs
// src/lib.rs
app_invoke_handler!(
// ... existing commands
my_command,
)
4. TypeScript Adapter
// client/src/api/tauri-adapter.ts
async myCommand(myParam: string): Promise<MyResponse> {
return invoke<MyResponse>('my_command', { my_param: myParam });
}
5. Mock Adapter
// client/src/api/mock-adapter.ts
async myCommand(myParam: string): Promise<MyResponse> {
return { /* mock response */ };
}
Adding New Events
1. Add Event Variant
// src/events/mod.rs
pub enum AppEvent {
// ... existing
MyEvent(MyEventPayload),
}
2. Add Payload Struct
#[derive(Debug, Serialize, Clone)]
pub struct MyEventPayload {
pub data: String,
}
3. Add Event Name Constant
// src/events/event_names.rs
pub const MY_EVENT: &str = "MY_EVENT";
4. Update Dispatch Methods
fn event_name(&self) -> &'static str {
match self {
// ...
AppEvent::MyEvent(_) => event_names::MY_EVENT,
}
}
fn to_payload(&self) -> serde_json::Value {
match self {
// ...
AppEvent::MyEvent(e) => serde_json::to_value(e).unwrap(),
}
}
5. TypeScript Constants
// client/src/api/tauri-constants.ts
export const MY_EVENT = 'MY_EVENT';
6. Update Contract Test
// client/src/api/tauri-constants.test.ts
// Add MY_EVENT to expected events
Forbidden Patterns
❌ Using unwrap() Without Justification
// WRONG
let value = some_option.unwrap();
// RIGHT
let value = some_option.ok_or(Error::NotFound)?;
❌ Direct Keychain Access at Startup
// WRONG: Triggers OS permission dialog at launch
fn new() -> Self {
let keychain = Keychain::new().get_key().unwrap();
}
// RIGHT: Lazy initialization
fn get_key(&self) -> Result<Key> {
let mut manager = self.crypto_manager.write();
if manager.is_none() {
*manager = Some(CryptoManager::new()?);
}
manager.as_ref().unwrap().get_key()
}
❌ Using std::sync::RwLock
// WRONG: Performance issues
use std::sync::RwLock;
// RIGHT: parking_lot is faster
use parking_lot::RwLock;
❌ Hardcoding Values
// WRONG
let timeout = Duration::from_secs(5);
// RIGHT
let timeout = Duration::from_secs(constants::CONNECTION_TIMEOUT_SECS);
❌ Bypassing Error Classification
// WRONG
Err(Error::Custom("Something failed".to_string()))
// RIGHT
Err(Error::MyOperation(MyOperationError::SpecificFailure))
// With classification implemented
Code Quality Standards
Clippy Configuration (clippy.toml)
cognitive-complexity-threshold = 25
too-many-lines-threshold = 100
too-many-arguments-threshold = 7
Required Practices
- Error Handling: Always use
Result<T, Error>, never panic - Documentation: Doc comments for all public items
- Naming:
snake_casecommands match TypeScript adapter - Constants: All magic numbers in
constants.rs - Async: Prefer
async/awaitover raw futures - Logging: Use
tracingmacros with appropriate levels
Key Dependencies
| Crate | Purpose | Version |
|---|---|---|
tauri |
App framework | 2.0 |
tokio |
Async runtime | 1.40 |
tonic |
gRPC client | 0.12 |
cpal |
Audio capture | 0.15 |
rodio |
Audio playback | 0.20 |
rubato |
Sample rate conversion | 0.16 |
aes-gcm |
Encryption | 0.10 |
parking_lot |
Sync primitives | 0.12 |
keyring |
Secure storage | 2.3 |
tracing |
Logging | 0.1 |
Testing
Unit Tests
#[cfg(test)]
mod tests {
#[test]
fn test_error_classification() {
let err = Error::Timeout("request timed out".into());
let classification = err.classify();
assert_eq!(classification.category, "timeout");
assert!(classification.retryable);
}
}
Test Files
error/tests.rs— Error classificationcommands/*_tests.rs— Command-specific testsgrpc/proto_compliance_tests.rs— Proto contract verificationevents/tests.rs— Event serialization
See Also
/client/CLAUDE.md— Full client development guide/client/src/api/types/— TypeScript type definitions/src/noteflow/grpc/proto/noteflow.proto— gRPC schema (source of truth)