// Rust LiveKit bridge — command/event glue between the JS CallContext and // the native livekit client. Feature-gated behind `rust-livekit` so the // baseline build doesn't pay the libwebrtc download / link cost while the // bridge is still evolving. // // Design contract (matches `lib/nativeLiveKit.ts` on the JS side): // command: livekit_connect { url, token, e2ee_key_b64? } // command: livekit_disconnect // command: livekit_set_mic { enabled } // command: livekit_set_camera { enabled } // command: livekit_start_share {} // command: livekit_stop_share {} // command: livekit_send_data { payload_b64, reliable } // event: livekit:room_state { state } // event: livekit:participant_joined { identity, name? } // event: livekit:participant_left { identity } // event: livekit:track_published { identity, sid, kind, source } // event: livekit:track_unpublished { identity, sid } // event: livekit:audio_level { identity, level } // event: livekit:data_received { identity, payload_b64 } // event: livekit:error { message } // // Phase B.1 (this file) only wires connect/disconnect + room-state events // so the JS side can prove round-trip; mic/camera/video come later. #![cfg(feature = "rust-livekit")] use std::sync::Arc; use livekit::{ id::ParticipantIdentity, DataPacketKind, Room, RoomEvent, RoomOptions, }; use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Emitter, State}; use tokio::sync::{mpsc, Mutex}; pub struct LivekitState { room: Mutex>>, } impl LivekitState { pub fn new() -> Self { Self { room: Mutex::new(None), } } } #[derive(Deserialize)] pub struct ConnectArgs { pub url: String, pub token: String, } #[derive(Serialize, Clone)] struct RoomStatePayload { state: &'static str, } #[derive(Serialize, Clone)] struct ParticipantPayload { identity: String, } #[derive(Serialize, Clone)] struct DataPayload { identity: String, payload_b64: String, reliable: bool, } #[tauri::command] pub async fn livekit_connect( app: AppHandle, state: State<'_, LivekitState>, args: ConnectArgs, ) -> Result<(), String> { let mut guard = state.room.lock().await; if guard.is_some() { return Err("already connected".into()); } let options = RoomOptions::default(); let (room, mut events) = Room::connect(&args.url, &args.token, options) .await .map_err(|e| format!("livekit connect failed: {}", e))?; let room = Arc::new(room); *guard = Some(room.clone()); drop(guard); let _ = app.emit( "livekit:room_state", RoomStatePayload { state: "connected" }, ); // Spawn the event pump. Lives for the duration of the room connection; // stops naturally when the channel closes (disconnect or crash). let app_for_events = app.clone(); tokio::spawn(async move { while let Some(event) = events.recv().await { pump_event(&app_for_events, event); } let _ = app_for_events.emit( "livekit:room_state", RoomStatePayload { state: "disconnected", }, ); }); Ok(()) } fn pump_event(app: &AppHandle, event: RoomEvent) { match event { RoomEvent::ParticipantConnected(p) => { let _ = app.emit( "livekit:participant_joined", ParticipantPayload { identity: identity_string(p.identity()), }, ); } RoomEvent::ParticipantDisconnected(p) => { let _ = app.emit( "livekit:participant_left", ParticipantPayload { identity: identity_string(p.identity()), }, ); } RoomEvent::DataReceived { payload, kind, participant, .. } => { use base64::engine::general_purpose::STANDARD; use base64::Engine; let identity = participant .map(|p| identity_string(p.identity())) .unwrap_or_default(); let _ = app.emit( "livekit:data_received", DataPayload { identity, payload_b64: STANDARD.encode(payload.as_ref()), reliable: matches!(kind, DataPacketKind::Reliable), }, ); } RoomEvent::Disconnected { .. } => { let _ = app.emit( "livekit:room_state", RoomStatePayload { state: "disconnected", }, ); } _ => { // Remaining events (TrackPublished, TrackSubscribed, etc.) land // in later phases. Ignoring silently keeps the prototype small. } } } fn identity_string(id: ParticipantIdentity) -> String { // ParticipantIdentity is a newtype around String in the livekit crate. id.0 } #[tauri::command] pub async fn livekit_disconnect(state: State<'_, LivekitState>) -> Result<(), String> { let mut guard = state.room.lock().await; if let Some(room) = guard.take() { let _ = room.close().await; } Ok(()) } #[tauri::command] pub async fn livekit_send_data( state: State<'_, LivekitState>, payload_b64: String, reliable: bool, ) -> Result<(), String> { use base64::engine::general_purpose::STANDARD; use base64::Engine; let room = state.room.lock().await; let room = room.as_ref().ok_or_else(|| "not connected".to_string())?; let payload = STANDARD .decode(&payload_b64) .map_err(|e| format!("bad base64: {}", e))?; let kind = if reliable { DataPacketKind::Reliable } else { DataPacketKind::Lossy }; room.local_participant() .publish_data(livekit::prelude::DataPacket { payload, topic: None, reliable: matches!(kind, DataPacketKind::Reliable), destination_identities: Vec::new(), }) .await .map_err(|e| format!("publish_data failed: {}", e))?; Ok(()) } // Placeholder — Phase B.2 will fill these in. #[tauri::command] pub async fn livekit_set_mic(_enabled: bool) -> Result<(), String> { Err("livekit_set_mic not implemented — Phase B.2".into()) } #[tauri::command] pub async fn livekit_set_camera(_enabled: bool) -> Result<(), String> { Err("livekit_set_camera not implemented — Phase B.2".into()) } // Unused-send bridge so `mpsc` doesn't get unused-import-warned when the // feature gate is off. #[allow(dead_code)] fn _mpsc_anchor() -> mpsc::Sender<()> { let (tx, _rx) = mpsc::channel::<()>(1); tx }