Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 9 additions & 8 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
181 changes: 159 additions & 22 deletions src/connect.rs
Original file line number Diff line number Diff line change
@@ -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<String, String> {
/// 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,
Expand All @@ -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,
}
}
}
Expand All @@ -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,
Expand All @@ -59,6 +93,7 @@ enum AuthMethod {
#[default]
ApiKey,
Token,
TokenSource,
}

/// The root window: a welcome screen holding the only connect form in the app.
Expand All @@ -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,
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand Down Expand Up @@ -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, "👁"))
Expand All @@ -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)
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
Loading
Loading