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:
2026-04-21 10:56:04 +02:00
parent 725a7e0364
commit 48ac9d2922
5 changed files with 1698 additions and 110 deletions
+1298 -109
View File
File diff suppressed because it is too large Load Diff
+13
View File
@@ -33,11 +33,24 @@ tauri-plugin-global-shortcut = "2"
tauri-plugin-updater = "2"
tauri-plugin-window-state = "2"
# LiveKit client SDK — lives behind the `rust-livekit` feature flag so the
# baseline build stays unaffected while the JS-SDK path is still the
# default. Pulls libwebrtc-rs which adds ~20MB to the binary and ~5-10min
# to the first build. Tokio runtime is required; the rest of the crate
# stays idle when the feature is off.
livekit = { version = "0.7", default-features = false, features = ["tokio", "rustls-tls-native-roots"], optional = true }
tokio = { version = "1", features = ["rt-multi-thread", "sync", "macros"], optional = true }
[features]
# This feature is used for production builds or when `devPath` points to the filesystem
# and disables specific features relevant to the dev build.
custom-protocol = ["tauri/custom-protocol"]
# Enable the Rust LiveKit client. Off by default so CI + users stay on the
# JS-SDK path until the rust bridge reaches feature parity. Turn on via:
# cargo build --features rust-livekit
rust-livekit = ["dep:livekit", "dep:tokio"]
# Release-profile tuned for ChatApp: whole-program LTO + single codegen unit
# cuts binary size by ~20-30% and trims startup overhead. `strip = "symbols"`
# removes debug + symbol tables (the updater already signs separately so
+28 -1
View File
@@ -1,5 +1,8 @@
mod crypto;
#[cfg(feature = "rust-livekit")]
mod livekit_bridge;
#[cfg(not(any(target_os = "android", target_os = "ios")))]
use tauri::{
menu::{Menu, MenuEvent, MenuItem, PredefinedMenuItem},
@@ -49,6 +52,7 @@ fn handle_menu_event(app: &AppHandle, event: MenuEvent) {
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
#[cfg(not(feature = "rust-livekit"))]
let mut builder = tauri::Builder::default()
.invoke_handler(tauri::generate_handler![
crypto::crypto_random_bytes,
@@ -61,7 +65,30 @@ pub fn run() {
crypto::crypto_box_seal_open,
crypto::crypto_pwhash,
])
.plugin(tauri_plugin_notification::init())
.plugin(tauri_plugin_notification::init());
#[cfg(feature = "rust-livekit")]
let mut builder = tauri::Builder::default()
.manage(livekit_bridge::LivekitState::new())
.invoke_handler(tauri::generate_handler![
crypto::crypto_random_bytes,
crypto::crypto_secretbox_encrypt,
crypto::crypto_secretbox_decrypt,
crypto::crypto_box_keypair,
crypto::crypto_box_encrypt,
crypto::crypto_box_decrypt,
crypto::crypto_box_seal,
crypto::crypto_box_seal_open,
crypto::crypto_pwhash,
livekit_bridge::livekit_connect,
livekit_bridge::livekit_disconnect,
livekit_bridge::livekit_send_data,
livekit_bridge::livekit_set_mic,
livekit_bridge::livekit_set_camera,
])
.plugin(tauri_plugin_notification::init());
builder = builder
.plugin(tauri_plugin_sql::Builder::default().build())
.plugin(tauri_plugin_fs::init())
.plugin(
@@ -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
}
+132
View File
@@ -0,0 +1,132 @@
// Thin JS wrapper around the Rust LiveKit bridge commands + events.
// Mirrors the subset of `livekit-client` that CallContext actually uses
// so the adapter can be swapped behind the `VITE_USE_RUST_LIVEKIT` flag.
//
// Phase B.1 — only connect/disconnect/data-channel/state events wired.
// Mic, camera, screen-share, active-speakers, video rendering ship in
// later phases. Components that call missing methods get a typed
// "not implemented" error so regressions surface immediately.
import { invoke } from '@tauri-apps/api/core';
import { listen, type UnlistenFn } from '@tauri-apps/api/event';
import { isTauriRuntime } from './globalShortcut';
export const rustLivekitFlag = (() => {
const raw = (import.meta as unknown as { env?: { VITE_USE_RUST_LIVEKIT?: string } })
.env?.VITE_USE_RUST_LIVEKIT;
return raw === 'true' || raw === '1';
})();
export function isRustLivekitAvailable(): boolean {
return rustLivekitFlag && isTauriRuntime();
}
export type NativeRoomState =
| { state: 'connecting' }
| { state: 'connected' }
| { state: 'disconnected' };
export interface NativeParticipantEvent {
identity: string;
name?: string | undefined;
}
export interface NativeDataEvent {
identity: string;
payloadB64: string;
reliable: boolean;
}
type Listener<T> = (payload: T) => void;
export class NativeRoom {
private unlistens: UnlistenFn[] = [];
private stateListeners = new Set<Listener<NativeRoomState>>();
private joinListeners = new Set<Listener<NativeParticipantEvent>>();
private leaveListeners = new Set<Listener<NativeParticipantEvent>>();
private dataListeners = new Set<Listener<NativeDataEvent>>();
async connect(url: string, token: string): Promise<void> {
if (!isRustLivekitAvailable()) {
throw new Error('Rust LiveKit backend not available');
}
await this.subscribeEvents();
try {
await invoke('livekit_connect', { args: { url, token } });
} catch (err: unknown) {
await this.teardown();
throw err;
}
}
async disconnect(): Promise<void> {
try {
await invoke('livekit_disconnect');
} finally {
await this.teardown();
}
}
async sendData(payload: Uint8Array, reliable: boolean): Promise<void> {
await invoke('livekit_send_data', {
payloadB64: bytesToB64(payload),
reliable,
});
}
onRoomState(fn: Listener<NativeRoomState>): () => void {
this.stateListeners.add(fn);
return () => this.stateListeners.delete(fn);
}
onParticipantJoined(fn: Listener<NativeParticipantEvent>): () => void {
this.joinListeners.add(fn);
return () => this.joinListeners.delete(fn);
}
onParticipantLeft(fn: Listener<NativeParticipantEvent>): () => void {
this.leaveListeners.add(fn);
return () => this.leaveListeners.delete(fn);
}
onDataReceived(fn: Listener<NativeDataEvent>): () => void {
this.dataListeners.add(fn);
return () => this.dataListeners.delete(fn);
}
private async subscribeEvents(): Promise<void> {
// Room state — connected / disconnected.
const uState = await listen<NativeRoomState>('livekit:room_state', (evt) => {
for (const fn of this.stateListeners) fn(evt.payload);
});
const uJoined = await listen<NativeParticipantEvent>('livekit:participant_joined', (evt) => {
for (const fn of this.joinListeners) fn(evt.payload);
});
const uLeft = await listen<NativeParticipantEvent>('livekit:participant_left', (evt) => {
for (const fn of this.leaveListeners) fn(evt.payload);
});
const uData = await listen<NativeDataEvent>('livekit:data_received', (evt) => {
for (const fn of this.dataListeners) fn(evt.payload);
});
this.unlistens.push(uState, uJoined, uLeft, uData);
}
private async teardown(): Promise<void> {
for (const unlisten of this.unlistens) {
try {
unlisten();
} catch {
/* unlistens become no-op after first call */
}
}
this.unlistens = [];
this.stateListeners.clear();
this.joinListeners.clear();
this.leaveListeners.clear();
this.dataListeners.clear();
}
}
function bytesToB64(bytes: Uint8Array): string {
let s = '';
for (const b of bytes) s += String.fromCharCode(b);
return btoa(s);
}