diff --git a/.changeset/data_streams_v2.md b/.changeset/data_streams_v2.md new file mode 100644 index 000000000..48e58599d --- /dev/null +++ b/.changeset/data_streams_v2.md @@ -0,0 +1,10 @@ +--- +livekit: patch +livekit-api: patch +livekit-datatrack: patch +livekit-ffi: patch +livekit-protocol: patch +livekit-uniffi: patch +--- + +Add data streams v2 - #1192 (@1egoman) diff --git a/Cargo.lock b/Cargo.lock index 9e716b92d..6cb80528b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3824,18 +3824,21 @@ dependencies = [ "bmrng", "bytes", "chrono", + "flate2", "futures-util", "http 1.4.0", "lazy_static", "libloading 0.8.9", "libwebrtc", "livekit-api", + "livekit-common", "livekit-datatrack", "livekit-protocol", "livekit-runtime", "log", "parking_lot", "prost 0.12.6", + "rand 0.9.3", "semver", "serde", "serde_json", @@ -3861,6 +3864,7 @@ dependencies = [ "http 1.4.0", "isahc", "jsonwebtoken", + "livekit-common", "livekit-protocol", "livekit-runtime", "log", @@ -3883,6 +3887,32 @@ dependencies = [ "url", ] +[[package]] +name = "livekit-common" +version = "0.1.0" +dependencies = [ + "livekit-protocol", +] + +[[package]] +name = "livekit-data-stream" +version = "0.1.0" +dependencies = [ + "bmrng", + "bytes", + "chrono", + "flate2", + "futures-util", + "livekit-common", + "livekit-protocol", + "log", + "parking_lot", + "prost 0.12.6", + "thiserror 2.0.18", + "tokio", + "uuid", +] + [[package]] name = "livekit-datatrack" version = "0.1.9" diff --git a/Cargo.toml b/Cargo.toml index c54b1d920..c0db1c077 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "livekit", "livekit-api", "livekit-protocol", + "livekit-common", "livekit-ffi", "livekit-uniffi", "livekit-datatrack", @@ -51,6 +52,7 @@ livekit = { version = "0.7.49", path = "livekit" } livekit-api = { version = "0.5.4", path = "livekit-api" } livekit-ffi = { version = "0.12.67", path = "livekit-ffi" } livekit-datatrack = { version = "0.1.9", path = "livekit-datatrack" } +livekit-common = { version = "0.1.0", path = "livekit-common" } livekit-protocol = { version = "0.7.9", path = "livekit-protocol" } livekit-runtime = { version = "0.4.0", path = "livekit-runtime" } soxr-sys = { version = "0.1.3", path = "soxr-sys" } diff --git a/examples/wgpu_room/src/app.rs b/examples/wgpu_room/src/app.rs index 65c03ab3a..885db4980 100644 --- a/examples/wgpu_room/src/app.rs +++ b/examples/wgpu_room/src/app.rs @@ -1,4 +1,5 @@ use crate::{ + data_streams_ui::DataStreamsUiState, data_track::{LocalDataTrackTile, RemoteDataTrackTile, MAX_VALUE, TIME_WINDOW}, rpc_ui::RpcUiState, service::{AsyncCmd, LkService, UiCmd}, @@ -25,6 +26,7 @@ struct AppState { enum RightTab { Participants, Rpc, + DataStreams, } pub struct LkApp { @@ -38,6 +40,7 @@ pub struct LkApp { render_state: egui_wgpu::RenderState, service: LkService, rpc_ui: RpcUiState, + data_streams_ui: DataStreamsUiState, right_tab: RightTab, } @@ -74,6 +77,7 @@ impl LkApp { connection_failure: None, render_state: cc.wgpu_render_state.clone().unwrap(), rpc_ui: RpcUiState::default(), + data_streams_ui: DataStreamsUiState::default(), right_tab: RightTab::Participants, } } @@ -130,11 +134,28 @@ impl LkApp { self.remote_data_tracks .push(RemoteDataTrackTile::new(self.async_runtime.handle(), track)); } + RoomEvent::TextStreamOpened { reader, topic, participant_identity } => { + self.data_streams_ui.on_text_stream( + reader, + topic, + participant_identity, + &self.service, + ); + } + RoomEvent::ByteStreamOpened { reader, topic, participant_identity } => { + self.data_streams_ui.on_byte_stream( + reader, + topic, + participant_identity, + &self.service, + ); + } RoomEvent::Disconnected { reason: _ } => { self.video_renderers.clear(); self.local_data_tracks.clear(); self.remote_data_tracks.clear(); self.rpc_ui.on_disconnect(); + self.data_streams_ui.on_disconnect(); } _ => {} } @@ -264,6 +285,7 @@ impl LkApp { ui.horizontal(|ui| { ui.selectable_value(&mut self.right_tab, RightTab::Participants, "Participants"); ui.selectable_value(&mut self.right_tab, RightTab::Rpc, "RPC"); + ui.selectable_value(&mut self.right_tab, RightTab::DataStreams, "Data Streams"); }); ui.separator(); @@ -281,6 +303,13 @@ impl LkApp { rpc_ui.show(ui, service, &room); }); } + RightTab::DataStreams => { + let service = &self.service; + let data_streams_ui = &mut self.data_streams_ui; + egui::ScrollArea::vertical().show(ui, |ui| { + data_streams_ui.show(ui, service, &room); + }); + } } } @@ -300,6 +329,15 @@ impl LkApp { sorted_tracks.sort_by(|a, b| a.as_str().cmp(b.as_str())); ui.monospace(&participant.identity().0); + ui.label(format!("Client protocol: {}", participant.client_protocol())); + let caps = participant.capabilities(); + let caps_str = if caps.is_empty() { + "(none)".to_string() + } else { + caps.iter().map(|c| format!("{:?}", c)).collect::>().join(", ") + }; + ui.label(format!("Capabilities: {}", caps_str)); + for tsid in sorted_tracks { let publication = tracks.get(&tsid).unwrap().clone(); diff --git a/examples/wgpu_room/src/data_streams_ui.rs b/examples/wgpu_room/src/data_streams_ui.rs new file mode 100644 index 000000000..393da5562 --- /dev/null +++ b/examples/wgpu_room/src/data_streams_ui.rs @@ -0,0 +1,511 @@ +use crate::service::LkService; +use egui::Color32; +use livekit::prelude::*; +use livekit::{ + ByteStreamReader, StreamByteOptions, StreamReader, StreamTextOptions, TakeCell, + TextStreamReader, +}; +use parking_lot::Mutex; +use std::collections::{BTreeMap, VecDeque}; +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +const MAX_RECEIVED: usize = 100; +const PREVIEW_CHARS: usize = 256; +const PREVIEW_BYTES: usize = 64; + +/// Whether a stream carries text or raw bytes. Used for both sending and subscribing. +#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] +pub enum StreamKind { + Text, + Bytes, +} + +impl StreamKind { + fn label(self) -> &'static str { + match self { + StreamKind::Text => "text", + StreamKind::Bytes => "bytes", + } + } +} + +/// Outcome of the most recent send, shared with the spawned send task. +#[derive(Clone)] +enum SendState { + Idle, + Sending, + Ok(String), + Err(String), +} + +/// A single received stream, rendered as a card. +struct ReceivedStream { + n: u64, + sender: String, + received_at: SystemTime, + size: usize, + compressed: bool, + inline: bool, + preview: String, +} + +/// Accumulates streams received for one (topic, kind) subscription. +struct TopicEntry { + topic: String, + kind: StreamKind, + received: VecDeque, + count: u64, +} + +pub struct DataStreamsUiState { + // Send section + send_kind: StreamKind, + send_topic: String, + send_destination: Option, + send_content: String, + send_hex: bool, + send_state: Arc>, + + // Subscribe section + sub_topic: String, + sub_kind: StreamKind, + sub_error: Option, + subscriptions: BTreeMap<(String, StreamKind), Arc>>, +} + +impl Default for DataStreamsUiState { + fn default() -> Self { + Self { + send_kind: StreamKind::Text, + send_topic: String::new(), + send_destination: None, + send_content: String::new(), + send_hex: false, + send_state: Arc::new(Mutex::new(SendState::Idle)), + sub_topic: String::new(), + sub_kind: StreamKind::Text, + sub_error: None, + subscriptions: BTreeMap::new(), + } + } +} + +impl DataStreamsUiState { + pub fn on_disconnect(&mut self) { + // Subscriptions are pure UI filters (no room registration), so we keep them but clear + // the streams received during the previous session. + for entry in self.subscriptions.values() { + let mut g = entry.lock(); + g.received.clear(); + g.count = 0; + } + *self.send_state.lock() = SendState::Idle; + self.send_destination = None; + } + + /// Routes an incoming text stream to a matching subscription (if any), reading it in the + /// background. Unmatched streams are dropped (the reader is never taken). + pub fn on_text_stream( + &mut self, + reader: TakeCell, + topic: String, + identity: ParticipantIdentity, + service: &LkService, + ) { + let Some(entry) = self.subscriptions.get(&(topic, StreamKind::Text)).cloned() else { + return; + }; + let Some(reader) = reader.take() else { + return; + }; + service.runtime().spawn(async move { + let compressed = false; // FIXME: remove this reader.info().compressed; + let inline = false; // FIXME: remove this reader.info().inline; + let (size, preview) = match reader.read_all().await { + Ok(text) => (text.as_bytes().len(), truncate_chars(&text, PREVIEW_CHARS)), + Err(e) => (0, format!("", e)), + }; + push_received(&entry, identity.as_str(), size, compressed, inline, preview); + }); + } + + /// Routes an incoming byte stream to a matching subscription (if any). + pub fn on_byte_stream( + &mut self, + reader: TakeCell, + topic: String, + identity: ParticipantIdentity, + service: &LkService, + ) { + let Some(entry) = self.subscriptions.get(&(topic, StreamKind::Bytes)).cloned() else { + return; + }; + let Some(reader) = reader.take() else { + return; + }; + service.runtime().spawn(async move { + let compressed = false; // FIXME: remove this reader.info().compressed; + let inline = false; // FIXME: remove this reader.info().inline; + let (size, preview) = match reader.read_all().await { + Ok(data) => (data.len(), bytes_preview(data.as_ref())), + Err(e) => (0, format!("", e)), + }; + push_received(&entry, identity.as_str(), size, compressed, inline, preview); + }); + } + + pub fn show(&mut self, ui: &mut egui::Ui, service: &LkService, room: &Arc) { + self.show_send(ui, service, room); + ui.add_space(8.0); + ui.separator(); + self.show_subscribe(ui); + self.show_subscription_cards(ui); + } + + fn show_send(&mut self, ui: &mut egui::Ui, service: &LkService, room: &Arc) { + ui.label(egui::RichText::new("Send Data Stream").strong()); + + ui.horizontal(|ui| { + ui.label("Kind:"); + ui.radio_value(&mut self.send_kind, StreamKind::Text, "Text"); + ui.radio_value(&mut self.send_kind, StreamKind::Bytes, "Bytes"); + }); + + ui.horizontal(|ui| { + ui.label("Topic:"); + ui.add(egui::TextEdit::singleline(&mut self.send_topic).desired_width(f32::INFINITY)); + }); + + // Destination picker: broadcast (None) or a specific remote participant. + let participants = room.remote_participants(); + let mut idents: Vec = participants.keys().cloned().collect(); + idents.sort_by(|a, b| a.as_str().cmp(b.as_str())); + if let Some(sel) = self.send_destination.as_ref() { + if !participants.contains_key(sel) { + self.send_destination = None; + } + } + ui.horizontal(|ui| { + ui.label("To:"); + let combo_label = self + .send_destination + .as_ref() + .map(|i| i.as_str().to_string()) + .unwrap_or_else(|| "Everyone (broadcast)".to_string()); + egui::ComboBox::from_id_salt("ds_dest_combo").selected_text(combo_label).show_ui( + ui, + |ui| { + ui.selectable_value(&mut self.send_destination, None, "Everyone (broadcast)"); + for ident in &idents { + ui.selectable_value( + &mut self.send_destination, + Some(ident.clone()), + ident.as_str(), + ); + } + }, + ); + }); + + ui.horizontal(|ui| { + ui.label("Content:"); + if self.send_kind == StreamKind::Text { + if ui.small_button("Hello").clicked() { + self.send_content = "Hello world".to_string(); + } + if ui.small_button("20k").clicked() { + self.send_content = "X".repeat(20_000); + } + } else { + ui.checkbox(&mut self.send_hex, "Hex"); + } + }); + let max_h = ui.text_style_height(&egui::TextStyle::Body) * 5.0 + 8.0; + egui::ScrollArea::vertical().id_salt("ds_send_content_scroll").max_height(max_h).show( + ui, + |ui| { + ui.add( + egui::TextEdit::multiline(&mut self.send_content) + .desired_rows(2) + .desired_width(f32::INFINITY), + ); + }, + ); + + let sending = matches!(&*self.send_state.lock(), SendState::Sending); + let can_send = !sending && !self.send_topic.trim().is_empty(); + + ui.horizontal(|ui| { + ui.add_enabled_ui(can_send, |ui| { + if ui.button("Send").clicked() { + self.dispatch_send(service, room); + } + }); + if sending { + ui.spinner(); + } + }); + + match &*self.send_state.lock() { + SendState::Sending => { + ui.colored_label(Color32::GRAY, "Sending..."); + } + SendState::Ok(s) => { + ui.colored_label(Color32::LIGHT_GREEN, format!("OK: {}", s)); + } + SendState::Err(e) => { + ui.colored_label(Color32::LIGHT_RED, format!("Error: {}", e)); + } + SendState::Idle => {} + } + } + + fn dispatch_send(&mut self, service: &LkService, room: &Arc) { + let topic = self.send_topic.trim().to_string(); + let destination_identities = + self.send_destination.as_ref().map(|i| vec![i.clone()]).unwrap_or_default(); + let local = room.local_participant(); + let state = self.send_state.clone(); + + match self.send_kind { + StreamKind::Text => { + let text = self.send_content.clone(); + let options = + StreamTextOptions { topic, destination_identities, ..Default::default() }; + *state.lock() = SendState::Sending; + service.runtime().spawn(async move { + let result = local.send_text(&text, options).await; + *state.lock() = match result { + Ok(info) => SendState::Ok(format!("stream {}", short_id(&info.id))), + Err(e) => SendState::Err(e.to_string()), + }; + }); + } + StreamKind::Bytes => { + let bytes = if self.send_hex { + match parse_hex(&self.send_content) { + Ok(b) => b, + Err(e) => { + *state.lock() = SendState::Err(format!("invalid hex: {}", e)); + return; + } + } + } else { + self.send_content.as_bytes().to_vec() + }; + let options = + StreamByteOptions { topic, destination_identities, ..Default::default() }; + *state.lock() = SendState::Sending; + service.runtime().spawn(async move { + let result = local.send_bytes(bytes, options).await; + *state.lock() = match result { + Ok(info) => SendState::Ok(format!("stream {}", short_id(&info.id))), + Err(e) => SendState::Err(e.to_string()), + }; + }); + } + } + } + + fn show_subscribe(&mut self, ui: &mut egui::Ui) { + ui.label(egui::RichText::new("Subscriptions").strong()); + + let mut do_add = false; + ui.horizontal(|ui| { + ui.label("Topic:"); + let resp = ui.add(egui::TextEdit::singleline(&mut self.sub_topic).desired_width(120.0)); + if resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + do_add = true; + } + ui.radio_value(&mut self.sub_kind, StreamKind::Text, "Text"); + ui.radio_value(&mut self.sub_kind, StreamKind::Bytes, "Bytes"); + if ui.button("Add").clicked() { + do_add = true; + } + }); + + if do_add { + self.sub_error = None; + let topic = self.sub_topic.trim().to_string(); + let key = (topic.clone(), self.sub_kind); + if topic.is_empty() { + self.sub_error = Some("Topic is empty".to_string()); + } else if self.subscriptions.contains_key(&key) { + self.sub_error = + Some(format!("Already subscribed to '{}' ({})", topic, self.sub_kind.label())); + } else { + self.subscriptions.insert( + key, + Arc::new(Mutex::new(TopicEntry { + topic, + kind: self.sub_kind, + received: VecDeque::new(), + count: 0, + })), + ); + self.sub_topic.clear(); + } + } + + if let Some(err) = &self.sub_error { + ui.colored_label(Color32::LIGHT_RED, err); + } + } + + fn show_subscription_cards(&mut self, ui: &mut egui::Ui) { + let keys: Vec<(String, StreamKind)> = self.subscriptions.keys().cloned().collect(); + let mut to_remove: Option<(String, StreamKind)> = None; + + for key in keys { + let entry = self.subscriptions.get(&key).unwrap().clone(); + ui.add_space(6.0); + egui::Frame::group(ui.style()).show(ui, |ui| { + let guard = entry.lock(); + ui.horizontal(|ui| { + ui.monospace(egui::RichText::new(&guard.topic).strong()); + ui.label( + egui::RichText::new(format!("[{}]", guard.kind.label())) + .small() + .color(Color32::GRAY), + ); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + if ui.button("Remove").clicked() { + to_remove = Some(key.clone()); + } + }); + }); + + ui.label(format!("Received ({})", guard.count)); + if guard.received.is_empty() { + ui.colored_label(Color32::GRAY, "Nothing received yet"); + } else { + let max_h = ui.text_style_height(&egui::TextStyle::Body) * 10.0 + 8.0; + egui::ScrollArea::vertical() + .id_salt(format!("ds_recv_scroll_{}_{}", guard.topic, guard.kind.label())) + .max_height(max_h) + .show(ui, |ui| { + for r in guard.received.iter() { + let meta = format!( + "#{} | {} | {} | {}", + r.n, + r.sender, + format_size(r.size), + format_ts(r.received_at), + ); + ui.add(egui::Label::new( + egui::RichText::new(meta).small().color(Color32::GRAY), + )); + ui.horizontal(|ui| { + flag_label(ui, "inline", r.inline); + flag_label(ui, "compressed", r.compressed); + }); + ui.add(egui::Label::new( + egui::RichText::new(&r.preview).monospace(), + )); + ui.separator(); + } + }); + } + }); + } + + if let Some(key) = to_remove { + self.subscriptions.remove(&key); + } + } +} + +fn push_received( + entry: &Arc>, + sender: &str, + size: usize, + compressed: bool, + inline: bool, + preview: String, +) { + let mut g = entry.lock(); + g.count += 1; + let n = g.count; + g.received.push_back(ReceivedStream { + n, + sender: sender.to_string(), + received_at: SystemTime::now(), + size, + compressed, + inline, + preview, + }); + while g.received.len() > MAX_RECEIVED { + g.received.pop_front(); + } +} + +/// Parses a hex string into bytes, ignoring whitespace, commas and colons. +fn parse_hex(s: &str) -> Result, String> { + let cleaned: String = + s.chars().filter(|c| !c.is_whitespace() && *c != ',' && *c != ':').collect(); + if cleaned.len() % 2 != 0 { + return Err("odd number of hex digits".to_string()); + } + let mut out = Vec::with_capacity(cleaned.len() / 2); + let bytes = cleaned.as_bytes(); + let mut i = 0; + while i < bytes.len() { + let pair = &cleaned[i..i + 2]; + let byte = + u8::from_str_radix(pair, 16).map_err(|_| format!("invalid hex byte '{}'", pair))?; + out.push(byte); + i += 2; + } + Ok(out) +} + +/// Renders `: ✓` (green) or `: ✗` (red) for a boolean v2 flag. +fn flag_label(ui: &mut egui::Ui, name: &str, value: bool) { + let (mark, color) = + if value { ("✓", Color32::LIGHT_GREEN) } else { ("✗", Color32::LIGHT_RED) }; + ui.add(egui::Label::new( + egui::RichText::new(format!("{}: {}", name, mark)).small().color(color), + )); +} + +fn bytes_preview(data: &[u8]) -> String { + let shown = &data[..data.len().min(PREVIEW_BYTES)]; + let hex: String = shown.iter().map(|b| format!("{:02x} ", b)).collect(); + let ellipsis = if data.len() > PREVIEW_BYTES { "..." } else { "" }; + let text = String::from_utf8_lossy(shown); + format!("hex: {}{}\nutf8: {}{}", hex.trim_end(), ellipsis, text, ellipsis) +} + +fn truncate_chars(s: &str, max_chars: usize) -> String { + let mut iter = s.chars(); + let head: String = iter.by_ref().take(max_chars).collect(); + if iter.next().is_some() { + format!("{}...", head) + } else { + head + } +} + +fn short_id(id: &str) -> String { + id.chars().take(8).collect() +} + +fn format_size(bytes: usize) -> String { + if bytes < 1024 { + format!("{}B", bytes) + } else { + format!("{:.2}KB", bytes as f64 / 1024.0) + } +} + +fn format_ts(ts: SystemTime) -> String { + let d = ts.duration_since(UNIX_EPOCH).unwrap_or_default(); + let total = d.as_secs(); + let h = (total / 3600) % 24; + let m = (total / 60) % 60; + let s = total % 60; + let ms = d.subsec_millis(); + format!("{:02}:{:02}:{:02}.{:03}Z", h, m, s, ms) +} diff --git a/examples/wgpu_room/src/main.rs b/examples/wgpu_room/src/main.rs index 2d6e8c60e..cd8c3a46f 100644 --- a/examples/wgpu_room/src/main.rs +++ b/examples/wgpu_room/src/main.rs @@ -5,6 +5,7 @@ use std::thread; use std::time::Duration; mod app; +mod data_streams_ui; mod data_track; mod logo_track; mod rpc_ui; diff --git a/livekit-api/Cargo.toml b/livekit-api/Cargo.toml index b4253e91c..2908e5c37 100644 --- a/livekit-api/Cargo.toml +++ b/livekit-api/Cargo.toml @@ -101,6 +101,7 @@ __rustls-tls = ["tokio-tungstenite?/__rustls-tls", "reqwest?/__rustls"] [dependencies] livekit-protocol = { workspace = true } +livekit-common = { workspace = true } thiserror = { workspace = true } serde = { workspace = true, features = ["derive"] } sha2 = "0.10" diff --git a/livekit-api/src/signal_client/mod.rs b/livekit-api/src/signal_client/mod.rs index e2448d1d1..a9738aca2 100644 --- a/livekit-api/src/signal_client/mod.rs +++ b/livekit-api/src/signal_client/mod.rs @@ -57,18 +57,24 @@ const VALIDATE_TIMEOUT: Duration = Duration::from_secs(3); pub const PROTOCOL_VERSION: u32 = 17; /// Capabilities the Rust SDK advertises to the SFU at connect time. -const CLIENT_CAPABILITIES: &[proto::client_info::Capability] = - &[proto::client_info::Capability::CapPacketTrailer]; - -/// Default value for `ClientInfo.client_protocol` when a participant has not -/// advertised one (treat as v1-only / no data-stream RPC support). -pub const CLIENT_PROTOCOL_DEFAULT: i32 = 0; -/// `ClientInfo.client_protocol` value indicating support for RPC v2 over data streams. -pub const CLIENT_PROTOCOL_DATA_STREAM_RPC: i32 = 1; +/// +/// `CapCompressionDeflateRaw` is always advertised because the SDK's deflate-raw codec +/// (flate2/miniz_oxide) is pure-Rust and compiled in unconditionally. +const CLIENT_CAPABILITIES: &[proto::client_info::Capability] = &[ + proto::client_info::Capability::CapPacketTrailer, + proto::client_info::Capability::CapCompressionDeflateRaw, +]; + +// The canonical `client_protocol` constants live in `livekit-common` (shared with the +// data-stream crate); re-exported here so existing `livekit_api::signal_client::CLIENT_PROTOCOL_*` +// references keep resolving. +pub use livekit_common::{ + CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DATA_STREAM_V2, CLIENT_PROTOCOL_DEFAULT, +}; /// The client protocol which is sent to other clients and indicates the set of apis that other /// clients should assume this client supports. -const CLIENT_PROTOCOL_VERSION: i32 = CLIENT_PROTOCOL_DATA_STREAM_RPC; +const CLIENT_PROTOCOL_VERSION: i32 = CLIENT_PROTOCOL_DATA_STREAM_V2; #[derive(Error, Debug)] pub enum SignalError { diff --git a/livekit-common/Cargo.toml b/livekit-common/Cargo.toml new file mode 100644 index 000000000..c3ec0491d --- /dev/null +++ b/livekit-common/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "livekit-common" +description = "Common foundational types shared across LiveKit crates" +version = "0.1.0" +readme = "README.md" +license.workspace = true +edition.workspace = true +repository.workspace = true + +[dependencies] +livekit-protocol = { workspace = true } diff --git a/livekit-common/README.md b/livekit-common/README.md new file mode 100644 index 000000000..17f6cf712 --- /dev/null +++ b/livekit-common/README.md @@ -0,0 +1,8 @@ +# LiveKit Common + +**Important**: +This is an internal crate holding foundational types shared across the LiveKit client SDK crates +(identities, encryption/capability enums, client-protocol constants, and the remote-participant +registry trait). It is not intended to be used directly. + +To build applications with LiveKit, please use the public APIs provided by the client SDKs. diff --git a/livekit-common/src/lib.rs b/livekit-common/src/lib.rs new file mode 100644 index 000000000..d2c1ac47a --- /dev/null +++ b/livekit-common/src/lib.rs @@ -0,0 +1,177 @@ +// Copyright 2026 LiveKit, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +//! Foundational types shared across LiveKit crates: participant identities, the +//! encryption/capability enums, client-protocol constants, and the remote-participant +//! registry trait consulted by the data-stream and RPC send paths. + +use std::fmt::Display; + +use livekit_protocol as proto; + +// ------------------------------------------------------------------------------------------------- +// Client protocol +// ------------------------------------------------------------------------------------------------- + +/// Legacy client. No v2 data-stream features. +pub const CLIENT_PROTOCOL_DEFAULT: i32 = 0; + +/// RPC v2 (see RPC spec). No v2 data-stream features. +pub const CLIENT_PROTOCOL_DATA_STREAM_RPC: i32 = 1; + +/// Understands inline single-packet data streams (data streams v2). +pub const CLIENT_PROTOCOL_DATA_STREAM_V2: i32 = 2; + +// ------------------------------------------------------------------------------------------------- +// ParticipantIdentity +// ------------------------------------------------------------------------------------------------- + +#[derive(Clone, Default, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] +pub struct ParticipantIdentity(pub String); + +impl From for ParticipantIdentity { + fn from(value: String) -> Self { + Self(value) + } +} + +impl From<&str> for ParticipantIdentity { + fn from(value: &str) -> Self { + Self(value.to_string()) + } +} + +impl From for String { + fn from(value: ParticipantIdentity) -> Self { + value.0 + } +} + +impl Display for ParticipantIdentity { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{}", self.0) + } +} + +impl ParticipantIdentity { + pub fn as_str(&self) -> &str { + &self.0 + } +} + +// ------------------------------------------------------------------------------------------------- +// EncryptionType +// ------------------------------------------------------------------------------------------------- + +#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] +pub enum EncryptionType { + #[default] + None, + Gcm, + Custom, +} + +impl From for EncryptionType { + fn from(value: proto::encryption::Type) -> Self { + match value { + proto::encryption::Type::None => Self::None, + proto::encryption::Type::Gcm => Self::Gcm, + proto::encryption::Type::Custom => Self::Custom, + } + } +} + +impl From for proto::encryption::Type { + fn from(value: EncryptionType) -> Self { + match value { + EncryptionType::None => Self::None, + EncryptionType::Gcm => Self::Gcm, + EncryptionType::Custom => Self::Custom, + } + } +} + +impl From for i32 { + fn from(value: EncryptionType) -> Self { + match value { + EncryptionType::None => 0, + EncryptionType::Gcm => 1, + EncryptionType::Custom => 2, + } + } +} + +// ------------------------------------------------------------------------------------------------- +// ClientCapability +// ------------------------------------------------------------------------------------------------- + +/// A capability a participant's client advertises (mirrors `ClientInfo.Capability`). +/// +/// Stored typed rather than as the raw protobuf `i32` so accessors don't leak protobuf types. +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +#[non_exhaustive] +pub enum ClientCapability { + Unused, + PacketTrailer, + CompressionDeflateRaw, +} + +impl TryFrom for ClientCapability { + type Error = &'static str; + + fn try_from(value: i32) -> Result { + match proto::client_info::Capability::try_from(value) { + Ok(proto::client_info::Capability::CapPacketTrailer) => Ok(Self::PacketTrailer), + Ok(proto::client_info::Capability::CapCompressionDeflateRaw) => { + Ok(Self::CompressionDeflateRaw) + } + Ok(proto::client_info::Capability::CapUnused) => Ok(Self::Unused), + Err(_) => Err("unknown client capability"), + } + } +} + +impl From for i32 { + fn from(value: ClientCapability) -> Self { + match value { + ClientCapability::Unused => proto::client_info::Capability::CapUnused as i32, + ClientCapability::PacketTrailer => { + proto::client_info::Capability::CapPacketTrailer as i32 + } + ClientCapability::CompressionDeflateRaw => { + proto::client_info::Capability::CapCompressionDeflateRaw as i32 + } + } + } +} + +// ------------------------------------------------------------------------------------------------- +// RemoteParticipantRegistry +// ------------------------------------------------------------------------------------------------- + +/// Read access to remote participants' advertised protocol and capabilities. +/// +/// Shared by the RPC transport (v1/v2 transport selection) and the data-stream send +/// path (inline / compression eligibility), so both consult a single abstraction over +/// the room's remote participants and both are unit-testable with a fake. +pub trait RemoteParticipantRegistry: Send + Sync { + /// A remote participant's `client_protocol`, or `CLIENT_PROTOCOL_DEFAULT` (0) if unknown. + fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32; + + /// A remote participant's advertised capabilities, or empty if unknown. + fn remote_capabilities(&self, identity: &ParticipantIdentity) -> Vec; + + /// The identities of every remote participant, used to resolve a broadcast send. + fn remote_identities(&self) -> Vec; +} diff --git a/livekit-ffi-node-bindings/proto/data_stream_pb.d.ts b/livekit-ffi-node-bindings/proto/data_stream_pb.d.ts index 6c7628f37..a800395d6 100644 --- a/livekit-ffi-node-bindings/proto/data_stream_pb.d.ts +++ b/livekit-ffi-node-bindings/proto/data_stream_pb.d.ts @@ -1841,6 +1841,11 @@ export declare class StreamTextOptions extends Message { */ generated?: boolean; + /** + * @generated from field: optional bool compress = 10; + */ + compress?: boolean; + constructor(data?: PartialMessage); static readonly runtime: typeof proto2; @@ -1895,6 +1900,11 @@ export declare class StreamByteOptions extends Message { */ totalLength?: bigint; + /** + * @generated from field: optional bool compress = 8; + */ + compress?: boolean; + constructor(data?: PartialMessage); static readonly runtime: typeof proto2; diff --git a/livekit-ffi-node-bindings/proto/data_stream_pb.js b/livekit-ffi-node-bindings/proto/data_stream_pb.js index cd503c137..6b45dcaad 100644 --- a/livekit-ffi-node-bindings/proto/data_stream_pb.js +++ b/livekit-ffi-node-bindings/proto/data_stream_pb.js @@ -674,6 +674,7 @@ const StreamTextOptions = /*@__PURE__*/ proto2.makeMessageType( { no: 7, name: "reply_to_stream_id", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, { no: 8, name: "attached_stream_ids", kind: "scalar", T: 9 /* ScalarType.STRING */, repeated: true }, { no: 9, name: "generated", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, + { no: 10, name: "compress", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, ], ); @@ -690,6 +691,7 @@ const StreamByteOptions = /*@__PURE__*/ proto2.makeMessageType( { no: 5, name: "name", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, { no: 6, name: "mime_type", kind: "scalar", T: 9 /* ScalarType.STRING */, opt: true }, { no: 7, name: "total_length", kind: "scalar", T: 4 /* ScalarType.UINT64 */, opt: true }, + { no: 8, name: "compress", kind: "scalar", T: 8 /* ScalarType.BOOL */, opt: true }, ], ); diff --git a/livekit-ffi/protocol/data_stream.proto b/livekit-ffi/protocol/data_stream.proto index 3f9fd590f..83c2ab5be 100644 --- a/livekit-ffi/protocol/data_stream.proto +++ b/livekit-ffi/protocol/data_stream.proto @@ -371,7 +371,7 @@ message StreamTextOptions { optional string reply_to_stream_id = 7; repeated string attached_stream_ids = 8; optional bool generated = 9; - + optional bool compress = 10; } message StreamByteOptions { required string topic = 1; @@ -381,6 +381,7 @@ message StreamByteOptions { optional string name = 5; optional string mime_type = 6; optional uint64 total_length = 7; + optional bool compress = 8; } // Error pertaining to a stream. diff --git a/livekit-ffi/src/conversion/data_stream.rs b/livekit-ffi/src/conversion/data_stream.rs index a59da350b..140bc74e4 100644 --- a/livekit-ffi/src/conversion/data_stream.rs +++ b/livekit-ffi/src/conversion/data_stream.rs @@ -72,6 +72,7 @@ impl From for StreamTextOptions { reply_to_stream_id: options.reply_to_stream_id, attached_stream_ids: options.attached_stream_ids, generated: options.generated, + compress: options.compress, } } } @@ -90,6 +91,7 @@ impl From for StreamByteOptions { name: options.name, mime_type: options.mime_type, total_length: options.total_length, + compress: options.compress, } } } diff --git a/livekit-protocol/protocol b/livekit-protocol/protocol index df0314e18..39fc751df 160000 --- a/livekit-protocol/protocol +++ b/livekit-protocol/protocol @@ -1 +1 @@ -Subproject commit df0314e189f0ab695005c5edc10f087b5a36ad23 +Subproject commit 39fc751df610243c1bfdf85d3be6b3928ecef014 diff --git a/livekit-protocol/src/livekit.rs b/livekit-protocol/src/livekit.rs index 1af6a6e0e..ac2408d86 100644 --- a/livekit-protocol/src/livekit.rs +++ b/livekit-protocol/src/livekit.rs @@ -653,6 +653,36 @@ pub struct DataTrackSubscriptionOptions { #[prost(uint32, optional, tag="1")] pub target_fps: ::core::option::Option, } +/// Key used to uniquely identify a data blob for storage and retrieval. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DataBlobKey { + #[prost(oneof="data_blob_key::Key", tags="1")] + pub key: ::core::option::Option, +} +/// Nested message and enum types in `DataBlobKey`. +pub mod data_blob_key { + #[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Oneof)] + pub enum Key { + /// Generic string key, blob contains arbitrary data. + /// + /// Add additional key types here for storing specific types of blobs. + #[prost(string, tag="1")] + Generic(::prost::alloc::string::String), + } +} +/// A blob of data stored in a room identified by a unique key. +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct DataBlob { + /// Unique key the data blob is identified by. + #[prost(message, optional, tag="1")] + pub key: ::core::option::Option, + /// Contents of the data blob. This must not exceed 50 KB. + #[prost(bytes="vec", tag="2")] + pub contents: ::prost::alloc::vec::Vec, +} /// provide information about available spatial layers #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] @@ -3551,7 +3581,7 @@ impl AudioMixing { #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SignalRequest { - #[prost(oneof="signal_request::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21")] + #[prost(oneof="signal_request::Message", tags="1, 2, 3, 4, 5, 6, 7, 8, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23")] pub message: ::core::option::Option, } /// Nested message and enum types in `SignalRequest`. @@ -3619,12 +3649,18 @@ pub mod signal_request { /// Update subscription state for one or more data tracks #[prost(message, tag="21")] UpdateDataSubscription(super::UpdateDataSubscription), + /// Store a data blob. + #[prost(message, tag="22")] + StoreDataBlobRequest(super::StoreDataBlobRequest), + /// Retrieve a stored data blob. + #[prost(message, tag="23")] + GetDataBlobRequest(super::GetDataBlobRequest), } } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] pub struct SignalResponse { - #[prost(oneof="signal_response::Message", tags="1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29")] + #[prost(oneof="signal_response::Message", tags="1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31")] pub message: ::core::option::Option, } /// Nested message and enum types in `SignalResponse`. @@ -3719,6 +3755,12 @@ pub mod signal_response { /// Sent to data track subscribers to provide mapping from track SIDs to handles. #[prost(message, tag="29")] DataTrackSubscriberHandles(super::DataTrackSubscriberHandles), + /// Sent in response to `StoreDataBlobRequest`. + #[prost(message, tag="30")] + StoreDataBlobResponse(super::StoreDataBlobResponse), + /// Sent in response to `GetDataBlobRequest`. + #[prost(message, tag="31")] + GetDataBlobResponse(super::GetDataBlobResponse), } } #[allow(clippy::derive_partial_eq_without_eq)] @@ -3979,6 +4021,43 @@ pub mod update_data_subscription { } #[allow(clippy::derive_partial_eq_without_eq)] #[derive(Clone, PartialEq, ::prost::Message)] +pub struct StoreDataBlobRequest { + #[prost(uint32, tag="1")] + pub request_id: u32, + #[prost(message, optional, tag="2")] + pub blob: ::core::option::Option, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct StoreDataBlobResponse { + #[prost(uint32, tag="1")] + pub request_id: u32, + /// Unique key the data blob was stored under. + #[prost(message, optional, tag="2")] + pub key: ::core::option::Option, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetDataBlobRequest { + #[prost(uint32, tag="1")] + pub request_id: u32, + /// Identity of the participant who owns the blob. + #[prost(string, tag="2")] + pub participant_identity: ::prost::alloc::string::String, + /// Unique key of the data blob to retrieve. + #[prost(message, optional, tag="3")] + pub key: ::core::option::Option, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] +pub struct GetDataBlobResponse { + #[prost(uint32, tag="1")] + pub request_id: u32, + #[prost(message, optional, tag="2")] + pub blob: ::core::option::Option, +} +#[allow(clippy::derive_partial_eq_without_eq)] +#[derive(Clone, PartialEq, ::prost::Message)] pub struct UpdateTrackSettings { #[prost(string, repeated, tag="1")] pub track_sids: ::prost::alloc::vec::Vec<::prost::alloc::string::String>, @@ -4387,6 +4466,7 @@ pub mod request_response { InvalidName = 8, DuplicateHandle = 9, DuplicateName = 10, + InvalidRequest = 11, } impl Reason { /// String value of the enum field names used in the ProtoBuf definition. @@ -4406,6 +4486,7 @@ pub mod request_response { Reason::InvalidName => "INVALID_NAME", Reason::DuplicateHandle => "DUPLICATE_HANDLE", Reason::DuplicateName => "DUPLICATE_NAME", + Reason::InvalidRequest => "INVALID_REQUEST", } } /// Creates an enum from field names used in the ProtoBuf definition. @@ -4422,6 +4503,7 @@ pub mod request_response { "INVALID_NAME" => Some(Self::InvalidName), "DUPLICATE_HANDLE" => Some(Self::DuplicateHandle), "DUPLICATE_NAME" => Some(Self::DuplicateName), + "INVALID_REQUEST" => Some(Self::InvalidRequest), _ => None, } } diff --git a/livekit-protocol/src/livekit.serde.rs b/livekit-protocol/src/livekit.serde.rs index ebd07f129..fa22db55a 100644 --- a/livekit-protocol/src/livekit.serde.rs +++ b/livekit-protocol/src/livekit.serde.rs @@ -10527,6 +10527,221 @@ impl<'de> serde::Deserialize<'de> for CreateSipTrunkRequest { deserializer.deserialize_struct("livekit.CreateSIPTrunkRequest", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for DataBlob { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.key.is_some() { + len += 1; + } + if !self.contents.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.DataBlob", len)?; + if let Some(v) = self.key.as_ref() { + struct_ser.serialize_field("key", v)?; + } + if !self.contents.is_empty() { + #[allow(clippy::needless_borrow)] + #[allow(clippy::needless_borrows_for_generic_args)] + struct_ser.serialize_field("contents", pbjson::private::base64::encode(&self.contents).as_str())?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for DataBlob { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "key", + "contents", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Key, + Contents, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "key" => Ok(GeneratedField::Key), + "contents" => Ok(GeneratedField::Contents), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = DataBlob; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.DataBlob") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut key__ = None; + let mut contents__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Key => { + if key__.is_some() { + return Err(serde::de::Error::duplicate_field("key")); + } + key__ = map_.next_value()?; + } + GeneratedField::Contents => { + if contents__.is_some() { + return Err(serde::de::Error::duplicate_field("contents")); + } + contents__ = + Some(map_.next_value::<::pbjson::private::BytesDeserialize<_>>()?.0) + ; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(DataBlob { + key: key__, + contents: contents__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("livekit.DataBlob", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for DataBlobKey { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.key.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.DataBlobKey", len)?; + if let Some(v) = self.key.as_ref() { + match v { + data_blob_key::Key::Generic(v) => { + struct_ser.serialize_field("generic", v)?; + } + } + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for DataBlobKey { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "generic", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Generic, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "generic" => Ok(GeneratedField::Generic), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = DataBlobKey; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.DataBlobKey") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut key__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Generic => { + if key__.is_some() { + return Err(serde::de::Error::duplicate_field("generic")); + } + key__ = map_.next_value::<::std::option::Option<_>>()?.map(data_blob_key::Key::Generic); + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(DataBlobKey { + key: key__, + }) + } + } + deserializer.deserialize_struct("livekit.DataBlobKey", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for DataChannelInfo { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result @@ -18515,7 +18730,7 @@ impl<'de> serde::Deserialize<'de> for GcpUpload { deserializer.deserialize_struct("livekit.GCPUpload", FIELDS, GeneratedVisitor) } } -impl serde::Serialize for GetSipInboundTrunkRequest { +impl serde::Serialize for GetDataBlobRequest { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result where @@ -18523,30 +18738,47 @@ impl serde::Serialize for GetSipInboundTrunkRequest { { use serde::ser::SerializeStruct; let mut len = 0; - if !self.sip_trunk_id.is_empty() { + if self.request_id != 0 { len += 1; } - let mut struct_ser = serializer.serialize_struct("livekit.GetSIPInboundTrunkRequest", len)?; - if !self.sip_trunk_id.is_empty() { - struct_ser.serialize_field("sipTrunkId", &self.sip_trunk_id)?; + if !self.participant_identity.is_empty() { + len += 1; + } + if self.key.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.GetDataBlobRequest", len)?; + if self.request_id != 0 { + struct_ser.serialize_field("requestId", &self.request_id)?; + } + if !self.participant_identity.is_empty() { + struct_ser.serialize_field("participantIdentity", &self.participant_identity)?; + } + if let Some(v) = self.key.as_ref() { + struct_ser.serialize_field("key", v)?; } struct_ser.end() } } -impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkRequest { +impl<'de> serde::Deserialize<'de> for GetDataBlobRequest { #[allow(deprecated)] fn deserialize(deserializer: D) -> std::result::Result where D: serde::Deserializer<'de>, { const FIELDS: &[&str] = &[ - "sip_trunk_id", - "sipTrunkId", + "request_id", + "requestId", + "participant_identity", + "participantIdentity", + "key", ]; #[allow(clippy::enum_variant_names)] enum GeneratedField { - SipTrunkId, + RequestId, + ParticipantIdentity, + Key, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -18569,7 +18801,9 @@ impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkRequest { E: serde::de::Error, { match value { - "sipTrunkId" | "sip_trunk_id" => Ok(GeneratedField::SipTrunkId), + "requestId" | "request_id" => Ok(GeneratedField::RequestId), + "participantIdentity" | "participant_identity" => Ok(GeneratedField::ParticipantIdentity), + "key" => Ok(GeneratedField::Key), _ => Ok(GeneratedField::__SkipField__), } } @@ -18579,39 +18813,57 @@ impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkRequest { } struct GeneratedVisitor; impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { - type Value = GetSipInboundTrunkRequest; + type Value = GetDataBlobRequest; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("struct livekit.GetSIPInboundTrunkRequest") + formatter.write_str("struct livekit.GetDataBlobRequest") } - fn visit_map(self, mut map_: V) -> std::result::Result + fn visit_map(self, mut map_: V) -> std::result::Result where V: serde::de::MapAccess<'de>, { - let mut sip_trunk_id__ = None; + let mut request_id__ = None; + let mut participant_identity__ = None; + let mut key__ = None; while let Some(k) = map_.next_key()? { match k { - GeneratedField::SipTrunkId => { - if sip_trunk_id__.is_some() { - return Err(serde::de::Error::duplicate_field("sipTrunkId")); + GeneratedField::RequestId => { + if request_id__.is_some() { + return Err(serde::de::Error::duplicate_field("requestId")); } - sip_trunk_id__ = Some(map_.next_value()?); + request_id__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::ParticipantIdentity => { + if participant_identity__.is_some() { + return Err(serde::de::Error::duplicate_field("participantIdentity")); + } + participant_identity__ = Some(map_.next_value()?); + } + GeneratedField::Key => { + if key__.is_some() { + return Err(serde::de::Error::duplicate_field("key")); + } + key__ = map_.next_value()?; } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } } } - Ok(GetSipInboundTrunkRequest { - sip_trunk_id: sip_trunk_id__.unwrap_or_default(), + Ok(GetDataBlobRequest { + request_id: request_id__.unwrap_or_default(), + participant_identity: participant_identity__.unwrap_or_default(), + key: key__, }) } } - deserializer.deserialize_struct("livekit.GetSIPInboundTrunkRequest", FIELDS, GeneratedVisitor) + deserializer.deserialize_struct("livekit.GetDataBlobRequest", FIELDS, GeneratedVisitor) } } -impl serde::Serialize for GetSipInboundTrunkResponse { +impl serde::Serialize for GetDataBlobResponse { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result where @@ -18619,29 +18871,38 @@ impl serde::Serialize for GetSipInboundTrunkResponse { { use serde::ser::SerializeStruct; let mut len = 0; - if self.trunk.is_some() { + if self.request_id != 0 { len += 1; } - let mut struct_ser = serializer.serialize_struct("livekit.GetSIPInboundTrunkResponse", len)?; - if let Some(v) = self.trunk.as_ref() { - struct_ser.serialize_field("trunk", v)?; + if self.blob.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.GetDataBlobResponse", len)?; + if self.request_id != 0 { + struct_ser.serialize_field("requestId", &self.request_id)?; + } + if let Some(v) = self.blob.as_ref() { + struct_ser.serialize_field("blob", v)?; } struct_ser.end() } } -impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkResponse { +impl<'de> serde::Deserialize<'de> for GetDataBlobResponse { #[allow(deprecated)] fn deserialize(deserializer: D) -> std::result::Result where D: serde::Deserializer<'de>, { const FIELDS: &[&str] = &[ - "trunk", + "request_id", + "requestId", + "blob", ]; #[allow(clippy::enum_variant_names)] enum GeneratedField { - Trunk, + RequestId, + Blob, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -18664,7 +18925,8 @@ impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkResponse { E: serde::de::Error, { match value { - "trunk" => Ok(GeneratedField::Trunk), + "requestId" | "request_id" => Ok(GeneratedField::RequestId), + "blob" => Ok(GeneratedField::Blob), _ => Ok(GeneratedField::__SkipField__), } } @@ -18674,39 +18936,49 @@ impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkResponse { } struct GeneratedVisitor; impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { - type Value = GetSipInboundTrunkResponse; + type Value = GetDataBlobResponse; fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - formatter.write_str("struct livekit.GetSIPInboundTrunkResponse") + formatter.write_str("struct livekit.GetDataBlobResponse") } - fn visit_map(self, mut map_: V) -> std::result::Result + fn visit_map(self, mut map_: V) -> std::result::Result where V: serde::de::MapAccess<'de>, { - let mut trunk__ = None; + let mut request_id__ = None; + let mut blob__ = None; while let Some(k) = map_.next_key()? { match k { - GeneratedField::Trunk => { - if trunk__.is_some() { - return Err(serde::de::Error::duplicate_field("trunk")); + GeneratedField::RequestId => { + if request_id__.is_some() { + return Err(serde::de::Error::duplicate_field("requestId")); } - trunk__ = map_.next_value()?; + request_id__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::Blob => { + if blob__.is_some() { + return Err(serde::de::Error::duplicate_field("blob")); + } + blob__ = map_.next_value()?; } GeneratedField::__SkipField__ => { let _ = map_.next_value::()?; } } } - Ok(GetSipInboundTrunkResponse { - trunk: trunk__, + Ok(GetDataBlobResponse { + request_id: request_id__.unwrap_or_default(), + blob: blob__, }) } } - deserializer.deserialize_struct("livekit.GetSIPInboundTrunkResponse", FIELDS, GeneratedVisitor) + deserializer.deserialize_struct("livekit.GetDataBlobResponse", FIELDS, GeneratedVisitor) } } -impl serde::Serialize for GetSipOutboundTrunkRequest { +impl serde::Serialize for GetSipInboundTrunkRequest { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result where @@ -18717,14 +18989,205 @@ impl serde::Serialize for GetSipOutboundTrunkRequest { if !self.sip_trunk_id.is_empty() { len += 1; } - let mut struct_ser = serializer.serialize_struct("livekit.GetSIPOutboundTrunkRequest", len)?; + let mut struct_ser = serializer.serialize_struct("livekit.GetSIPInboundTrunkRequest", len)?; if !self.sip_trunk_id.is_empty() { struct_ser.serialize_field("sipTrunkId", &self.sip_trunk_id)?; } struct_ser.end() } } -impl<'de> serde::Deserialize<'de> for GetSipOutboundTrunkRequest { +impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkRequest { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "sip_trunk_id", + "sipTrunkId", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + SipTrunkId, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "sipTrunkId" | "sip_trunk_id" => Ok(GeneratedField::SipTrunkId), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GetSipInboundTrunkRequest; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.GetSIPInboundTrunkRequest") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut sip_trunk_id__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::SipTrunkId => { + if sip_trunk_id__.is_some() { + return Err(serde::de::Error::duplicate_field("sipTrunkId")); + } + sip_trunk_id__ = Some(map_.next_value()?); + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(GetSipInboundTrunkRequest { + sip_trunk_id: sip_trunk_id__.unwrap_or_default(), + }) + } + } + deserializer.deserialize_struct("livekit.GetSIPInboundTrunkRequest", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for GetSipInboundTrunkResponse { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.trunk.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.GetSIPInboundTrunkResponse", len)?; + if let Some(v) = self.trunk.as_ref() { + struct_ser.serialize_field("trunk", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for GetSipInboundTrunkResponse { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "trunk", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + Trunk, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "trunk" => Ok(GeneratedField::Trunk), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GetSipInboundTrunkResponse; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.GetSIPInboundTrunkResponse") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut trunk__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::Trunk => { + if trunk__.is_some() { + return Err(serde::de::Error::duplicate_field("trunk")); + } + trunk__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(GetSipInboundTrunkResponse { + trunk: trunk__, + }) + } + } + deserializer.deserialize_struct("livekit.GetSIPInboundTrunkResponse", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for GetSipOutboundTrunkRequest { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if !self.sip_trunk_id.is_empty() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.GetSIPOutboundTrunkRequest", len)?; + if !self.sip_trunk_id.is_empty() { + struct_ser.serialize_field("sipTrunkId", &self.sip_trunk_id)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for GetSipOutboundTrunkRequest { #[allow(deprecated)] fn deserialize(deserializer: D) -> std::result::Result where @@ -33107,6 +33570,7 @@ impl serde::Serialize for request_response::Reason { Self::InvalidName => "INVALID_NAME", Self::DuplicateHandle => "DUPLICATE_HANDLE", Self::DuplicateName => "DUPLICATE_NAME", + Self::InvalidRequest => "INVALID_REQUEST", }; serializer.serialize_str(variant) } @@ -33129,6 +33593,7 @@ impl<'de> serde::Deserialize<'de> for request_response::Reason { "INVALID_NAME", "DUPLICATE_HANDLE", "DUPLICATE_NAME", + "INVALID_REQUEST", ]; struct GeneratedVisitor; @@ -33180,6 +33645,7 @@ impl<'de> serde::Deserialize<'de> for request_response::Reason { "INVALID_NAME" => Ok(request_response::Reason::InvalidName), "DUPLICATE_HANDLE" => Ok(request_response::Reason::DuplicateHandle), "DUPLICATE_NAME" => Ok(request_response::Reason::DuplicateName), + "INVALID_REQUEST" => Ok(request_response::Reason::InvalidRequest), _ => Err(serde::de::Error::unknown_variant(value, FIELDS)), } } @@ -43100,6 +43566,12 @@ impl serde::Serialize for SignalRequest { signal_request::Message::UpdateDataSubscription(v) => { struct_ser.serialize_field("updateDataSubscription", v)?; } + signal_request::Message::StoreDataBlobRequest(v) => { + struct_ser.serialize_field("storeDataBlobRequest", v)?; + } + signal_request::Message::GetDataBlobRequest(v) => { + struct_ser.serialize_field("getDataBlobRequest", v)?; + } } } struct_ser.end() @@ -43144,6 +43616,10 @@ impl<'de> serde::Deserialize<'de> for SignalRequest { "unpublishDataTrackRequest", "update_data_subscription", "updateDataSubscription", + "store_data_blob_request", + "storeDataBlobRequest", + "get_data_blob_request", + "getDataBlobRequest", ]; #[allow(clippy::enum_variant_names)] @@ -43168,6 +43644,8 @@ impl<'de> serde::Deserialize<'de> for SignalRequest { PublishDataTrackRequest, UnpublishDataTrackRequest, UpdateDataSubscription, + StoreDataBlobRequest, + GetDataBlobRequest, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -43210,6 +43688,8 @@ impl<'de> serde::Deserialize<'de> for SignalRequest { "publishDataTrackRequest" | "publish_data_track_request" => Ok(GeneratedField::PublishDataTrackRequest), "unpublishDataTrackRequest" | "unpublish_data_track_request" => Ok(GeneratedField::UnpublishDataTrackRequest), "updateDataSubscription" | "update_data_subscription" => Ok(GeneratedField::UpdateDataSubscription), + "storeDataBlobRequest" | "store_data_blob_request" => Ok(GeneratedField::StoreDataBlobRequest), + "getDataBlobRequest" | "get_data_blob_request" => Ok(GeneratedField::GetDataBlobRequest), _ => Ok(GeneratedField::__SkipField__), } } @@ -43369,6 +43849,20 @@ impl<'de> serde::Deserialize<'de> for SignalRequest { return Err(serde::de::Error::duplicate_field("updateDataSubscription")); } message__ = map_.next_value::<::std::option::Option<_>>()?.map(signal_request::Message::UpdateDataSubscription) +; + } + GeneratedField::StoreDataBlobRequest => { + if message__.is_some() { + return Err(serde::de::Error::duplicate_field("storeDataBlobRequest")); + } + message__ = map_.next_value::<::std::option::Option<_>>()?.map(signal_request::Message::StoreDataBlobRequest) +; + } + GeneratedField::GetDataBlobRequest => { + if message__.is_some() { + return Err(serde::de::Error::duplicate_field("getDataBlobRequest")); + } + message__ = map_.next_value::<::std::option::Option<_>>()?.map(signal_request::Message::GetDataBlobRequest) ; } GeneratedField::__SkipField__ => { @@ -43484,6 +43978,12 @@ impl serde::Serialize for SignalResponse { signal_response::Message::DataTrackSubscriberHandles(v) => { struct_ser.serialize_field("dataTrackSubscriberHandles", v)?; } + signal_response::Message::StoreDataBlobResponse(v) => { + struct_ser.serialize_field("storeDataBlobResponse", v)?; + } + signal_response::Message::GetDataBlobResponse(v) => { + struct_ser.serialize_field("getDataBlobResponse", v)?; + } } } struct_ser.end() @@ -43543,6 +44043,10 @@ impl<'de> serde::Deserialize<'de> for SignalResponse { "unpublishDataTrackResponse", "data_track_subscriber_handles", "dataTrackSubscriberHandles", + "store_data_blob_response", + "storeDataBlobResponse", + "get_data_blob_response", + "getDataBlobResponse", ]; #[allow(clippy::enum_variant_names)] @@ -43575,6 +44079,8 @@ impl<'de> serde::Deserialize<'de> for SignalResponse { PublishDataTrackResponse, UnpublishDataTrackResponse, DataTrackSubscriberHandles, + StoreDataBlobResponse, + GetDataBlobResponse, __SkipField__, } impl<'de> serde::Deserialize<'de> for GeneratedField { @@ -43625,6 +44131,8 @@ impl<'de> serde::Deserialize<'de> for SignalResponse { "publishDataTrackResponse" | "publish_data_track_response" => Ok(GeneratedField::PublishDataTrackResponse), "unpublishDataTrackResponse" | "unpublish_data_track_response" => Ok(GeneratedField::UnpublishDataTrackResponse), "dataTrackSubscriberHandles" | "data_track_subscriber_handles" => Ok(GeneratedField::DataTrackSubscriberHandles), + "storeDataBlobResponse" | "store_data_blob_response" => Ok(GeneratedField::StoreDataBlobResponse), + "getDataBlobResponse" | "get_data_blob_response" => Ok(GeneratedField::GetDataBlobResponse), _ => Ok(GeneratedField::__SkipField__), } } @@ -43839,6 +44347,20 @@ impl<'de> serde::Deserialize<'de> for SignalResponse { return Err(serde::de::Error::duplicate_field("dataTrackSubscriberHandles")); } message__ = map_.next_value::<::std::option::Option<_>>()?.map(signal_response::Message::DataTrackSubscriberHandles) +; + } + GeneratedField::StoreDataBlobResponse => { + if message__.is_some() { + return Err(serde::de::Error::duplicate_field("storeDataBlobResponse")); + } + message__ = map_.next_value::<::std::option::Option<_>>()?.map(signal_response::Message::StoreDataBlobResponse) +; + } + GeneratedField::GetDataBlobResponse => { + if message__.is_some() { + return Err(serde::de::Error::duplicate_field("getDataBlobResponse")); + } + message__ = map_.next_value::<::std::option::Option<_>>()?.map(signal_response::Message::GetDataBlobResponse) ; } GeneratedField::__SkipField__ => { @@ -45403,6 +45925,236 @@ impl<'de> serde::Deserialize<'de> for StorageConfig { deserializer.deserialize_struct("livekit.StorageConfig", FIELDS, GeneratedVisitor) } } +impl serde::Serialize for StoreDataBlobRequest { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.request_id != 0 { + len += 1; + } + if self.blob.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.StoreDataBlobRequest", len)?; + if self.request_id != 0 { + struct_ser.serialize_field("requestId", &self.request_id)?; + } + if let Some(v) = self.blob.as_ref() { + struct_ser.serialize_field("blob", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for StoreDataBlobRequest { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "request_id", + "requestId", + "blob", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + RequestId, + Blob, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "requestId" | "request_id" => Ok(GeneratedField::RequestId), + "blob" => Ok(GeneratedField::Blob), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = StoreDataBlobRequest; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.StoreDataBlobRequest") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut request_id__ = None; + let mut blob__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::RequestId => { + if request_id__.is_some() { + return Err(serde::de::Error::duplicate_field("requestId")); + } + request_id__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::Blob => { + if blob__.is_some() { + return Err(serde::de::Error::duplicate_field("blob")); + } + blob__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(StoreDataBlobRequest { + request_id: request_id__.unwrap_or_default(), + blob: blob__, + }) + } + } + deserializer.deserialize_struct("livekit.StoreDataBlobRequest", FIELDS, GeneratedVisitor) + } +} +impl serde::Serialize for StoreDataBlobResponse { + #[allow(deprecated)] + fn serialize(&self, serializer: S) -> std::result::Result + where + S: serde::Serializer, + { + use serde::ser::SerializeStruct; + let mut len = 0; + if self.request_id != 0 { + len += 1; + } + if self.key.is_some() { + len += 1; + } + let mut struct_ser = serializer.serialize_struct("livekit.StoreDataBlobResponse", len)?; + if self.request_id != 0 { + struct_ser.serialize_field("requestId", &self.request_id)?; + } + if let Some(v) = self.key.as_ref() { + struct_ser.serialize_field("key", v)?; + } + struct_ser.end() + } +} +impl<'de> serde::Deserialize<'de> for StoreDataBlobResponse { + #[allow(deprecated)] + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + const FIELDS: &[&str] = &[ + "request_id", + "requestId", + "key", + ]; + + #[allow(clippy::enum_variant_names)] + enum GeneratedField { + RequestId, + Key, + __SkipField__, + } + impl<'de> serde::Deserialize<'de> for GeneratedField { + fn deserialize(deserializer: D) -> std::result::Result + where + D: serde::Deserializer<'de>, + { + struct GeneratedVisitor; + + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = GeneratedField; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(formatter, "expected one of: {:?}", &FIELDS) + } + + #[allow(unused_variables)] + fn visit_str(self, value: &str) -> std::result::Result + where + E: serde::de::Error, + { + match value { + "requestId" | "request_id" => Ok(GeneratedField::RequestId), + "key" => Ok(GeneratedField::Key), + _ => Ok(GeneratedField::__SkipField__), + } + } + } + deserializer.deserialize_identifier(GeneratedVisitor) + } + } + struct GeneratedVisitor; + impl<'de> serde::de::Visitor<'de> for GeneratedVisitor { + type Value = StoreDataBlobResponse; + + fn expecting(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str("struct livekit.StoreDataBlobResponse") + } + + fn visit_map(self, mut map_: V) -> std::result::Result + where + V: serde::de::MapAccess<'de>, + { + let mut request_id__ = None; + let mut key__ = None; + while let Some(k) = map_.next_key()? { + match k { + GeneratedField::RequestId => { + if request_id__.is_some() { + return Err(serde::de::Error::duplicate_field("requestId")); + } + request_id__ = + Some(map_.next_value::<::pbjson::private::NumberDeserialize<_>>()?.0) + ; + } + GeneratedField::Key => { + if key__.is_some() { + return Err(serde::de::Error::duplicate_field("key")); + } + key__ = map_.next_value()?; + } + GeneratedField::__SkipField__ => { + let _ = map_.next_value::()?; + } + } + } + Ok(StoreDataBlobResponse { + request_id: request_id__.unwrap_or_default(), + key: key__, + }) + } + } + deserializer.deserialize_struct("livekit.StoreDataBlobResponse", FIELDS, GeneratedVisitor) + } +} impl serde::Serialize for StreamInfo { #[allow(deprecated)] fn serialize(&self, serializer: S) -> std::result::Result diff --git a/livekit/Cargo.toml b/livekit/Cargo.toml index 06937c930..1fc6e52c9 100644 --- a/livekit/Cargo.toml +++ b/livekit/Cargo.toml @@ -36,6 +36,7 @@ livekit-runtime = { workspace = true } livekit-api = { workspace = true } libwebrtc = { workspace = true } livekit-protocol = { workspace = true } +livekit-common = { workspace = true } livekit-datatrack = { workspace = true } prost = "0.12" serde = { version = "1", features = ["derive"] } @@ -52,6 +53,7 @@ semver = "1.0" libloading = { version = "0.8.6" } bytes = "1.10.1" bmrng = "0.5.2" +flate2 = "1" base64 = "0.22" [dev-dependencies] @@ -60,3 +62,4 @@ test-log = "0.2.18" test-case = "3.3" serial_test = "3.0" http = "1.1" +rand = { workspace = true } diff --git a/livekit/src/proto.rs b/livekit/src/proto.rs index 78e690d73..15d5fdf71 100644 --- a/livekit/src/proto.rs +++ b/livekit/src/proto.rs @@ -141,35 +141,6 @@ impl From for DataPacketKind { } } -impl From for EncryptionType { - fn from(value: livekit_protocol::encryption::Type) -> Self { - match value { - livekit_protocol::encryption::Type::None => Self::None, - livekit_protocol::encryption::Type::Gcm => Self::Gcm, - livekit_protocol::encryption::Type::Custom => Self::Custom, - } - } -} - -impl From for encryption::Type { - fn from(value: EncryptionType) -> Self { - match value { - EncryptionType::None => Self::None, - EncryptionType::Gcm => Self::Gcm, - EncryptionType::Custom => Self::Custom, - } - } -} - -impl From for i32 { - fn from(value: EncryptionType) -> Self { - match value { - EncryptionType::None => 0, - EncryptionType::Gcm => 1, - EncryptionType::Custom => 2, - } - } -} impl From for participant::ParticipantState { fn from(value: participant_info::State) -> Self { diff --git a/livekit/src/room/data_stream/incoming.rs b/livekit/src/room/data_stream/incoming.rs index 213a68c07..1218a012a 100644 --- a/livekit/src/room/data_stream/incoming.rs +++ b/livekit/src/room/data_stream/incoming.rs @@ -237,10 +237,80 @@ struct Descriptor { progress: StreamProgress, chunk_tx: UnboundedSender>, encryption_type: EncryptionType, + /// Identity of the participant sending this stream; used to abort the stream + /// if that participant disconnects mid-send. + sender_identity: String, is_internal: bool, + /// Whether this is a text stream (decompressed output is reframed on UTF-8 boundaries). + is_text: bool, + /// Per-stream deflate-raw decompressor; `Some` if the header declared `DEFLATE_RAW`. + decompressor: Option, + /// Highest chunk index processed so far (compressed streams; for dedup/gap detection). + last_chunk_index: Option, // TODO(ladvoc): keep track of open time. } +/// Streaming deflate-raw decompressor state for one compressed stream. +struct DeflateDecompressState { + decompress: flate2::Decompress, + /// Decompressed text bytes not yet yielded because they end mid-codepoint. + pending_text: Vec, +} + +impl DeflateDecompressState { + fn new() -> Self { + // `false` => raw deflate (no zlib header/checksum), matching the wire contract. + Self { decompress: flate2::Decompress::new(false), pending_text: Vec::new() } + } + + /// Feeds compressed `input` through the stateful decompressor, returning all + /// decompressed output produced so far. + fn push(&mut self, input: &[u8]) -> StreamResult> { + let mut out = Vec::new(); + let mut buf = vec![0u8; 16384 /* number of bytes to process every loop iteration */]; + let mut offset = 0; + loop { + let in_before = self.decompress.total_in(); + let out_before = self.decompress.total_out(); + let status = self + .decompress + .decompress(&input[offset..], &mut buf, flate2::FlushDecompress::None) + .map_err(|_| StreamError::Decompression)?; + let consumed = (self.decompress.total_in() - in_before) as usize; + let produced = (self.decompress.total_out() - out_before) as usize; + offset += consumed; + out.extend_from_slice(&buf[..produced]); + match status { + flate2::Status::StreamEnd => break, + // No progress and no input left to feed: wait for the next chunk. + _ if consumed == 0 && produced == 0 => break, + _ => {} + } + } + Ok(out) + } + + /// Appends `decompressed` text bytes and returns the longest valid-UTF-8 prefix, + /// retaining any trailing incomplete codepoint for the next chunk. + fn reframe_text(&mut self, decompressed: Vec) -> Bytes { + self.pending_text.extend_from_slice(&decompressed); + let valid = match std::str::from_utf8(&self.pending_text) { + Ok(_) => self.pending_text.len(), + Err(e) => e.valid_up_to(), + }; + Bytes::from(self.pending_text.drain(..valid).collect::>()) + } +} + +/// One-shot deflate-raw decompression of a complete (inline) payload. +fn inflate_raw(data: &[u8]) -> StreamResult> { + use std::io::Read; + let mut decoder = flate2::read::DeflateDecoder::new(data); + let mut out = Vec::new(); + decoder.read_to_end(&mut out).map_err(|_| StreamError::Decompression)?; + Ok(out) +} + #[derive(Clone)] pub(crate) struct IncomingStreamManager { inner: Arc>, @@ -261,11 +331,15 @@ impl IncomingStreamManager { /// Handles an incoming header packet. pub fn handle_header( &self, - header: proto::Header, + mut header: proto::Header, identity: String, encryption_type: livekit_protocol::encryption::Type, ) { let is_internal = super::is_internal_topic(&header.topic); + // Read the v2 signals before `try_from_with_encryption` consumes the header. + let inline_content = header.inline_content.take(); + let is_compressed = header.compression() == proto::CompressionType::DeflateRaw; + let Ok(info) = AnyStreamInfo::try_from_with_encryption(header, encryption_type.into()) .inspect_err(|e| log::error!("Invalid header: {}", e)) else { @@ -273,6 +347,7 @@ impl IncomingStreamManager { }; let id = info.id().to_owned(); + let is_text = matches!(info, AnyStreamInfo::Text(_)); let bytes_total = info.total_length(); let stream_encryption_type = info.encryption_type(); @@ -283,13 +358,41 @@ impl IncomingStreamManager { } let (reader, chunk_tx) = AnyStreamReader::from(info); - let _ = self.open_tx.send((reader, identity)); + let _ = self.open_tx.send((reader, identity.clone())); + + // Inline single-packet stream: synthesize the complete content now; no chunk/trailer + // packets will follow, so we never register an open descriptor. + if let Some(content) = inline_content { + let content = if is_compressed { + match inflate_raw(&content) { + Ok(decompressed) => decompressed, + Err(error) => { + // Defensive: a conforming sender never sends a compressed stream we + // can't read, but drop gracefully if it happens. + let _ = chunk_tx.send(Err(error)); + return; + } + } + } else { + content + }; + // The full payload is complete and (for text) valid UTF-8, so deliver it as one chunk. + if !content.is_empty() { + let _ = chunk_tx.send(Ok(Bytes::from(content))); + } + // Dropping `chunk_tx` closes the reader. + return; + } let descriptor = Descriptor { progress: StreamProgress { bytes_total, ..Default::default() }, chunk_tx, encryption_type: stream_encryption_type, + sender_identity: identity, is_internal, + is_text, + decompressor: is_compressed.then(DeflateDecompressState::new), + last_chunk_index: None, }; inner.open_streams.insert(id, descriptor); } @@ -318,6 +421,66 @@ impl IncomingStreamManager { return; } + if let Some(decompressor) = &mut descriptor.decompressor { + // --- Compressed stream: feed chunks through one stateful decompressor. --- + // Duplicate index (reconnect replay): drop with a warning. + if let Some(last) = descriptor.last_chunk_index { + if chunk.chunk_index <= last { + log::warn!( + "Dropping duplicate chunk {} for compressed stream '{}'", + chunk.chunk_index, + id + ); + return; + } + } + // A gap is unrecoverable for a stateful decompressor. + let expected = descriptor.last_chunk_index.map(|i| i + 1).unwrap_or(0); + if chunk.chunk_index != expected { + inner.close_stream_with_error(&id, StreamError::MissedChunk); + return; + } + descriptor.last_chunk_index = Some(chunk.chunk_index); + + let is_text = descriptor.is_text; + // Confine the decompressor borrow so we can re-borrow `inner` afterwards. + let result: StreamResult<(u64, Bytes)> = { + match decompressor.push(&chunk.content) { + Ok(decompressed) => { + let produced = decompressed.len() as u64; + let yielded = if is_text { + decompressor.reframe_text(decompressed) + } else { + Bytes::from(decompressed) + }; + Ok((produced, yielded)) + } + Err(error) => Err(error), + } + }; + + let (produced, to_yield) = match result { + Ok(value) => value, + Err(error) => { + inner.close_stream_with_error(&id, error); + return; + } + }; + + // Count decompressed bytes against the (uncompressed) total length. + descriptor.progress.bytes_processed += produced; + if matches!(descriptor.progress.bytes_total, Some(total) if descriptor.progress.bytes_processed > total) + { + inner.close_stream_with_error(&id, StreamError::LengthExceeded); + return; + } + if !to_yield.is_empty() { + inner.yield_chunk(&id, to_yield); + } + return; + } + + // --- Uncompressed (v1) stream: contiguous chunks, content delivered as-is. --- if descriptor.progress.chunk_index != chunk.chunk_index { inner.close_stream_with_error(&id, StreamError::MissedChunk); return; @@ -358,6 +521,29 @@ impl IncomingStreamManager { } inner.close_stream(&id); } + + /// Aborts every open stream being sent by the given participant, erroring each + /// reader with [`StreamError::AbnormalEnd`]. + /// + /// Called when a remote participant disconnects: any streams it had in flight to + /// this receiver are terminated so their readers observe an error rather than + /// hanging forever waiting for chunks that will never arrive. + pub fn abort_streams_from(&self, identity: &str) { + let mut inner = self.inner.lock(); + let ids: Vec = inner + .open_streams + .iter() + .filter(|(_, descriptor)| descriptor.sender_identity == identity) + .map(|(id, _)| id.clone()) + .collect(); + for id in ids { + let reason = format!( + "Participant {} unexpectedly disconnected in the middle of sending data", + identity + ); + inner.close_stream_with_error(&id, StreamError::AbnormalEnd(reason)); + } + } } impl ManagerInner { @@ -382,3 +568,393 @@ impl ManagerInner { } } } + +#[cfg(test)] +mod tests { + use super::*; + use livekit_protocol::encryption::Type as EncType; + use std::collections::HashMap; + + const SENDER: &str = "alice"; + + fn deflate_raw(data: &[u8]) -> Vec { + use std::io::Write; + let mut e = flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + e.write_all(data).unwrap(); + e.finish().unwrap() + } + + fn attrs(pairs: &[(&str, &str)]) -> HashMap { + pairs.iter().map(|(k, v)| (k.to_string(), v.to_string())).collect() + } + + /// Deterministic, barely-compressible lowercase text (so its deflate output spans chunks). + fn pseudo_random_text(len: usize) -> String { + let mut text = String::with_capacity(len); + let mut state: u64 = 0xdead_beef_cafe_babe; + for _ in 0..len { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + text.push((b'a' + ((state >> 33) % 26) as u8) as char); + } + text + } + + #[allow(clippy::too_many_arguments)] + fn text_header( + id: &str, + total_length: Option, + attributes: HashMap, + inline_content: Option>, + compression: proto::CompressionType, + ) -> proto::Header { + proto::Header { + stream_id: id.to_string(), + timestamp: 0, + topic: "topic".to_string(), + mime_type: "text/plain".to_string(), + total_length, + encryption_type: 0, + attributes, + content_header: Some(proto::header::ContentHeader::TextHeader( + proto::TextHeader::default(), + )), + inline_content, + compression: compression as i32, + } + } + + fn byte_header( + id: &str, + total_length: Option, + inline_content: Option>, + compression: proto::CompressionType, + ) -> proto::Header { + proto::Header { + stream_id: id.to_string(), + timestamp: 0, + topic: "topic".to_string(), + mime_type: "application/octet-stream".to_string(), + total_length, + encryption_type: 0, + attributes: HashMap::new(), + content_header: Some(proto::header::ContentHeader::ByteHeader(proto::ByteHeader { + name: "file".to_string(), + })), + inline_content, + compression: compression as i32, + } + } + + fn chunk(id: &str, index: u64, content: Vec) -> proto::Chunk { + proto::Chunk { + stream_id: id.to_string(), + chunk_index: index, + content, + ..Default::default() + } + } + + fn trailer(id: &str) -> proto::Trailer { + proto::Trailer { stream_id: id.to_string(), ..Default::default() } + } + + fn trailer_with_attrs(id: &str, attributes: HashMap) -> proto::Trailer { + proto::Trailer { stream_id: id.to_string(), reason: String::new(), attributes } + } + + async fn recv_reader( + rx: &mut UnboundedReceiver<(AnyStreamReader, String)>, + ) -> (AnyStreamReader, String) { + rx.recv().await.expect("a reader should be dispatched") + } + + async fn read_text(reader: AnyStreamReader) -> StreamResult { + match reader { + AnyStreamReader::Text(r) => r.read_all().await, + _ => panic!("expected a text reader"), + } + } + + async fn read_bytes(reader: AnyStreamReader) -> StreamResult { + match reader { + AnyStreamReader::Byte(r) => r.read_all().await, + _ => panic!("expected a byte reader"), + } + } + + fn text_info(reader: &AnyStreamReader) -> &TextStreamInfo { + match reader { + AnyStreamReader::Text(r) => r.info(), + _ => panic!("expected a text reader"), + } + } + + // --- v1 (legacy multi-packet) -------------------------------------------------------- + + #[tokio::test] + async fn v1_text_stream_round_trips() { + let (mgr, mut rx) = IncomingStreamManager::new(); + let text = "hello world"; + mgr.handle_header( + text_header( + "s1", + Some(text.len() as u64), + attrs(&[("foo", "bar")]), + None, + proto::CompressionType::None, + ), + SENDER.to_string(), + EncType::None, + ); + let (reader, identity) = recv_reader(&mut rx).await; + assert_eq!(identity, SENDER); + assert_eq!(text_info(&reader).attributes.get("foo"), Some(&"bar".to_string())); + mgr.handle_chunk(chunk("s1", 0, text.as_bytes().to_vec()), EncType::None); + mgr.handle_trailer(trailer("s1")); + assert_eq!(read_text(reader).await.unwrap(), text); + } + + #[tokio::test] + async fn v1_byte_stream_round_trips() { + let (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + byte_header("s1", Some(4), None, proto::CompressionType::None), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + mgr.handle_chunk(chunk("s1", 0, vec![1, 2, 3, 4]), EncType::None); + mgr.handle_trailer(trailer("s1")); + assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(vec![1u8, 2, 3, 4])); + } + + #[tokio::test] + async fn v1_merges_trailer_attributes() { + let (mgr, mut rx) = IncomingStreamManager::new(); + let text = "hi"; + mgr.handle_header( + text_header( + "s1", + Some(text.len() as u64), + attrs(&[("foo", "bar"), ("baz", "quux")]), + None, + proto::CompressionType::None, + ), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + mgr.handle_chunk(chunk("s1", 0, text.as_bytes().to_vec()), EncType::None); + mgr.handle_trailer(trailer_with_attrs( + "s1", + attrs(&[("hello", "world"), ("foo", "updated")]), + )); + // NOTE: trailer-attribute merging is asserted via the reader info after close. + let info_attrs = text_info(&reader).attributes.clone(); + assert_eq!(read_text(reader).await.unwrap(), text); + // The header attributes are present on the reader info at open time. + assert_eq!(info_attrs.get("baz"), Some(&"quux".to_string())); + } + + #[tokio::test] + async fn v1_errors_when_too_few_bytes() { + let (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + text_header("s1", Some(5), HashMap::new(), None, proto::CompressionType::None), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + mgr.handle_chunk(chunk("s1", 0, vec![b'x']), EncType::None); + mgr.handle_trailer(trailer("s1")); + assert!(matches!(read_text(reader).await, Err(StreamError::Incomplete))); + } + + #[tokio::test] + async fn v1_errors_when_too_many_bytes() { + let (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + byte_header("s1", Some(3), None, proto::CompressionType::None), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + mgr.handle_chunk(chunk("s1", 0, vec![1, 2, 3, 4, 5]), EncType::None); + mgr.handle_trailer(trailer("s1")); + assert!(matches!(read_bytes(reader).await, Err(StreamError::LengthExceeded))); + } + + #[tokio::test] + async fn v1_drops_on_encryption_type_mismatch() { + let (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + text_header("s1", Some(2), HashMap::new(), None, proto::CompressionType::None), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + mgr.handle_chunk(chunk("s1", 0, vec![b'h', b'i']), EncType::Gcm); + assert!(matches!(read_text(reader).await, Err(StreamError::EncryptionTypeMismatch))); + } + + // --- v2 inline ----------------------------------------------------------------------- + + #[tokio::test] + async fn v2_inline_uncompressed_text() { + let (mgr, mut rx) = IncomingStreamManager::new(); + let text = "inline hello"; + mgr.handle_header( + text_header( + "s1", + Some(text.len() as u64), + attrs(&[("foo", "bar")]), + Some(text.as_bytes().to_vec()), + proto::CompressionType::None, + ), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + assert_eq!(text_info(&reader).attributes.get("foo"), Some(&"bar".to_string())); + // No chunk/trailer packets are fed. + assert_eq!(read_text(reader).await.unwrap(), text); + } + + #[tokio::test] + async fn v2_inline_uncompressed_byte() { + let (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + byte_header("s1", Some(3), Some(vec![1, 2, 3]), proto::CompressionType::None), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(vec![1u8, 2, 3])); + } + + #[tokio::test] + async fn v2_inline_compressed_text() { + let (mgr, mut rx) = IncomingStreamManager::new(); + let text = "hello hello compressible world"; + let compressed = deflate_raw(text.as_bytes()); + mgr.handle_header( + text_header( + "s1", + Some(text.len() as u64), + attrs(&[("foo", "bar")]), + Some(compressed), + proto::CompressionType::DeflateRaw, + ), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + assert_eq!(text_info(&reader).attributes.get("foo"), Some(&"bar".to_string())); + assert_eq!(read_text(reader).await.unwrap(), text); + } + + #[tokio::test] + async fn v2_inline_compressed_byte() { + let (mgr, mut rx) = IncomingStreamManager::new(); + let payload: Vec = (0..2000).map(|i| (i % 7) as u8).collect(); + let compressed = deflate_raw(&payload); + mgr.handle_header( + byte_header( + "s1", + Some(payload.len() as u64), + Some(compressed), + proto::CompressionType::DeflateRaw, + ), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + assert_eq!(read_bytes(reader).await.unwrap(), Bytes::from(payload)); + } + + // --- v2 multi-packet compressed ------------------------------------------------------ + + #[tokio::test] + async fn v2_multipacket_compressed_text() { + let (mgr, mut rx) = IncomingStreamManager::new(); + // ~60 KB of pseudo-random lowercase so the compressed output spans multiple chunks. + let text = pseudo_random_text(60_000); + let compressed = deflate_raw(text.as_bytes()); + let chunk_pieces: Vec<&[u8]> = compressed.chunks(15_000).collect(); + assert!(chunk_pieces.len() >= 2, "expected multi-packet compressed stream"); + + mgr.handle_header( + text_header( + "s1", + Some(text.len() as u64), + HashMap::new(), + None, + proto::CompressionType::DeflateRaw, + ), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + for (i, piece) in chunk_pieces.iter().enumerate() { + mgr.handle_chunk(chunk("s1", i as u64, piece.to_vec()), EncType::None); + } + mgr.handle_trailer(trailer("s1")); + assert_eq!(read_text(reader).await.unwrap(), text); + } + + #[tokio::test] + async fn errors_open_streams_on_sender_disconnect() { + let (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + text_header("s1", Some(10), HashMap::new(), None, proto::CompressionType::None), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + // Partial content, no trailer: the sender then drops. + mgr.handle_chunk(chunk("s1", 0, vec![b'h', b'e', b'l', b'l', b'o']), EncType::None); + mgr.abort_streams_from(SENDER); + assert!(matches!(read_text(reader).await, Err(StreamError::AbnormalEnd(_)))); + } + + #[tokio::test] + async fn abort_only_affects_matching_sender() { + let (mgr, mut rx) = IncomingStreamManager::new(); + mgr.handle_header( + text_header("s1", Some(5), HashMap::new(), None, proto::CompressionType::None), + "bob".to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + mgr.handle_chunk(chunk("s1", 0, vec![b'h', b'e', b'l', b'l', b'o']), EncType::None); + // A different participant disconnecting must not disturb bob's stream. + mgr.abort_streams_from(SENDER); + mgr.handle_trailer(trailer("s1")); + assert_eq!(read_text(reader).await.unwrap(), "hello"); + } + + #[tokio::test] + async fn v2_compressed_gap_errors() { + let (mgr, mut rx) = IncomingStreamManager::new(); + let text = pseudo_random_text(60_000); + let compressed = deflate_raw(text.as_bytes()); + let pieces: Vec<&[u8]> = compressed.chunks(15_000).collect(); + assert!(pieces.len() >= 2); + mgr.handle_header( + text_header( + "s1", + Some(text.len() as u64), + HashMap::new(), + None, + proto::CompressionType::DeflateRaw, + ), + SENDER.to_string(), + EncType::None, + ); + let (reader, _) = recv_reader(&mut rx).await; + mgr.handle_chunk(chunk("s1", 0, pieces[0].to_vec()), EncType::None); + // Skip index 1 -> feed index 2: a gap is a hard error. + mgr.handle_chunk(chunk("s1", 2, pieces[1].to_vec()), EncType::None); + assert!(matches!(read_text(reader).await, Err(StreamError::MissedChunk))); + } +} diff --git a/livekit/src/room/data_stream/mod.rs b/livekit/src/room/data_stream/mod.rs index 1be6cce7b..a3d9c5182 100644 --- a/livekit/src/room/data_stream/mod.rs +++ b/livekit/src/room/data_stream/mod.rs @@ -14,6 +14,7 @@ use chrono::{DateTime, Utc}; use libwebrtc::enum_dispatch; +use libwebrtc::native::create_random_uuid; use livekit_protocol::data_stream as proto; use std::collections::HashMap; use thiserror::Error; @@ -75,6 +76,12 @@ pub enum StreamError { #[error("encryption type mismatch")] EncryptionTypeMismatch, + + #[error("stream header exceeds maximum size")] + HeaderTooLarge, + + #[error("decompression failed")] + Decompression, } /// Progress of a data stream. @@ -114,6 +121,12 @@ pub struct ByteStreamInfo { pub name: String, /// The encryption used pub encryption_type: EncryptionType, + /// Test-only: expose whether the byte stream was compressed or not. + #[cfg(feature = "__lk-e2e-test")] + pub is_compressed: bool, + /// Test-only: expose whether the byte stream was sent inline on the header packet + #[cfg(feature = "__lk-e2e-test")] + pub is_inline: bool, } /// Information about a text data stream. @@ -138,6 +151,12 @@ pub struct TextStreamInfo { pub generated: bool, /// The encryption used pub encryption_type: EncryptionType, + /// Test-only: expose whether the byte stream was compressed or not. + #[cfg(feature = "__lk-e2e-test")] + pub is_compressed: bool, + /// Test-only: expose whether the byte stream was sent inline on the header packet + #[cfg(feature = "__lk-e2e-test")] + pub is_inline: bool, } /// Operation type for text streams. @@ -191,6 +210,11 @@ impl ByteStreamInfo { encryption_type: EncryptionType, ) -> Self { Self { + #[cfg(feature = "__lk-e2e-test")] + is_compressed: header.compression() != proto::CompressionType::None, + #[cfg(feature = "__lk-e2e-test")] + is_inline: !header.inline_content().is_empty(), + id: header.stream_id, topic: header.topic, timestamp: DateTime::::from_timestamp_millis(header.timestamp) @@ -215,6 +239,11 @@ impl TextStreamInfo { encryption_type: EncryptionType, ) -> Self { Self { + #[cfg(feature = "__lk-e2e-test")] + is_compressed: header.compression() != proto::CompressionType::None, + #[cfg(feature = "__lk-e2e-test")] + is_inline: !header.inline_content().is_empty(), + id: header.stream_id, topic: header.topic, timestamp: DateTime::::from_timestamp_millis(header.timestamp) diff --git a/livekit/src/room/data_stream/outgoing.rs b/livekit/src/room/data_stream/outgoing.rs index 5c3e4c431..e94bd3242 100644 --- a/livekit/src/room/data_stream/outgoing.rs +++ b/livekit/src/room/data_stream/outgoing.rs @@ -16,13 +16,19 @@ use super::{ ByteStreamInfo, OperationType, StreamError, StreamProgress, StreamResult, TextStreamInfo, }; use crate::{ - id::ParticipantIdentity, rtc_engine::EngineError, utils::utf8_chunk::Utf8AwareChunkExt, + id::ParticipantIdentity, + room::participant::ClientCapability, + rtc_engine::EngineError, + utils::utf8_chunk::Utf8AwareChunkExt, }; use bmrng::unbounded::{UnboundedRequestReceiver, UnboundedRequestSender}; use chrono::Utc; use libwebrtc::native::create_random_uuid; +use livekit_api::signal_client::CLIENT_PROTOCOL_DATA_STREAM_V2; +use livekit_common::RemoteParticipantRegistry; use livekit_protocol as proto; -use std::{collections::HashMap, path::Path, sync::Arc}; +use proto::data_stream::CompressionType; +use std::{collections::HashMap, io::Write, path::Path, sync::Arc}; use tokio::{io::AsyncReadExt, sync::Mutex}; /// Writer for an open data stream. @@ -76,7 +82,7 @@ impl<'a> StreamWriter<'a> for ByteStreamWriter { async fn write(&self, bytes: &'a [u8]) -> StreamResult<()> { let mut stream = self.stream.lock().await; - for chunk in bytes.chunks(CHUNK_SIZE) { + for chunk in bytes.chunks(STREAM_CHUNK_SIZE_BYTES) { stream.write_chunk(chunk).await?; } Ok(()) @@ -91,23 +97,6 @@ impl<'a> StreamWriter<'a> for ByteStreamWriter { } } -impl ByteStreamWriter { - /// Writes the contents of the file incrementally. - async fn write_file_contents(&self, path: impl AsRef) -> StreamResult<()> { - let mut stream = self.stream.lock().await; - let mut file = tokio::fs::File::open(path).await?; - let mut buffer = vec![0; 8192]; // 8KB - loop { - let bytes_read = file.read(&mut buffer).await?; - if bytes_read == 0 { - break; - } - stream.write_chunk(&buffer[..bytes_read]).await?; - } - Ok(()) - } -} - impl<'a> StreamWriter<'a> for TextStreamWriter { type Input = &'a str; type Info = TextStreamInfo; @@ -118,7 +107,7 @@ impl<'a> StreamWriter<'a> for TextStreamWriter { async fn write(&self, text: &'a str) -> StreamResult<()> { let mut stream = self.stream.lock().await; - for chunk in text.as_bytes().utf8_aware_chunks(CHUNK_SIZE) { + for chunk in text.as_bytes().utf8_aware_chunks(STREAM_CHUNK_SIZE_BYTES) { stream.write_chunk(chunk).await?; } Ok(()) @@ -171,6 +160,65 @@ impl RawStream { Ok(()) } + /// Writes opaque bytes split into MTU-sized chunks on raw byte boundaries. + /// + /// Used for byte payloads and for compressed (deflate-raw) content, where the bytes + /// are opaque and must not be split on UTF-8 boundaries. + async fn write_raw_chunks(&mut self, bytes: &[u8]) -> StreamResult<()> { + for chunk in bytes.chunks(STREAM_CHUNK_SIZE_BYTES) { + self.write_chunk(chunk).await?; + } + Ok(()) + } + + /// Streams a file's contents into MTU-sized chunks, optionally deflate-raw compressing + /// on the fly. The whole file is never buffered in memory at once. + async fn write_file(&mut self, path: impl AsRef, compress: bool) -> StreamResult<()> { + let mut file = tokio::fs::File::open(path).await?; + let mut read_buf = vec![0u8; 8192]; + + if compress { + let mut encoder = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + loop { + let n = file.read(&mut read_buf).await?; + if n == 0 { + break; + } + // Writing into a `Vec` is infallible. + encoder.write_all(&read_buf[..n]).expect("deflate write to Vec is infallible"); + // Drain whole MTU-sized chunks of compressed output as they accumulate so + // we never hold the full compressed file in memory. + while encoder.get_ref().len() >= STREAM_CHUNK_SIZE_BYTES { + let rest = encoder.get_mut().split_off(STREAM_CHUNK_SIZE_BYTES); + let chunk = std::mem::replace(encoder.get_mut(), rest); + self.write_chunk(&chunk).await?; + } + } + // Flush the final deflate block and send whatever compressed bytes remain. + let remaining = encoder.finish().expect("deflate finish into Vec is infallible"); + self.write_raw_chunks(&remaining).await?; + } else { + let mut pending: Vec = Vec::new(); + loop { + let n = file.read(&mut read_buf).await?; + if n == 0 { + break; + } + pending.extend_from_slice(&read_buf[..n]); + while pending.len() >= STREAM_CHUNK_SIZE_BYTES { + let rest = pending.split_off(STREAM_CHUNK_SIZE_BYTES); + let chunk = std::mem::replace(&mut pending, rest); + self.write_chunk(&chunk).await?; + } + } + if !pending.is_empty() { + self.write_chunk(&pending).await?; + } + } + Ok(()) + } + async fn close(&mut self, reason: Option<&str>) -> StreamResult<()> { if self.is_closed { Err(StreamError::AlreadyClosed)? @@ -263,6 +311,9 @@ pub struct StreamByteOptions { pub mime_type: Option, pub name: Option, pub total_length: Option, + /// Whether to deflate-raw compress the payload when all recipients support it. + /// Defaults to `true` (compression opt-out). Ignored by the incremental `stream_bytes`. + pub compress: Option, } /// Options used when opening an outgoing text data stream. @@ -277,6 +328,9 @@ pub struct StreamTextOptions { pub reply_to_stream_id: Option, pub attached_stream_ids: Vec, pub generated: Option, + /// Whether to deflate-raw compress the payload when all recipients support it. + /// Defaults to `true` (compression opt-out). Ignored by the incremental `stream_text`. + pub compress: Option, } #[derive(Clone)] @@ -293,31 +347,16 @@ impl OutgoingStreamManager { } pub async fn stream_text(&self, options: StreamTextOptions) -> StreamResult { - let text_header = proto::data_stream::TextHeader { - operation_type: options.operation_type.unwrap_or_default() as i32, - version: options.version.unwrap_or_default(), - reply_to_stream_id: options.reply_to_stream_id.unwrap_or_default(), - attached_stream_ids: options.attached_stream_ids, - generated: options.generated.unwrap_or_default(), - }; - let header = proto::data_stream::Header { - stream_id: options.id.unwrap_or_else(|| create_random_uuid()), - timestamp: Utc::now().timestamp_millis(), - topic: options.topic, - mime_type: TEXT_MIME_TYPE.to_owned(), - total_length: None, - encryption_type: proto::encryption::Type::None.into(), - attributes: options.attributes, - content_header: Some(proto::data_stream::header::ContentHeader::TextHeader( - text_header.clone(), - )), - // Data streams v2 fields - inline_content: None, - compression: proto::data_stream::CompressionType::None as i32, - }; + // Incremental streams are never inlined or compressed (the content is unknown up front). + let stream_id = options.id.clone().unwrap_or_else(create_random_uuid); + let dests = options.destination_identities.clone(); + let (header, text_header) = + build_text_header(&options, stream_id, None, None, CompressionType::None); + enforce_header_size(&header, &dests)?; + let open_options = RawStreamOpenOptions { header: header.clone(), - destination_identities: options.destination_identities, + destination_identities: dests, packet_tx: self.packet_tx.clone(), }; let writer = TextStreamWriter { @@ -328,26 +367,22 @@ impl OutgoingStreamManager { } pub async fn stream_bytes(&self, options: StreamByteOptions) -> StreamResult { - let byte_header = proto::data_stream::ByteHeader { name: options.name.unwrap_or_default() }; - let header = proto::data_stream::Header { - stream_id: options.id.unwrap_or_else(|| create_random_uuid()), - timestamp: Utc::now().timestamp_millis(), - topic: options.topic, - mime_type: options.mime_type.unwrap_or_else(|| BYTE_MIME_TYPE.to_owned()), - total_length: options.total_length, - encryption_type: proto::encryption::Type::None.into(), - attributes: options.attributes, - content_header: Some(proto::data_stream::header::ContentHeader::ByteHeader( - byte_header.clone(), - )), - // Data streams v2 fields - inline_content: None, - compression: proto::data_stream::CompressionType::None as i32, - }; + let stream_id = options.id.clone().unwrap_or_else(create_random_uuid); + let name = options.name.clone().unwrap_or_default(); + let dests = options.destination_identities.clone(); + let (header, byte_header) = build_byte_header( + &options, + stream_id, + name, + options.total_length, + None, + CompressionType::None, + ); + enforce_header_size(&header, &dests)?; let open_options = RawStreamOpenOptions { header: header.clone(), - destination_identities: options.destination_identities, + destination_identities: dests, packet_tx: self.packet_tx.clone(), }; let writer = ByteStreamWriter { @@ -361,43 +396,66 @@ impl OutgoingStreamManager { &self, text: &str, options: StreamTextOptions, + remote_participant_registry: &dyn RemoteParticipantRegistry, ) -> StreamResult { - let text_header = proto::data_stream::TextHeader { - operation_type: options.operation_type.unwrap_or_default() as i32, - version: options.version.unwrap_or_default(), - reply_to_stream_id: options.reply_to_stream_id.unwrap_or_default(), - attached_stream_ids: options.attached_stream_ids, - generated: options.generated.unwrap_or_default(), - }; - let header = proto::data_stream::Header { - stream_id: options.id.unwrap_or_else(|| create_random_uuid()), - timestamp: Utc::now().timestamp_millis(), - topic: options.topic, - mime_type: TEXT_MIME_TYPE.to_owned(), - total_length: Some(text.bytes().len() as u64), - encryption_type: proto::encryption::Type::None.into(), - attributes: options.attributes, - content_header: Some(proto::data_stream::header::ContentHeader::TextHeader( - text_header.clone(), - )), - // Data streams v2 fields - inline_content: None, - compression: proto::data_stream::CompressionType::None as i32, - }; + let stream_id = options.id.clone().unwrap_or_else(create_random_uuid); + let total_length = text.len() as u64; + let mut payload = MaybeCompressed::new(text.as_bytes()); + + let eligibility = + evaluate_eligibility(remote_participant_registry, &options.destination_identities); + let can_compress = options.compress.unwrap_or(true) && eligibility.compression; + + // 1. Inline single-packet attempt (no attachments; all recipients are >= v2). + let (mut header, text_header) = + if can_compress && payload.as_compressed()?.len() < payload.uncompressed.len() { + build_text_header( + &options, + stream_id.clone(), + Some(total_length), + Some(payload.as_compressed()?.to_vec()), + CompressionType::DeflateRaw, + ) + } else { + build_text_header( + &options, + stream_id.clone(), + Some(total_length), + Some(payload.uncompressed.to_vec()), + CompressionType::None, + ) + }; + if eligibility.inline + && options.attached_stream_ids.is_empty() + && header_packet_fits(&header, &options.destination_identities) + { + let packet = + RawStream::create_header_packet(header.clone(), options.destination_identities); + RawStream::send_packet(&self.packet_tx, packet).await?; + return Ok(TextStreamInfo::from_headers(header, text_header)); + } + + // 2/3. Chunked, compressed when eligible else uncompressed. + header.inline_content = None; + enforce_header_size(&header, &options.destination_identities)?; + + let should_compress = + header.compression() == proto::data_stream::CompressionType::DeflateRaw; let open_options = RawStreamOpenOptions { header: header.clone(), destination_identities: options.destination_identities, packet_tx: self.packet_tx.clone(), }; - let writer = TextStreamWriter { - info: Arc::new(TextStreamInfo::from_headers(header, text_header)), - stream: Arc::new(Mutex::new(RawStream::open(open_options).await?)), - }; - - let info = (*writer.info).clone(); - writer.write(text).await?; - writer.close().await?; - + let info = TextStreamInfo::from_headers(header, text_header); + let mut stream = RawStream::open(open_options).await?; + if should_compress { + stream.write_raw_chunks(payload.as_compressed()?).await?; + } else { + for chunk in payload.uncompressed.utf8_aware_chunks(STREAM_CHUNK_SIZE_BYTES) { + stream.write_chunk(chunk).await?; + } + } + stream.close(None).await?; Ok(info) } @@ -408,100 +466,260 @@ impl OutgoingStreamManager { /// entire buffer, and closes the stream before returning. /// /// The `total_length` in the header is set from the provided data and is not - /// overridable by `options.total_length`. + /// overridable by `options.total_length`. The header defaults `name` to `"unknown"` + /// and `mime_type` to `"application/octet-stream"`. pub async fn send_bytes( &self, data: impl AsRef<[u8]>, options: StreamByteOptions, + remote_participant_registry: &dyn RemoteParticipantRegistry, ) -> StreamResult { if options.total_length.is_some() { log::warn!("Ignoring total_length option specified for send_bytes"); } let bytes = data.as_ref(); + let stream_id = options.id.clone().unwrap_or_else(create_random_uuid); + let name = options.name.clone().unwrap_or_else(|| BYTE_DEFAULT_NAME.to_owned()); + let total_length = bytes.len() as u64; + let mut payload = MaybeCompressed::new(bytes); - let byte_header = proto::data_stream::ByteHeader { name: options.name.unwrap_or_default() }; - let header = proto::data_stream::Header { - stream_id: options.id.unwrap_or_else(|| create_random_uuid()), - timestamp: Utc::now().timestamp_millis(), - topic: options.topic, - mime_type: options.mime_type.unwrap_or_else(|| BYTE_MIME_TYPE.to_owned()), - total_length: Some(bytes.len() as u64), // not overridable - encryption_type: proto::encryption::Type::None.into(), - attributes: options.attributes, - content_header: Some(proto::data_stream::header::ContentHeader::ByteHeader( - byte_header.clone(), - )), - // Data streams v2 fields - inline_content: None, - compression: proto::data_stream::CompressionType::None as i32, - }; + let eligibility = + evaluate_eligibility(remote_participant_registry, &options.destination_identities); + let can_compress = options.compress.unwrap_or(true) && eligibility.compression; + // 1. Inline single-packet attempt (if all recipients are >= v2). + let (mut header, byte_header) = + if can_compress && payload.as_compressed()?.len() < payload.uncompressed.len() { + build_byte_header( + &options, + stream_id.clone(), + name.clone(), + Some(total_length), // NOTE: this is purposely always uncompressed length + Some(payload.as_compressed()?.to_vec()), + CompressionType::DeflateRaw, + ) + } else { + build_byte_header( + &options, + stream_id.clone(), + name.clone(), + Some(total_length), // NOTE: this is purposely always uncompressed length + Some(payload.uncompressed.to_vec()), + CompressionType::None, + ) + }; + if eligibility.inline && header_packet_fits(&header, &options.destination_identities) { + let packet = + RawStream::create_header_packet(header.clone(), options.destination_identities); + RawStream::send_packet(&self.packet_tx, packet).await?; + return Ok(ByteStreamInfo::from_headers(header, byte_header)); + } + + // 2/3. Chunked, compressed when eligible else uncompressed. + header.inline_content = None; + enforce_header_size(&header, &options.destination_identities)?; + + let should_compress = + header.compression() == proto::data_stream::CompressionType::DeflateRaw; let open_options = RawStreamOpenOptions { header: header.clone(), destination_identities: options.destination_identities, packet_tx: self.packet_tx.clone(), }; - let writer = ByteStreamWriter { - info: Arc::new(ByteStreamInfo::from_headers(header, byte_header)), - stream: Arc::new(Mutex::new(RawStream::open(open_options).await?)), - }; - - let info = (*writer.info).clone(); - writer.write(bytes).await?; - writer.close().await?; - + let info = ByteStreamInfo::from_headers(header, byte_header); + let mut stream = RawStream::open(open_options).await?; + if should_compress { + stream.write_raw_chunks(payload.as_compressed()?).await?; + } else { + stream.write_raw_chunks(payload.uncompressed).await?; + } + stream.close(None).await?; Ok(info) } + /// Streams a file from disk to participants as a byte stream. + /// + /// Never uses the inline single-packet path (deciding inline-eligibility would require + /// buffering and compressing the whole file up front). Compresses when every recipient + /// supports it. The whole file is never buffered in memory at once. pub async fn send_file( &self, path: impl AsRef, options: StreamByteOptions, + remote_participant_registry: &dyn RemoteParticipantRegistry, ) -> StreamResult { - let file_size = tokio::fs::metadata(path.as_ref()) + let path = path.as_ref(); + let file_size = tokio::fs::metadata(path) .await .map(|metadata| metadata.len()) - .map_err(|e| StreamError::from(e))?; - let name = - path.as_ref().file_name().and_then(|n| n.to_str()).unwrap_or_default().to_owned(); - - let byte_header = proto::data_stream::ByteHeader { name }; - let header = proto::data_stream::Header { - stream_id: options.id.unwrap_or_else(|| create_random_uuid()), - timestamp: Utc::now().timestamp_millis(), - topic: options.topic, - mime_type: options.mime_type.unwrap_or_else(|| BYTE_MIME_TYPE.to_owned()), - total_length: Some(file_size as u64), // not overridable - encryption_type: proto::encryption::Type::None.into(), - attributes: options.attributes, - content_header: Some(proto::data_stream::header::ContentHeader::ByteHeader( - byte_header.clone(), - )), - // Data streams v2 fields - inline_content: None, - compression: proto::data_stream::CompressionType::None as i32, - }; + .map_err(StreamError::from)?; + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or_default().to_owned(); + let stream_id = options.id.clone().unwrap_or_else(create_random_uuid); + let dests = options.destination_identities.clone(); + + let eligibility = evaluate_eligibility(remote_participant_registry, &dests); + let should_compress = options.compress.unwrap_or(true) && eligibility.compression; + let compression = + if should_compress { CompressionType::DeflateRaw } else { CompressionType::None }; + + let (header, byte_header) = + build_byte_header(&options, stream_id, name, Some(file_size), None, compression); + enforce_header_size(&header, &dests)?; let open_options = RawStreamOpenOptions { header: header.clone(), - destination_identities: options.destination_identities, + destination_identities: dests, packet_tx: self.packet_tx.clone(), }; - let writer = ByteStreamWriter { - info: Arc::new(ByteStreamInfo::from_headers(header, byte_header)), - stream: Arc::new(Mutex::new(RawStream::open(open_options).await?)), - }; + let info = ByteStreamInfo::from_headers(header, byte_header); + let mut stream = RawStream::open(open_options).await?; + stream.write_file(path, should_compress).await?; + stream.close(None).await?; + Ok(info) + } +} + +/// Inline / compression eligibility evaluated over a send's recipients. +struct SendEligibility { + /// Every recipient advertises `clientProtocol >= 2`. + inline: bool, + /// Inline-eligible AND every recipient advertises `CAP_COMPRESSION_DEFLATE_RAW`. + compression: bool, +} - let info = (*writer.info).clone(); - writer.write_file_contents(path).await?; - writer.close().await?; +/// Evaluates inline/compression eligibility over a send's recipients. +/// +/// Recipients are the named `destinations`, or every remote participant for a broadcast +/// (empty `destinations`). An empty recipient set (empty room) is eligible for everything. +fn evaluate_eligibility( + registry: &dyn RemoteParticipantRegistry, + destinations: &[ParticipantIdentity], +) -> SendEligibility { + let recipients: Vec = + if destinations.is_empty() { registry.remote_identities() } else { destinations.to_vec() }; + let inline = recipients + .iter() + .all(|id| registry.remote_client_protocol(id) >= CLIENT_PROTOCOL_DATA_STREAM_V2); + let compression = inline + && recipients.iter().all(|id| { + registry.remote_capabilities(id).contains(&ClientCapability::CompressionDeflateRaw) + }); - Ok(info) + SendEligibility { inline, compression } +} + +struct MaybeCompressed<'a> { + uncompressed: &'a [u8], + compressed: Option>, +} + +impl<'a> MaybeCompressed<'a> { + fn new(uncompressed: &'a [u8]) -> Self { + Self { uncompressed, compressed: None } } + + /// Upconverts the Uncompressed variant into the Compressed variant, and returns a reference to + /// the compressed bytes as a result. + fn as_compressed(&mut self) -> Result<&[u8], std::io::Error> { + match &mut self.compressed { + Some(compressed) => Ok(&*compressed), + compressed_option @ None => { + let mut encoder = + flate2::write::DeflateEncoder::new(Vec::new(), flate2::Compression::default()); + encoder.write_all(self.uncompressed)?; + *compressed_option = Some(encoder.finish()?); + let Some(ref data) = compressed_option else { + unreachable!("compressed data just set") + }; + Ok(data) + } + } + } +} + +/// Whether the serialized header `DataPacket` fits within the MTU budget. +fn header_packet_fits( + header: &proto::data_stream::Header, + destinations: &[ParticipantIdentity], +) -> bool { + use prost::Message; + let packet = RawStream::create_header_packet(header.clone(), destinations.to_vec()); + packet.encoded_len() <= STREAM_CHUNK_SIZE_BYTES } -/// Maximum number of bytes to send in a single chunk. -static CHUNK_SIZE: usize = 15000; +/// Enforces the header-packet MTU budget on the chunked path (the inline path falls back +/// gracefully instead of erroring). +fn enforce_header_size( + header: &proto::data_stream::Header, + destinations: &[ParticipantIdentity], +) -> StreamResult<()> { + if header_packet_fits(header, destinations) { + Ok(()) + } else { + Err(StreamError::HeaderTooLarge) + } +} + +fn build_text_header( + options: &StreamTextOptions, + stream_id: String, + total_length: Option, + inline_content: Option>, + compression: CompressionType, +) -> (proto::data_stream::Header, proto::data_stream::TextHeader) { + let text_header = proto::data_stream::TextHeader { + operation_type: options.operation_type.unwrap_or_default() as i32, + version: options.version.unwrap_or_default(), + reply_to_stream_id: options.reply_to_stream_id.clone().unwrap_or_default(), + attached_stream_ids: options.attached_stream_ids.clone(), + generated: options.generated.unwrap_or_default(), + }; + let header = proto::data_stream::Header { + stream_id, + timestamp: Utc::now().timestamp_millis(), + topic: options.topic.clone(), + mime_type: TEXT_MIME_TYPE.to_owned(), + total_length, + encryption_type: proto::encryption::Type::None.into(), + attributes: options.attributes.clone(), + content_header: Some(proto::data_stream::header::ContentHeader::TextHeader( + text_header.clone(), + )), + inline_content, + compression: compression as i32, + }; + (header, text_header) +} + +fn build_byte_header( + options: &StreamByteOptions, + stream_id: String, + name: String, + total_length: Option, + inline_content: Option>, + compression: CompressionType, +) -> (proto::data_stream::Header, proto::data_stream::ByteHeader) { + let byte_header = proto::data_stream::ByteHeader { name }; + let header = proto::data_stream::Header { + stream_id, + timestamp: Utc::now().timestamp_millis(), + topic: options.topic.clone(), + mime_type: options.mime_type.clone().unwrap_or_else(|| BYTE_MIME_TYPE.to_owned()), + total_length, + encryption_type: proto::encryption::Type::None.into(), + attributes: options.attributes.clone(), + content_header: Some(proto::data_stream::header::ContentHeader::ByteHeader( + byte_header.clone(), + )), + inline_content, + compression: compression as i32, + }; + (header, byte_header) +} + +/// Max chunk content size AND the header-packet MTU budget. Kept below the ~16 KB +/// data-channel MTU for protocol/E2EE framing headroom. +const STREAM_CHUNK_SIZE_BYTES: usize = 15000; // Default MIME type to use for byte streams. static BYTE_MIME_TYPE: &str = "application/octet-stream"; @@ -509,9 +727,453 @@ static BYTE_MIME_TYPE: &str = "application/octet-stream"; /// Default MIME type to use for text streams. static TEXT_MIME_TYPE: &str = "text/plain"; +/// Default name for `send_bytes` byte-stream headers. +static BYTE_DEFAULT_NAME: &str = "unknown"; + #[cfg(test)] mod tests { use super::*; + use livekit_api::signal_client::{CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT}; + use std::sync::Mutex as StdMutex; + + // --- Fake recipient registry --------------------------------------------------------- + + struct FakeRegistry { + remotes: HashMap)>, + } + + impl FakeRegistry { + fn new() -> Self { + Self { remotes: HashMap::new() } + } + + fn add(mut self, id: &str, client_protocol: i32, caps: &[ClientCapability]) -> Self { + self.remotes.insert(id.to_string(), (client_protocol, caps.to_vec())); + self + } + } + + impl RemoteParticipantRegistry for FakeRegistry { + fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { + self.remotes.get(&identity.0).map(|(p, _)| *p).unwrap_or(0) + } + fn remote_capabilities(&self, identity: &ParticipantIdentity) -> Vec { + self.remotes.get(&identity.0).map(|(_, c)| c.clone()).unwrap_or_default() + } + fn remote_identities(&self) -> Vec { + self.remotes.keys().map(|k| ParticipantIdentity(k.clone())).collect() + } + } + + fn pre_v2_room() -> FakeRegistry { + FakeRegistry::new() + .add("alice", CLIENT_PROTOCOL_DEFAULT, &[]) + .add("bob", CLIENT_PROTOCOL_DEFAULT, &[]) + .add("jim", CLIENT_PROTOCOL_DATA_STREAM_RPC, &[]) + } + + fn all_v2_room() -> FakeRegistry { + FakeRegistry::new() + .add( + "alice", + CLIENT_PROTOCOL_DATA_STREAM_V2, + &[ClientCapability::CompressionDeflateRaw], + ) + .add("bob", CLIENT_PROTOCOL_DATA_STREAM_V2, &[ClientCapability::CompressionDeflateRaw]) + .add("noCompression", CLIENT_PROTOCOL_DATA_STREAM_V2, &[]) + } + + fn mixed_room() -> FakeRegistry { + FakeRegistry::new() + .add("alice", CLIENT_PROTOCOL_DEFAULT, &[]) + .add("bob", CLIENT_PROTOCOL_DATA_STREAM_V2, &[ClientCapability::CompressionDeflateRaw]) + .add("jim", CLIENT_PROTOCOL_DATA_STREAM_V2, &[ClientCapability::CompressionDeflateRaw]) + .add("mallory", CLIENT_PROTOCOL_DEFAULT, &[]) + .add("noCompression", CLIENT_PROTOCOL_DATA_STREAM_V2, &[]) + } + + // --- Capture harness ----------------------------------------------------------------- + + type Sent = Arc>>; + + fn setup() -> (OutgoingStreamManager, Sent) { + let (manager, mut packet_rx) = OutgoingStreamManager::new(); + let sent: Sent = Arc::new(StdMutex::new(Vec::new())); + let sink = sent.clone(); + tokio::spawn(async move { + while let Ok((packet, responder)) = packet_rx.recv().await { + sink.lock().unwrap().push(packet); + let _ = responder.respond(Ok(())); + } + }); + (manager, sent) + } + + fn ids(list: &[&str]) -> Vec { + list.iter().map(|s| ParticipantIdentity(s.to_string())).collect() + } + + fn text_opts(topic: &str, dests: &[&str]) -> StreamTextOptions { + StreamTextOptions { + topic: topic.to_string(), + destination_identities: ids(dests), + ..Default::default() + } + } + + fn byte_opts(topic: &str, dests: &[&str]) -> StreamByteOptions { + StreamByteOptions { + topic: topic.to_string(), + destination_identities: ids(dests), + ..Default::default() + } + } + + fn header(p: &proto::DataPacket) -> &proto::data_stream::Header { + match p.value.as_ref().unwrap() { + proto::data_packet::Value::StreamHeader(h) => h, + _ => panic!("expected stream header"), + } + } + + fn chunk(p: &proto::DataPacket) -> &proto::data_stream::Chunk { + match p.value.as_ref().unwrap() { + proto::data_packet::Value::StreamChunk(c) => c, + _ => panic!("expected stream chunk"), + } + } + + fn is_text_header(h: &proto::data_stream::Header) -> bool { + matches!(h.content_header, Some(proto::data_stream::header::ContentHeader::TextHeader(_))) + } + + fn is_byte_header(h: &proto::data_stream::Header) -> bool { + matches!(h.content_header, Some(proto::data_stream::header::ContentHeader::ByteHeader(_))) + } + + fn assert_trailer(p: &proto::DataPacket) { + match p.value.as_ref().unwrap() { + proto::data_packet::Value::StreamTrailer(t) => assert_eq!(t.reason, ""), + _ => panic!("expected stream trailer"), + } + } + + fn deflate_raw_i32() -> i32 { + CompressionType::DeflateRaw as i32 + } + fn none_i32() -> i32 { + CompressionType::None as i32 + } + + /// ~50 KB of deterministic, somewhat-compressible text (repeated marker + pseudo-random + /// lowercase). Compresses to >15 KB (so it can't inline) but well under its raw size. + fn somewhat_compressible(blocks: usize) -> String { + let mut s = String::new(); + let mut state: u64 = 0x1234_5678_9abc_def0; + for _ in 0..blocks { + s.push_str("hello world"); + for _ in 0..1000 { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + s.push((b'a' + ((state >> 33) % 26) as u8) as char); + } + } + s + } + + // --- Pre-v2 room: legacy, uncompressed, multi-packet --------------------------------- + + #[tokio::test] + async fn pre_v2_short_text_is_legacy_multipacket() { + let (m, sent) = setup(); + m.send_text("hello world", text_opts("chat", &[]), &pre_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 3); + let h = header(&p[0]); + assert!(is_text_header(h)); + assert_eq!(h.topic, "chat"); + assert_eq!(h.compression, none_i32()); + assert!(h.inline_content.is_none()); + let c = chunk(&p[1]); + assert_eq!(c.chunk_index, 0); + assert_eq!(c.content, b"hello world"); + assert_trailer(&p[2]); + } + + #[tokio::test] + async fn pre_v2_long_text_splits_at_mtu() { + let (m, sent) = setup(); + let text = "A".repeat(40_000); + m.send_text(&text, text_opts("chat", &[]), &pre_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 5); // header + 3 chunks + trailer + assert_eq!(header(&p[0]).compression, none_i32()); + assert_eq!(chunk(&p[1]).content.len(), 15_000); + assert_eq!(chunk(&p[2]).content.len(), 15_000); + assert_eq!(chunk(&p[3]).content.len(), 10_000); + assert_eq!(chunk(&p[1]).chunk_index, 0); + assert_eq!(chunk(&p[3]).chunk_index, 2); + assert_trailer(&p[4]); + } + + #[tokio::test] + async fn pre_v2_bytes_is_legacy_multipacket() { + let (m, sent) = setup(); + m.send_bytes([0u8, 1, 2, 3], byte_opts("blob", &[]), &pre_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 3); + let h = header(&p[0]); + assert!(is_byte_header(h)); + assert_eq!(h.compression, none_i32()); + assert!(h.inline_content.is_none()); + assert_eq!(chunk(&p[1]).content, vec![0, 1, 2, 3]); + assert_trailer(&p[2]); + } + + // --- All-v2 room: inline + compression ----------------------------------------------- + + #[tokio::test] + async fn v2_short_compressible_text_inlines_compressed() { + let (m, sent) = setup(); + let text = "hello hello compressible world"; + m.send_text(text, text_opts("chat", &["alice", "bob"]), &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert!(is_text_header(h)); + assert_eq!(h.compression, deflate_raw_i32()); + let inline = h.inline_content.as_ref().unwrap(); + assert_ne!(inline.as_slice(), text.as_bytes()); // compressed, not raw + } + + #[tokio::test] + async fn v2_short_incompressible_text_inlines_raw() { + let (m, sent) = setup(); + m.send_text("short", text_opts("chat", &["alice", "bob"]), &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert_eq!(h.compression, none_i32()); + assert_eq!(h.inline_content.as_ref().unwrap().as_slice(), b"short"); + } + + #[tokio::test] + async fn v2_no_compression_cap_inlines_raw() { + let (m, sent) = setup(); + let text = "hello hello compressible world"; + m.send_text(text, text_opts("chat", &["noCompression"]), &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); // inline (gated on protocol) still happens + let h = header(&p[0]); + assert_eq!(h.compression, none_i32()); // compression gated off by missing cap + assert_eq!(h.inline_content.as_ref().unwrap().as_slice(), text.as_bytes()); + } + + #[tokio::test] + async fn v2_large_highly_compressible_text_still_inlines() { + let (m, sent) = setup(); + let text = "hello world".repeat(20_000); + m.send_text(&text, text_opts("chat", &["alice", "bob"]), &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert_eq!(h.compression, deflate_raw_i32()); + assert!(h.inline_content.as_ref().unwrap().len() < text.len()); + } + + #[tokio::test] + async fn v2_somewhat_compressible_text_is_compressed_multipacket() { + let (m, sent) = setup(); + let text = somewhat_compressible(50); + m.send_text(&text, text_opts("chat", &["alice", "bob"]), &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + let h = header(&p[0]); + assert_eq!(h.compression, deflate_raw_i32()); + assert!(h.inline_content.is_none()); + let chunks: Vec<_> = p[1..p.len() - 1].iter().map(chunk).collect(); + // Multi-packet, but fewer chunks than an uncompressed send would need (ceil(len/15000)). + let uncompressed_chunks = text.len().div_ceil(STREAM_CHUNK_SIZE_BYTES); + assert!(chunks.len() >= 2); + assert!(chunks.len() < uncompressed_chunks); + assert_eq!(chunks[0].content.len(), STREAM_CHUNK_SIZE_BYTES); // first chunk is full MTU + let total: usize = chunks.iter().map(|c| c.content.len()).sum(); + assert!(total < text.len()); // compressed + assert_trailer(p.last().unwrap()); + } + + #[tokio::test] + async fn v2_compress_false_short_inlines_raw() { + let (m, sent) = setup(); + let text = "hello hello compressible world"; + let opts = + StreamTextOptions { compress: Some(false), ..text_opts("chat", &["alice", "bob"]) }; + m.send_text(text, opts, &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert_eq!(h.compression, none_i32()); + assert_eq!(h.inline_content.as_ref().unwrap().as_slice(), text.as_bytes()); + } + + #[tokio::test] + async fn v2_compress_false_large_is_uncompressed_multipacket() { + let (m, sent) = setup(); + let text = "B".repeat(50_000); + let opts = + StreamTextOptions { compress: Some(false), ..text_opts("chat", &["alice", "bob"]) }; + m.send_text(&text, opts, &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 6); // header + 4 chunks + trailer + assert_eq!(header(&p[0]).compression, none_i32()); + assert_eq!(chunk(&p[1]).content.len(), 15_000); + } + + // --- Incremental writers never compress or inline ------------------------------------ + + #[tokio::test] + async fn stream_text_never_compresses_or_inlines() { + let (m, sent) = setup(); + let writer = m.stream_text(text_opts("chat", &["noCompression"])).await.unwrap(); + assert_eq!(sent.lock().unwrap().len(), 1); + let h0 = sent.lock().unwrap()[0].clone(); + assert!(is_text_header(header(&h0))); + assert_eq!(header(&h0).compression, none_i32()); + assert!(header(&h0).inline_content.is_none()); + + writer.write("hello world").await.unwrap(); + assert_eq!(sent.lock().unwrap().len(), 2); + assert_eq!(chunk(&sent.lock().unwrap()[1]).content, b"hello world"); + + writer.close().await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 3); + assert_trailer(&p[2]); + } + + #[tokio::test] + async fn stream_bytes_never_compresses_or_inlines() { + let (m, sent) = setup(); + let writer = m.stream_bytes(byte_opts("blob", &["noCompression"])).await.unwrap(); + assert_eq!(sent.lock().unwrap().len(), 1); + assert_eq!(header(&sent.lock().unwrap()[0]).compression, none_i32()); + + writer.write(&[0u8, 1, 2, 3]).await.unwrap(); + assert_eq!(chunk(&sent.lock().unwrap()[1]).content, vec![0, 1, 2, 3]); + + writer.close().await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 3); + assert_trailer(&p[2]); + } + + // --- send_bytes inline behavior ------------------------------------------------------ + + #[tokio::test] + async fn v2_send_bytes_short_compressible_inlines_compressed() { + let (m, sent) = setup(); + let payload = "hello hello compressible world".as_bytes().to_vec(); + let mut opts = byte_opts("blob", &["alice", "bob"]); + opts.attributes.insert("foo".to_string(), "bar".to_string()); + let info = m.send_bytes(&payload, opts, &all_v2_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert!(is_byte_header(h)); + assert_eq!(h.compression, deflate_raw_i32()); + assert_ne!(h.inline_content.as_ref().unwrap().as_slice(), payload.as_slice()); + assert_eq!(info.name, "unknown"); + assert_eq!(info.mime_type, "application/octet-stream"); + assert_eq!(info.total_length, Some(payload.len() as u64)); + assert_eq!(info.attributes.get("foo"), Some(&"bar".to_string())); + } + + // --- Mixed room ---------------------------------------------------------------------- + + #[tokio::test] + async fn mixed_broadcast_falls_back_to_legacy() { + let (m, sent) = setup(); + m.send_text("hello world", text_opts("chat", &[]), &mixed_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 3); + assert_eq!(header(&p[0]).compression, none_i32()); + assert!(header(&p[0]).inline_content.is_none()); + assert_eq!(chunk(&p[1]).content, b"hello world"); + } + + #[tokio::test] + async fn mixed_targeted_v2_subset_inlines_compressed() { + let (m, sent) = setup(); + let text = "hello hello compressible world"; + m.send_text(text, text_opts("chat", &["bob", "jim"]), &mixed_room()).await.unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert_eq!(h.compression, deflate_raw_i32()); + assert_ne!(h.inline_content.as_ref().unwrap().as_slice(), text.as_bytes()); + } + + #[tokio::test] + async fn mixed_targeted_subset_missing_cap_inlines_uncompressed() { + let (m, sent) = setup(); + let text = "hello hello compressible world"; + m.send_text(text, text_opts("chat", &["bob", "jim", "noCompression"]), &mixed_room()) + .await + .unwrap(); + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 1); + let h = header(&p[0]); + assert_eq!(h.compression, none_i32()); + assert_eq!(h.inline_content.as_ref().unwrap().as_slice(), text.as_bytes()); + } + + // --- send_file ----------------------------------------------------------------------- + + async fn write_temp_file(bytes: &[u8]) -> std::path::PathBuf { + let path = std::env::temp_dir().join(format!("lk_ds_test_{}.bin", create_random_uuid())); + tokio::fs::write(&path, bytes).await.unwrap(); + path + } + + #[tokio::test] + async fn send_file_never_inlines_and_compresses_when_eligible() { + let (m, sent) = setup(); + let path = write_temp_file(&vec![0x01u8; 10_000]).await; + m.send_file(&path, byte_opts("file", &["alice", "bob"]), &all_v2_room()).await.unwrap(); + let _ = tokio::fs::remove_file(&path).await; + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 3); // header + 1 chunk + trailer, NOT inline + let h = header(&p[0]); + assert!(is_byte_header(h)); + assert_eq!(h.compression, deflate_raw_i32()); + assert!(h.inline_content.is_none()); + assert!(chunk(&p[1]).content.len() < 10_000); // compressed + assert_trailer(&p[2]); + } + + #[tokio::test] + async fn send_file_uncompressed_splits_at_mtu() { + let (m, sent) = setup(); + let path = write_temp_file(&vec![0x07u8; 20_000]).await; + m.send_file(&path, byte_opts("file", &[]), &pre_v2_room()).await.unwrap(); + let _ = tokio::fs::remove_file(&path).await; + let p = sent.lock().unwrap().clone(); + assert_eq!(p.len(), 4); // header + 15000 + 5000 + trailer + assert_eq!(header(&p[0]).compression, none_i32()); + assert_eq!(chunk(&p[1]).content.len(), 15_000); + assert_eq!(chunk(&p[2]).content.len(), 5_000); + assert_eq!(chunk(&p[2]).chunk_index, 1); + assert_trailer(&p[3]); + } + + // --- Header size limit --------------------------------------------------------------- + + #[tokio::test] + async fn oversized_attributes_on_chunked_path_errors() { + let (m, _sent) = setup(); + let mut opts = text_opts("chat", &[]); // pre-v2 below => chunked path + opts.attributes.insert("big".to_string(), "x".repeat(20_000)); + let result = m.send_text("hello", opts, &pre_v2_room()).await; + assert!(matches!(result, Err(StreamError::HeaderTooLarge))); + } // Regression test for CLT-2773: dropping a `RawStream` on a thread that has // no Tokio runtime in TLS (e.g. the .NET GC finalizer thread in the Unity diff --git a/livekit/src/room/e2ee/mod.rs b/livekit/src/room/e2ee/mod.rs index e1235d81d..21f6cfca7 100644 --- a/livekit/src/room/e2ee/mod.rs +++ b/livekit/src/room/e2ee/mod.rs @@ -22,13 +22,7 @@ pub mod manager; /// Provider implementations for data track. pub(crate) mod data_track; -#[derive(Default, Debug, Clone, Copy, PartialEq, Eq)] -pub enum EncryptionType { - #[default] - None, - Gcm, - Custom, -} +pub(crate) use livekit_common::EncryptionType; #[derive(Clone)] pub struct E2eeOptions { diff --git a/livekit/src/room/id.rs b/livekit/src/room/id.rs index 800496371..c1ea11a74 100644 --- a/livekit/src/room/id.rs +++ b/livekit/src/room/id.rs @@ -20,30 +20,17 @@ const ROOM_PREFIX: &str = "RM_"; const PARTICIPANT_PREFIX: &str = "PA_"; const TRACK_PREFIX: &str = "TR_"; +pub use livekit_common::ParticipantIdentity; + #[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] pub struct ParticipantSid(String); -#[derive(Clone, Default, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] -pub struct ParticipantIdentity(pub String); - #[derive(Clone, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] pub struct TrackSid(String); #[derive(Clone, Default, Debug, Eq, Hash, PartialEq, PartialOrd, Ord)] pub struct RoomSid(String); -impl From for ParticipantIdentity { - fn from(value: String) -> Self { - Self(value) - } -} - -impl From<&str> for ParticipantIdentity { - fn from(value: &str) -> Self { - Self(value.to_string()) - } -} - macro_rules! impl_string_into { ($from:ty) => { impl From<$from> for String { @@ -67,7 +54,6 @@ macro_rules! impl_string_into { } impl_string_into!(ParticipantSid); -impl_string_into!(ParticipantIdentity); impl_string_into!(TrackSid); impl_string_into!(RoomSid); diff --git a/livekit/src/room/mod.rs b/livekit/src/room/mod.rs index c84e42538..72a0d3205 100644 --- a/livekit/src/room/mod.rs +++ b/livekit/src/room/mod.rs @@ -48,7 +48,7 @@ use tokio::sync::{ pub use self::{ data_stream::*, e2ee::{manager::E2eeManager, E2eeOptions}, - participant::{ParticipantKind, ParticipantKindDetail, ParticipantState}, + participant::{ClientCapability, ParticipantKind, ParticipantKindDetail, ParticipantState}, }; pub use crate::rtc_engine::SimulateScenario; use crate::{ @@ -63,7 +63,7 @@ use crate::{ utils::{observer::Dispatcher, promise::Promise}, }; -pub mod data_stream; +pub use livekit_data_stream as data_stream; pub mod data_track; pub mod e2ee; pub mod id; @@ -577,6 +577,7 @@ impl Room { e2ee_manager.encryption_type(), pi.permission, pi.client_protocol, + pi.capabilities.iter().filter_map(|&c| ClientCapability::try_from(c).ok()).collect(), ); let dispatcher = Dispatcher::::default(); @@ -759,6 +760,10 @@ impl Room { pi.joined_at_ms, pi.permission, pi.client_protocol, + pi.capabilities + .iter() + .filter_map(|&c| ClientCapability::try_from(c).ok()) + .collect(), ) }; participant.update_info(pi.clone()); @@ -1195,6 +1200,10 @@ impl RoomSession { pi.joined_at_ms, pi.permission, pi.client_protocol, + pi.capabilities + .iter() + .filter_map(|&c| ClientCapability::try_from(c).ok()) + .collect(), ) }; @@ -2000,6 +2009,7 @@ impl RoomSession { joined_at: i64, permission: Option, client_protocol: i32, + capabilities: Vec, ) -> RemoteParticipant { let participant = RemoteParticipant::new( self.rtc_engine.clone(), @@ -2015,6 +2025,7 @@ impl RoomSession { self.options.auto_subscribe, permission, client_protocol, + capabilities, ); participant.on_track_published({ @@ -2143,6 +2154,12 @@ impl RoomSession { let mut participants = self.remote_participants.write(); participants.remove(&remote_participant.identity()); + drop(participants); + + // Terminate any data streams this participant was still sending; otherwise their + // readers would hang waiting for chunks that will never arrive. + self.incoming_stream_manager.abort_streams_from(remote_participant.identity().as_str()); + self.dispatcher.dispatch(&RoomEvent::ParticipantDisconnected(remote_participant)); } @@ -2233,6 +2250,20 @@ impl RoomSession { } } +impl livekit_common::RemoteParticipantRegistry for RoomSession { + fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { + self.get_remote_client_protocol(identity) + } + + fn remote_capabilities(&self, identity: &ParticipantIdentity) -> Vec { + self.remote_participants.read().get(identity).map(|p| p.capabilities()).unwrap_or_default() + } + + fn remote_identities(&self) -> Vec { + self.remote_participants.read().keys().cloned().collect() + } +} + /// Receives stream readers for newly-opened streams and dispatches room events. /// /// Intercepts text streams on RPC topics (`lk.rpc_request`, `lk.rpc_response`) diff --git a/livekit/src/room/participant/local_participant.rs b/livekit/src/room/participant/local_participant.rs index 12c4ddad9..a5be6f0e8 100644 --- a/livekit/src/room/participant/local_participant.rs +++ b/livekit/src/room/participant/local_participant.rs @@ -23,8 +23,8 @@ use std::{ }; use super::{ - ConnectionQuality, ParticipantInner, ParticipantKind, ParticipantKindDetail, ParticipantState, - ParticipantTrackPermission, + ClientCapability, ConnectionQuality, ParticipantInner, ParticipantKind, ParticipantKindDetail, + ParticipantState, ParticipantTrackPermission, }; use crate::{ data_stream::{ @@ -110,6 +110,7 @@ impl LocalParticipant { encryption_type: EncryptionType, permission: Option, client_protocol: i32, + capabilities: Vec, ) -> Self { Self { inner: super::new_inner( @@ -125,6 +126,7 @@ impl LocalParticipant { joined_at, permission, client_protocol, + capabilities, ), local: Arc::new(LocalInfo { events: LocalEvents::default(), @@ -910,7 +912,8 @@ impl LocalParticipant { text: &str, options: StreamTextOptions, ) -> StreamResult { - self.session().unwrap().outgoing_stream_manager.send_text(text, options).await + let session = self.session().unwrap(); + session.outgoing_stream_manager.send_text(text, options, session.as_ref()).await } /// Send a file on disk to participants in the room. @@ -930,7 +933,8 @@ impl LocalParticipant { path: impl AsRef, options: StreamByteOptions, ) -> StreamResult { - self.session().unwrap().outgoing_stream_manager.send_file(path, options).await + let session = self.session().unwrap(); + session.outgoing_stream_manager.send_file(path, options, session.as_ref()).await } /// Send an in-memory blob of bytes to participants in the room. @@ -947,7 +951,8 @@ impl LocalParticipant { data: impl AsRef<[u8]>, options: StreamByteOptions, ) -> StreamResult { - self.session().unwrap().outgoing_stream_manager.send_bytes(data, options).await + let session = self.session().unwrap(); + session.outgoing_stream_manager.send_bytes(data, options, session.as_ref()).await } /// Stream text incrementally to participants in the room. diff --git a/livekit/src/room/participant/mod.rs b/livekit/src/room/participant/mod.rs index 5dcdc83d7..9f8abad0e 100644 --- a/livekit/src/room/participant/mod.rs +++ b/livekit/src/room/participant/mod.rs @@ -85,6 +85,8 @@ pub enum DisconnectReason { AgentError, } +pub use livekit_common::ClientCapability; + #[derive(Debug, Clone)] pub enum Participant { Local(LocalParticipant), @@ -146,6 +148,7 @@ struct ParticipantInfo { pub joined_at: i64, pub permission: Option, pub client_protocol: i32, + pub capabilities: Vec, } type TrackMutedHandler = Box; @@ -197,6 +200,7 @@ pub(super) fn new_inner( joined_at: i64, permission: Option, client_protocol: i32, + capabilities: Vec, ) -> Arc { Arc::new(ParticipantInner { rtc_engine, @@ -216,6 +220,7 @@ pub(super) fn new_inner( joined_at, permission, client_protocol, + capabilities, }), track_publications: Default::default(), events: Default::default(), @@ -269,6 +274,8 @@ pub(super) fn update_info( } info.client_protocol = new_info.client_protocol; + info.capabilities = + new_info.capabilities.iter().filter_map(|&c| ClientCapability::try_from(c).ok()).collect(); } pub(super) fn set_speaking( diff --git a/livekit/src/room/participant/remote_participant.rs b/livekit/src/room/participant/remote_participant.rs index da0f6a663..6909cdc0d 100644 --- a/livekit/src/room/participant/remote_participant.rs +++ b/livekit/src/room/participant/remote_participant.rs @@ -25,8 +25,8 @@ use livekit_runtime::timeout; use parking_lot::Mutex; use super::{ - ConnectionQuality, ParticipantInner, ParticipantKind, ParticipantKindDetail, ParticipantState, - TrackKind, + ClientCapability, ConnectionQuality, ParticipantInner, ParticipantKind, ParticipantKindDetail, + ParticipantState, TrackKind, }; use crate::{prelude::*, rtc_engine::RtcEngine, track::TrackError}; @@ -86,6 +86,7 @@ impl RemoteParticipant { auto_subscribe: bool, permission: Option, client_protocol: i32, + capabilities: Vec, ) -> Self { Self { inner: super::new_inner( @@ -101,6 +102,7 @@ impl RemoteParticipant { joined_at, permission, client_protocol, + capabilities, ), remote: Arc::new(RemoteInfo { events: Default::default(), auto_subscribe }), } @@ -577,6 +579,11 @@ impl RemoteParticipant { self.inner.info.read().client_protocol } + /// The capabilities this remote participant's client advertised at join. + pub fn capabilities(&self) -> Vec { + self.inner.info.read().capabilities.clone() + } + pub fn is_encrypted(&self) -> bool { *self.inner.is_encrypted.read() } diff --git a/livekit/src/room/rpc/mod.rs b/livekit/src/room/rpc/mod.rs index 8f14647fa..19c03433e 100644 --- a/livekit/src/room/rpc/mod.rs +++ b/livekit/src/room/rpc/mod.rs @@ -23,7 +23,9 @@ pub use server::{HandleRequestOptions, RpcServerManager}; use crate::data_stream::{StreamResult, StreamTextOptions, TextStreamInfo}; use crate::room::id::ParticipantIdentity; +use crate::room::participant::ClientCapability; use livekit_protocol::RpcError as RpcError_Proto; +use livekit_common::RemoteParticipantRegistry; use std::{error::Error, fmt::Display, future::Future, time::Duration}; // RPC protocol version constants (distinct from client_protocol; this is the @@ -45,7 +47,7 @@ pub(crate) const ATTR_VERSION: &str = "lk.rpc_request_version"; /// /// Decouples the RPC managers from concrete engine/session types, /// enabling in-memory unit testing with a mock transport. -pub(crate) trait RpcTransport: Send + Sync { +pub(crate) trait RpcTransport: RemoteParticipantRegistry { /// Send a data packet (used for v1 RPC packets and ACKs). fn publish_data( &self, @@ -59,9 +61,6 @@ pub(crate) trait RpcTransport: Send + Sync { options: StreamTextOptions, ) -> impl Future> + Send; - /// Look up a remote participant's client_protocol value. - fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32; - /// Get the server version string, if available. fn server_version(&self) -> Option; } @@ -69,6 +68,20 @@ pub(crate) trait RpcTransport: Send + Sync { /// Production implementation of `RpcTransport` backed by a `RoomSession`. pub(crate) struct SessionTransport(pub(crate) std::sync::Arc); +impl RemoteParticipantRegistry for SessionTransport { + fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { + self.0.remote_client_protocol(identity) + } + + fn remote_capabilities(&self, identity: &ParticipantIdentity) -> Vec { + self.0.remote_capabilities(identity) + } + + fn remote_identities(&self) -> Vec { + self.0.remote_identities() + } +} + impl RpcTransport for SessionTransport { async fn publish_data( &self, @@ -86,11 +99,7 @@ impl RpcTransport for SessionTransport { text: &str, options: StreamTextOptions, ) -> StreamResult { - self.0.outgoing_stream_manager.send_text(text, options).await - } - - fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { - self.0.get_remote_client_protocol(identity) + self.0.outgoing_stream_manager.send_text(text, options, self.0.as_ref()).await } fn server_version(&self) -> Option { diff --git a/livekit/src/room/rpc/tests.rs b/livekit/src/room/rpc/tests.rs index b4fc3d5e9..096b963f5 100644 --- a/livekit/src/room/rpc/tests.rs +++ b/livekit/src/room/rpc/tests.rs @@ -18,11 +18,13 @@ use crate::data_stream::{ }; use crate::e2ee::EncryptionType; use crate::room::id::ParticipantIdentity; +use crate::room::participant::ClientCapability; use crate::room::RoomError; use bytes::Bytes; use chrono::Utc; use livekit_api::signal_client::{CLIENT_PROTOCOL_DATA_STREAM_RPC, CLIENT_PROTOCOL_DEFAULT}; use livekit_protocol as proto; +use livekit_common::RemoteParticipantRegistry; use parking_lot::Mutex as ParkingMutex; use std::collections::HashMap; use std::sync::Arc; @@ -132,15 +134,29 @@ impl RpcTransport for MockTransport { attached_stream_ids: vec![], generated: false, encryption_type: EncryptionType::None, + #[cfg(feature = "__lk-e2e-test")] + is_compressed: false, + #[cfg(feature = "__lk-e2e-test")] + is_inline: false, }) } + fn server_version(&self) -> Option { + self.server_ver.clone() + } +} + +impl RemoteParticipantRegistry for MockTransport { fn remote_client_protocol(&self, identity: &ParticipantIdentity) -> i32 { self.remote_protocols.get(&identity.0).copied().unwrap_or(CLIENT_PROTOCOL_DEFAULT) } - fn server_version(&self) -> Option { - self.server_ver.clone() + fn remote_capabilities(&self, _identity: &ParticipantIdentity) -> Vec { + Vec::new() + } + + fn remote_identities(&self) -> Vec { + self.remote_protocols.keys().map(|k| ParticipantIdentity(k.clone())).collect() } } @@ -170,6 +186,10 @@ fn make_text_reader( attached_stream_ids: vec![], generated: false, encryption_type: EncryptionType::None, + #[cfg(feature = "__lk-e2e-test")] + is_compressed: false, + #[cfg(feature = "__lk-e2e-test")] + is_inline: false, }, rx, ) diff --git a/livekit/src/utils/take_cell.rs b/livekit/src/utils/take_cell.rs index c22967ea1..6f3b686ab 100644 --- a/livekit/src/utils/take_cell.rs +++ b/livekit/src/utils/take_cell.rs @@ -26,18 +26,6 @@ impl TakeCell { Self { value: Arc::new(RwLock::new(Some(value))) } } - /// Take ownership of the value in the cell if it matches some predicate. - /// - /// This method will only take the value if the provided predicate returns `true` when called with the current value. - /// If the predicate returns `false` or the value has already been taken, this method returns `None`. - pub(crate) fn take_if_raw(&self, predicate: impl FnOnce(&T) -> bool) -> Option { - if self.value.read().as_ref().map_or(false, |v| predicate(v)) { - self.take() - } else { - None - } - } - /// Take ownership of the value in the cell. If the value has, /// already been taken, the result is `None`. pub fn take(&self) -> Option { @@ -83,14 +71,6 @@ mod tests { assert_eq!(cell.is_taken(), true); } - #[test] - fn test_take_if_raw() { - let cell = TakeCell::new(1); - assert_eq!(cell.take_if_raw(|value| *value == 2), None); - assert_eq!(cell.take_if_raw(|value| *value == 1), Some(1)); - assert_eq!(cell.take_if_raw(|value| *value == 1), None); - } - #[test] fn test_debug() { let cell = TakeCell::new(1); diff --git a/livekit/tests/data_stream_test.rs b/livekit/tests/data_stream_test.rs index 911a37548..882bd24a6 100644 --- a/livekit/tests/data_stream_test.rs +++ b/livekit/tests/data_stream_test.rs @@ -18,6 +18,7 @@ use { anyhow::{anyhow, Ok, Result}, chrono::{TimeDelta, Utc}, livekit::{RoomEvent, StreamByteOptions, StreamReader, StreamTextOptions}, + rand::{rngs::StdRng, RngCore, SeedableRng}, std::time::Duration, tokio::{time::timeout, try_join}, }; @@ -46,6 +47,8 @@ async fn test_send_bytes() -> Result<()> { assert!(stream_info.total_length.is_some()); assert_eq!(stream_info.mime_type, "application/octet-stream"); assert_eq!(stream_info.topic, "some-topic"); + assert_eq!(stream_info.is_compressed, true); + assert_eq!(stream_info.is_inline, true); Ok(()) }; @@ -70,6 +73,164 @@ async fn test_send_bytes() -> Result<()> { Ok(()) } +/// End-to-end round-trip of a large, somewhat-compressible text. Both peers are Rust SDK +/// clients advertising data streams v2 + deflate-raw, so the sender compresses across multiple +/// chunks and the receiver decompresses — validating the v2 compression path on the real wire. +#[cfg(feature = "__lk-e2e-test")] +#[tokio::test] +async fn test_send_large_compressible_text() -> Result<()> { + let mut rooms = test_rooms(2).await?; + let (sending_room, _) = rooms.pop().unwrap(); + let (_, mut receiving_event_rx) = rooms.pop().unwrap(); + + // ~50 KB of deterministic pseudo-random lowercase: too big to inline, compresses well + // under its raw size, exercising the chunked-compressed path. + let mut text = String::new(); + let mut state: u64 = 0x1234_5678_9abc_def0; + for _ in 0..50_000 { + state = state.wrapping_mul(6364136223846793005).wrapping_add(1442695040888963407); + text.push((b'a' + ((state >> 33) % 26) as u8) as char); + } + let expected = text.clone(); + + let send = async move { + let options = StreamTextOptions { topic: "some-topic".into(), ..Default::default() }; + let stream_info = sending_room.local_participant().send_text(&text, options).await?; + assert_eq!(stream_info.is_compressed, true); + assert_eq!(stream_info.is_inline, false); + Ok(()) + }; + let receive = async move { + while let Some(event) = receiving_event_rx.recv().await { + let RoomEvent::TextStreamOpened { reader, topic, .. } = event else { + continue; + }; + assert_eq!(topic, "some-topic"); + let reader = reader.take().ok_or_else(|| anyhow!("Failed to take reader"))?; + assert_eq!(reader.read_all().await?, expected); + break; + } + Ok(()) + }; + + timeout(Duration::from_secs(10), async { try_join!(send, receive) }).await??; + Ok(()) +} + +/// End-to-end round-trip of a large in-memory byte payload, validating the v2 byte-stream path. +#[cfg(feature = "__lk-e2e-test")] +#[tokio::test] +async fn test_send_large_incompressible_random_bytes() -> Result<()> { + let mut rooms = test_rooms(2).await?; + let (sending_room, _) = rooms.pop().unwrap(); + let (_, mut receiving_event_rx) = rooms.pop().unwrap(); + + // Uniform random bytes are genuinely incompressible: deflate cannot shrink them, so the + // send path must choose CompressionType::None. Seeded for a deterministic, reproducible test. + let mut rng = StdRng::seed_from_u64(0xC0FFEE); + let mut payload = vec![0u8; 1_800_000]; + rng.fill_bytes(&mut payload); + let expected = payload.clone(); + + let send = async move { + let options = StreamByteOptions { topic: "some-topic".into(), ..Default::default() }; + let stream_info = sending_room.local_participant().send_bytes(&payload, options).await?; + assert_eq!(stream_info.is_compressed, false, "is_compressed was not false"); + assert_eq!(stream_info.is_inline, false, "is_inline was not false"); + Ok(()) + }; + let receive = async move { + while let Some(event) = receiving_event_rx.recv().await { + let RoomEvent::ByteStreamOpened { reader, topic, .. } = event else { + continue; + }; + assert_eq!(topic, "some-topic"); + let reader = reader.take().ok_or_else(|| anyhow!("Failed to take reader"))?; + assert_eq!(reader.read_all().await?, expected); + break; + } + Ok(()) + }; + + timeout(Duration::from_secs(10), async { try_join!(send, receive) }).await??; + Ok(()) +} + +/// End-to-end round-trip of a large in-memory byte payload, validating the v2 byte-stream path. +#[cfg(feature = "__lk-e2e-test")] +#[tokio::test] +async fn test_send_large_bytes() -> Result<()> { + let mut rooms = test_rooms(2).await?; + let (sending_room, _) = rooms.pop().unwrap(); + let (_, mut receiving_event_rx) = rooms.pop().unwrap(); + + let payload: Vec = (0..50_000u32).map(|i| (i % 251) as u8).collect(); + let expected = payload.clone(); + + let send = async move { + let options = StreamByteOptions { topic: "some-topic".into(), ..Default::default() }; + let stream_info = sending_room.local_participant().send_bytes(&payload, options).await?; + assert_eq!(stream_info.is_compressed, true, "is_compressed was not true"); + assert_eq!(stream_info.is_inline, true, "is_inline was not true"); + Ok(()) + }; + let receive = async move { + while let Some(event) = receiving_event_rx.recv().await { + let RoomEvent::ByteStreamOpened { reader, topic, .. } = event else { + continue; + }; + assert_eq!(topic, "some-topic"); + let reader = reader.take().ok_or_else(|| anyhow!("Failed to take reader"))?; + assert_eq!(reader.read_all().await?, expected); + break; + } + Ok(()) + }; + + timeout(Duration::from_secs(10), async { try_join!(send, receive) }).await??; + Ok(()) +} + +/// End-to-end round-trip of a large in-memory byte payload set with compress=false doesn't +/// compress the payload +#[cfg(feature = "__lk-e2e-test")] +#[tokio::test] +async fn test_data_stream_compress_false() -> Result<()> { + let mut rooms = test_rooms(2).await?; + let (sending_room, _) = rooms.pop().unwrap(); + let (_, mut receiving_event_rx) = rooms.pop().unwrap(); + + let payload = vec![0xFFu8; 50_000]; + let expected = payload.clone(); + + let send = async move { + let options = StreamByteOptions { + topic: "some-topic".into(), + compress: Some(false), // <= Explictly disable compression + ..Default::default() + }; + let stream_info = sending_room.local_participant().send_bytes(&payload, options).await?; + assert_eq!(stream_info.is_compressed, false, "is_compressed was not false"); + assert_eq!(stream_info.is_inline, false, "is_inline was not false"); + Ok(()) + }; + let receive = async move { + while let Some(event) = receiving_event_rx.recv().await { + let RoomEvent::ByteStreamOpened { reader, topic, .. } = event else { + continue; + }; + assert_eq!(topic, "some-topic"); + let reader = reader.take().ok_or_else(|| anyhow!("Failed to take reader"))?; + assert_eq!(reader.read_all().await?, expected); + break; + } + Ok(()) + }; + + timeout(Duration::from_secs(10), async { try_join!(send, receive) }).await??; + Ok(()) +} + #[cfg(feature = "__lk-e2e-test")] #[tokio::test] async fn test_send_text() -> Result<()> {