diff --git a/AGENTS.md b/AGENTS.md index 1c82cba..7cc1b71 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,10 +61,13 @@ - Adhere to requirements in [_CONTRIBUTING.md_](./CONTRIBUTING.md) - Always format using `cargo fmt` + - CI enforces this via `cargo fmt --check` - Always address all issues, both clippy and compiler warnings + - Verify with the same invocation CI uses: `cargo clippy --all-targets -- -D warnings --no-deps` - Do not reach for `#[allow(...)]` to bypass warnings unless it is unavoidable in the context - Be explicit when you are bypassing warnings - Always run cspell and fix spelling issues + - `npx cspell --no-progress "**"` checks all files, matching CI - If a flagged word is valid project terminology, add it to _cspell.yml_ and sort the list alphabetically ## Release process diff --git a/Cargo.lock b/Cargo.lock index f035a6d..cfa9f7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2716,9 +2716,9 @@ dependencies = [ [[package]] name = "libwebrtc" -version = "0.3.41" +version = "0.3.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2da2ccaceaf34cf580c2b6819c57f80c85e53f7da7e8cf070b1d0a7ba12f3b3" +checksum = "6f5497ff0694ddcee8f88129c78139defa5ec3fc434c3e182906ed0a72372bd9" dependencies = [ "cxx", "jni 0.21.1", @@ -2779,9 +2779,9 @@ checksum = "11d3d7f243d5c5a8b9bb5d6dd2b1602c0cb0b9db1621bafc7ed66e35ff9fe092" [[package]] name = "livekit" -version = "0.7.52" +version = "0.7.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63eeb45aa7d13d3e5c0009ea39c9ce8b7a3587f795eb7c3a13aa4880637295f" +checksum = "aa8ed793e154d63b588397ea7a84cfb3606b6d049f4d24137d9d9e0384617059" dependencies = [ "base64 0.22.1", "bmrng", @@ -2810,9 +2810,9 @@ dependencies = [ [[package]] name = "livekit-api" -version = "0.5.5" +version = "0.5.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1006f7c009369fe5f16f2eb6e93d6d0dcb2b427306a2f61b26ff5d2e440ec282" +checksum = "e827d3444235ddccf360fc1d4737034e86ee2ea2c0a696f148ecc08e7249bfd3" dependencies = [ "base64 0.21.7", "bytes", @@ -2822,6 +2822,7 @@ dependencies = [ "hmac", "http", "jsonwebtoken", + "livekit-common", "livekit-protocol", "livekit-runtime", "log", @@ -5798,9 +5799,9 @@ dependencies = [ [[package]] name = "webrtc-sys" -version = "0.3.38" +version = "0.3.39" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ca21867ef47ef33e43564a6d1e6947658728579216da9c97c2415ca555f2c96" +checksum = "7a555de886b8d4961e6c2878ac3ca1f716b75c77f60311c5cf6ed136d0df14e1" dependencies = [ "cc", "cxx", diff --git a/Cargo.toml b/Cargo.toml index 117bc0f..5df2831 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -20,12 +20,14 @@ wgpu = "29.0" winit = { version = "0.30.13", features = [ "android-native-activity" ] } livekit = { version = "0.7.49", features = ["rustls-tls-native-roots"] } livekit-api = { version = "0.5.4", default-features = false, features = ["access-token"] } +livekit-token-source = { version = "0.1.0" } -# For local SDK development, comment out the two lines above and uncomment these +# For local SDK development, comment out the three lines above and uncomment these # (clone https://github.com/livekit/rust-sdks to ../rust-sdks first; see # "Building against a local rust-sdks" in README.md): # livekit = { path = "../rust-sdks/livekit", features = ["rustls-tls-native-roots"] } # livekit-api = { path = "../rust-sdks/livekit-api", default-features = false, features = ["access-token"] } +# livekit-token-source = { path = "../rust-sdks/livekit-token-source" } [package.metadata.bundle] name = "LiveKit Client" diff --git a/src/connect.rs b/src/connect.rs index 6a52589..7b54966 100644 --- a/src/connect.rs +++ b/src/connect.rs @@ -1,26 +1,40 @@ use crate::ui::{labeled_field::LabeledTextEdit, prominent_button::ProminentButton}; +use livekit_token_source::{TokenSourceFetchOptions, TokenSourceSandbox}; -/// How a room connection is authenticated. -#[derive(Clone)] +/// How a room connection is authenticated. The methods that target a known +/// server carry its URL; the token-source method learns it from the sandbox. +#[derive(Clone, Debug)] pub enum Auth { /// A pre-generated access token (the room is encoded in it). - Token(String), + Token { url: String, token: String }, /// API credentials from which a join token is generated on demand. ApiKey { + url: String, api_key: String, api_secret: String, identity: String, room: String, }, + /// A LiveKit Cloud sandbox token server, which provides both the server + /// URL and the join token. `options` customizes the request; unset + /// fields are left to server defaults. + TokenSource { + sandbox_id: String, + options: TokenSourceFetchOptions, + }, } impl Auth { - /// Resolve to a room-connection JWT, generating one from the API credentials - /// when this is the API-key method. - pub fn access_token(&self) -> Result { + /// Resolve to `(server_url, token)`: the JWT is generated locally for the + /// API-key method, fetched over HTTP for the token-source method. + /// + /// Async because of that fetch — call it from the service task, not the UI + /// thread. + pub async fn connection_details(&self) -> Result<(String, String), String> { match self { - Auth::Token(token) => Ok(token.clone()), + Auth::Token { url, token } => Ok((url.clone(), token.clone())), Auth::ApiKey { + url, api_key, api_secret, identity, @@ -34,7 +48,28 @@ impl Auth { ..Default::default() }) .to_jwt() + .map(|token| (url.clone(), token)) .map_err(|e| e.to_string()), + Auth::TokenSource { + sandbox_id, + options, + } => { + let token_source = TokenSourceSandbox::new(sandbox_id.to_owned()); + let response = token_source + .fetch(options) + .await + .map_err(|e| e.to_string())?; + Ok((response.server_url, response.participant_token)) + } + } + } + + /// Short label of the connection target for window titles: the server URL + /// when known up front, otherwise the sandbox id. + pub fn target_label(&self) -> &str { + match self { + Auth::Token { url, .. } | Auth::ApiKey { url, .. } => url, + Auth::TokenSource { sandbox_id, .. } => sandbox_id, } } } @@ -43,7 +78,6 @@ impl Auth { /// per application, passed to each room window as it is opened. #[derive(Clone)] pub struct ConnectSettings { - pub url: String, pub auth: Auth, pub key: String, pub auto_subscribe: bool, @@ -59,6 +93,7 @@ enum AuthMethod { #[default] ApiKey, Token, + TokenSource, } /// The root window: a welcome screen holding the only connect form in the app. @@ -74,6 +109,16 @@ pub struct ConnectView { method: AuthMethod, url: String, token: String, + sandbox_id: String, + // Token-source fetch options (`ts_` to keep them apart from the API-key + // tab's identity/room). Empty means "omit, let the server default". + ts_room_name: String, + ts_participant_name: String, + ts_participant_identity: String, + ts_participant_metadata: String, + ts_agent_name: String, + ts_agent_metadata: String, + ts_agent_deployment: String, api_key: String, api_secret: String, identity: String, @@ -101,6 +146,14 @@ impl Default for ConnectView { method: AuthMethod::default(), url: env_or("LIVEKIT_URL", "ws://localhost:7880"), token: env_or("LIVEKIT_TOKEN", ""), + sandbox_id: "sandbox-id".to_string(), + ts_room_name: String::new(), + ts_participant_name: String::new(), + ts_participant_identity: String::new(), + ts_participant_metadata: String::new(), + ts_agent_name: String::new(), + ts_agent_metadata: String::new(), + ts_agent_deployment: String::new(), api_key: env_or("LIVEKIT_API_KEY", "devkey"), api_secret: env_or("LIVEKIT_API_SECRET", "secret"), identity: "participant-0".to_string(), @@ -116,32 +169,57 @@ impl Default for ConnectView { impl ConnectView { fn is_connect_enabled(&self) -> bool { - if self.url.trim().is_empty() { - return false; - } + // The URL only matters for the methods that use it; the token-source + // method gets its server URL from the sandbox response. match self.method { AuthMethod::ApiKey => { - !self.api_key.trim().is_empty() + !self.url.trim().is_empty() + && !self.api_key.trim().is_empty() && !self.api_secret.trim().is_empty() && !self.identity.trim().is_empty() && !self.room.trim().is_empty() } - AuthMethod::Token => !self.token.trim().is_empty(), + AuthMethod::Token => !self.url.trim().is_empty() && !self.token.trim().is_empty(), + AuthMethod::TokenSource => !self.sandbox_id.trim().is_empty(), } } fn current_settings(&self) -> ConnectSettings { let auth = match self.method { AuthMethod::ApiKey => Auth::ApiKey { + url: self.url.clone(), api_key: self.api_key.clone(), api_secret: self.api_secret.clone(), identity: self.identity.clone(), room: self.room.clone(), }, - AuthMethod::Token => Auth::Token(self.token.clone()), + AuthMethod::Token => Auth::Token { + url: self.url.clone(), + token: self.token.clone(), + }, + AuthMethod::TokenSource => { + // Empty (or whitespace-only) fields are omitted from the + // request so the token server applies its defaults. + let opt = |s: &str| { + let s = s.trim(); + (!s.is_empty()).then(|| s.to_string()) + }; + Auth::TokenSource { + sandbox_id: self.sandbox_id.clone(), + options: TokenSourceFetchOptions { + room_name: opt(&self.ts_room_name), + participant_name: opt(&self.ts_participant_name), + participant_identity: opt(&self.ts_participant_identity), + participant_metadata: opt(&self.ts_participant_metadata), + agent_name: opt(&self.ts_agent_name), + agent_metadata: opt(&self.ts_agent_metadata), + agent_deployment: opt(&self.ts_agent_deployment), + ..Default::default() + }, + } + } }; ConnectSettings { - url: self.url.clone(), auth, key: self.key.clone(), auto_subscribe: self.auto_subscribe, @@ -218,12 +296,10 @@ impl egui::Widget for ConnectForm<'_> { ui.label(egui::RichText::new("Connect to a Room").text_style(egui::TextStyle::Heading)); ui.add_space(8.0); - ui.add(LabeledTextEdit::singleline("URL", &mut view.url)); - ui.add_space(8.0); - ui.horizontal(|ui| { ui.selectable_value(&mut view.method, AuthMethod::ApiKey, "API Key"); ui.selectable_value(&mut view.method, AuthMethod::Token, "Token"); + ui.selectable_value(&mut view.method, AuthMethod::TokenSource, "TokenSource"); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let toggle = ui .add(egui::Button::selectable(view.show_secrets, "👁")) @@ -239,15 +315,23 @@ impl egui::Widget for ConnectForm<'_> { // the eye toggle above is on. let mask = !view.show_secrets; - // Scope each method's fields under a distinct id so switching tabs - // is seen as a layout change, not an unstable widget id (egui warns - // when a rect's id changes between passes under the same parent). - ui.push_id(view.method, |ui| match view.method { + // Scope the method's fields under a *constant* id. The single-field + // tabs (Token / TokenSource) render the same full-width widget at the + // same rect, so a per-method salt would give that rect a different id + // each switch — which is exactly what egui's "rect changed id between + // passes" warning flags. A stable salt keeps the id constant. + // The URL lives inside the Token / API Key tabs (shared between + // them): the token-source method gets its URL from the sandbox. + ui.push_id("auth_method_fields", |ui| match view.method { AuthMethod::Token => { + ui.add(LabeledTextEdit::singleline("URL", &mut view.url)); + ui.add_space(8.0); ui.add(LabeledTextEdit::singleline("Token", &mut view.token).password(mask)); ui.add_space(8.0); } AuthMethod::ApiKey => { + ui.add(LabeledTextEdit::singleline("URL", &mut view.url)); + ui.add_space(8.0); ui.columns(2, |columns| { columns[0].add( LabeledTextEdit::singleline("API Key", &mut view.api_key) @@ -265,6 +349,59 @@ impl egui::Widget for ConnectForm<'_> { }); ui.add_space(8.0); } + AuthMethod::TokenSource => { + ui.add(LabeledTextEdit::singleline( + "Sandbox Id", + &mut view.sandbox_id, + )); + ui.add_space(8.0); + + ui.label( + egui::RichText::new( + "Optional overrides — empty fields use server defaults", + ) + .text_style(egui::TextStyle::Small), + ); + ui.add_space(8.0); + ui.columns(2, |columns| { + columns[0].add(LabeledTextEdit::singleline( + "Room Name", + &mut view.ts_room_name, + )); + columns[1].add(LabeledTextEdit::singleline( + "Participant Name", + &mut view.ts_participant_name, + )); + }); + ui.add_space(8.0); + ui.columns(2, |columns| { + columns[0].add(LabeledTextEdit::singleline( + "Participant Identity", + &mut view.ts_participant_identity, + )); + columns[1].add(LabeledTextEdit::singleline( + "Participant Metadata", + &mut view.ts_participant_metadata, + )); + }); + ui.add_space(8.0); + ui.columns(2, |columns| { + columns[0].add(LabeledTextEdit::singleline( + "Agent Name", + &mut view.ts_agent_name, + )); + columns[1].add(LabeledTextEdit::singleline( + "Agent Deployment", + &mut view.ts_agent_deployment, + )); + }); + ui.add_space(8.0); + ui.add(LabeledTextEdit::singleline( + "Agent Metadata", + &mut view.ts_agent_metadata, + )); + ui.add_space(8.0); + } }); ui.add( diff --git a/src/lib.rs b/src/lib.rs index 9c027d7..1c33b69 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -67,7 +67,7 @@ impl AppRoot { let id = self.next_window_id; self.next_window_id += 1; - let title = format!("{} - {}", APP_NAME, request.url); + let title = format!("{} - {}", APP_NAME, request.auth.target_label()); let window = RoomWindow::new( id, self.async_runtime.handle().clone(), diff --git a/src/room/window.rs b/src/room/window.rs index 075be27..3d29219 100644 --- a/src/room/window.rs +++ b/src/room/window.rs @@ -54,19 +54,10 @@ impl RoomWindow { } fn connect(&mut self) { - let token = match self.request.auth.access_token() { - Ok(token) => token, - Err(err) => { - self.connecting = false; - self.connection_failure = Some(err); - return; - } - }; self.connecting = true; self.connection_failure = None; let _ = self.service.send(AsyncCmd::RoomConnect { - url: self.request.url.clone(), - token, + auth: Box::new(self.request.auth.clone()), auto_subscribe: self.request.auto_subscribe, dynacast: self.request.dynacast, enable_e2ee: self.request.enable_e2ee, @@ -85,7 +76,7 @@ impl RoomWindow { UiCmd::ConnectResult { result } => { self.connecting = false; if let Err(err) = result { - self.connection_failure = Some(err.to_string()); + self.connection_failure = Some(err); } } UiCmd::DataTrackPublished { track } => { diff --git a/src/service.rs b/src/service.rs index addf4f9..7c7595c 100644 --- a/src/service.rs +++ b/src/service.rs @@ -1,3 +1,4 @@ +use crate::connect::Auth; use crate::media::{LogoTrack, MicTrack, SineParameters, SineTrack}; use livekit::{ SimulateScenario, StreamByteOptions, StreamTextOptions, @@ -12,8 +13,9 @@ use tokio::sync::mpsc::{self, error::SendError}; #[derive(Debug)] pub enum AsyncCmd { RoomConnect { - url: String, - token: String, + /// Boxed to keep `AsyncCmd` small (clippy: `result_large_err` on + /// [`LkService::send`]); the token-source options make `Auth` large. + auth: Box, auto_subscribe: bool, dynacast: bool, enable_e2ee: bool, @@ -64,7 +66,9 @@ pub enum DataStreamPayload { #[derive(Debug)] pub enum UiCmd { ConnectResult { - result: RoomResult<()>, + /// `Err` is a human-readable message: token resolution and room + /// connection can each fail, with different error types. + result: Result<(), String>, }, RoomEvent { event: RoomEvent, @@ -161,13 +165,24 @@ async fn service_task(inner: Arc, mut cmd_rx: mpsc::UnboundedRecei while let Some(event) = cmd_rx.recv().await { match event { AsyncCmd::RoomConnect { - url, - token, + auth, auto_subscribe, dynacast, enable_e2ee, key, } => { + // Resolved here rather than UI-side: the token-source method + // fetches the connection details over HTTP, which must not + // block the UI. + let (url, token) = match auth.connection_details().await { + Ok(details) => details, + Err(err) => { + log::error!("failed to resolve connection details: {err}"); + let _ = inner.ui_tx.send(UiCmd::ConnectResult { result: Err(err) }); + continue; + } + }; + log::info!("connecting to room: {}", url); let key_provider = @@ -217,7 +232,9 @@ async fn service_task(inner: Arc, mut cmd_rx: mpsc::UnboundedRecei let _ = inner.ui_tx.send(UiCmd::ConnectResult { result: Ok(()) }); } else if let Err(err) = res { log::error!("failed to connect to room: {:?}", err); - let _ = inner.ui_tx.send(UiCmd::ConnectResult { result: Err(err) }); + let _ = inner.ui_tx.send(UiCmd::ConnectResult { + result: Err(err.to_string()), + }); } } AsyncCmd::RoomDisconnect => {