Files
noteflow/client/src-tauri/src/CLAUDE.md

13 KiB
Raw Blame History

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 StreamHealth event
  • Inactivity timeout (5 min default)
  • Max stream duration (4 hours default)

Audio Subsystem (audio/)

Capture (capture.rs)

  • Uses cpal for cross-platform audio input
  • RMS level callback for VU meter display
  • Channel sender for streaming to gRPC

Playback (playback.rs)

  • Uses rodio with symphonia decoder
  • Thread-based playback handle
  • Position tracking via samples played

Dual Capture (mixer.rs, drift_compensation/)

  • Microphone + System Audio mixing
  • Gain controls (0.01.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:

  1. Update src/noteflow/grpc/proto/noteflow.proto (Python)
  2. Run npm run tauri:build (regenerates grpc/noteflow.rs)
  3. Update src/grpc/types/*.rs (Rust domain types)
  4. Update client/src/api/types/ (TypeScript types)
  5. 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_case commands match TypeScript adapter
  • Constants: All magic numbers in constants.rs
  • Async: Prefer async/await over raw futures
  • Logging: Use tracing macros 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 classification
  • commands/*_tests.rs — Command-specific tests
  • grpc/proto_compliance_tests.rs — Proto contract verification
  • events/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)