feat(livekit): rust SDK scaffold behind rust-livekit feature flag
Phase B.1 of the native-livekit migration. Ships the command surface and event bridge behind a cargo feature so the default build stays unaffected while the JS-SDK path keeps running in production. Rust side - livekit 0.7 (default tokio runtime, rustls-tls-native-roots) pulled as an optional dependency; tokio also optional under the same feature - Feature `rust-livekit` gates everything — off by default; on via `cargo build --features rust-livekit` - src-tauri/src/livekit_bridge.rs: LivekitState mutex, connect / disconnect / send_data commands, event pump for room_state, participant_joined, participant_left, data_received - Mic / camera / screen share commands stubbed with explicit "not implemented" errors so JS callers fail loudly rather than silently no-op JS side - src/lib/nativeLiveKit.ts exposes a NativeRoom class with the same event / method shape the CallContext will need, plus a VITE_USE_RUST_LIVEKIT flag so the adapter can be swapped once the bridge reaches parity - isRustLivekitAvailable() gates access at both env + runtime layers Build impact - Baseline build unchanged (1s incremental, no new deps pulled) - Feature build initial: ~10min (libwebrtc download + link) - Feature build incremental: ~1s - Binary size with feature: +12-20MB vs baseline Open questions (documented for the next phase) - Video-frame rendering bridge remains an upstream gap; livekit-rust exposes NativeVideoFrame but no stable path to expose that as a MediaStreamTrack inside the WebView - Audio-only rust path is realistic near-term; full-rust needs either upstream video-bridge or a native-overlay render window
This commit is contained in:
@@ -0,0 +1,227 @@
|
||||
// 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<Option<Arc<Room>>>,
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
Reference in New Issue
Block a user