48ac9d2922
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
133 lines
4.2 KiB
TypeScript
133 lines
4.2 KiB
TypeScript
// 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);
|
|
}
|