diff --git a/.codex/skills/run-headless-simulator/SKILL.md b/.codex/skills/run-headless-simulator/SKILL.md index efde94b..503e801 100644 --- a/.codex/skills/run-headless-simulator/SKILL.md +++ b/.codex/skills/run-headless-simulator/SKILL.md @@ -189,7 +189,7 @@ Headless option names use kebab-case. Participant JSON fields use snake_case. Repeat `--participant JSON` to add participants. It has no matching JSON field. -Auto gain control, background blur, both video constraints and the track limit default to `default`, which leaves the setting to hyper.video: an admin's value for the session, else hyper.video's default. JSON `null` inherits the shared value instead. +Auto gain control, the transport, background blur, both video constraints and the track limit default to `default`, which leaves the setting to hyper.video: an admin's value for the session, else hyper.video's default. JSON `null` inherits the shared value instead. Use a nonnegative integer, `unlimited` or `default` for `--video-max-concurrent-tracks` or `video_max_concurrent_tracks`. hyper.video's default is one track. @@ -204,6 +204,7 @@ Backend: Transport: +- `default` - `webtransport` - `webrtc` diff --git a/browser/src/participant/cloudflare/mod.rs b/browser/src/participant/cloudflare/mod.rs index 4e19efb..9b195e6 100644 --- a/browser/src/participant/cloudflare/mod.rs +++ b/browser/src/participant/cloudflare/mod.rs @@ -19,6 +19,7 @@ use client_simulator_config::{ media::FakeMedia, CloudflareConfig, NoiseSuppression, + OwnOrDefault, TransportMode, VideoConstraint, VideoMaxConcurrentTracks, @@ -220,7 +221,7 @@ impl CloudflareSession { fn normalized_settings(&self) -> crate::participant::shared::ParticipantSettings { let mut settings = self.launch_spec.settings.clone(); - if settings.transport != TransportMode::WebRTC { + if settings.transport != OwnOrDefault::Own(TransportMode::WebRTC) { self.log_message( "warn", format!( @@ -228,7 +229,7 @@ impl CloudflareSession { settings.transport ), ); - settings.transport = TransportMode::WebRTC; + settings.transport = OwnOrDefault::Own(TransportMode::WebRTC); } match settings.noise_suppression { @@ -525,7 +526,7 @@ fn map_settings(settings: &crate::participant::shared::ParticipantSettings) -> t } }, screenshare_enabled: settings.screenshare_enabled, - transport: match settings.transport { + transport: match settings.transport.own().unwrap_or(TransportMode::WebTransport) { client_simulator_config::TransportMode::WebRTC => types::ParticipantSettingsTransport::Webrtc, client_simulator_config::TransportMode::WebTransport => types::ParticipantSettingsTransport::Webtransport, }, @@ -1489,7 +1490,7 @@ mod tests { ]); let (base_url, requests, server) = spawn_http_server(responses).await; let mut spec = launch_spec(ResolvedFrontendKind::HyperLite, &format!("{base_url}/room/demo")); - spec.settings.transport = TransportMode::WebTransport; + spec.settings.transport = OwnOrDefault::Own(TransportMode::WebTransport); let mut session = CloudflareSession::new_for_test( spec, launch_options(false, FakeMedia::None), @@ -1764,7 +1765,7 @@ mod tests { screenshare_enabled: false, auto_gain_control: OwnOrDefault::Own(true), noise_suppression: NoiseSuppression::Disabled, - transport: TransportMode::WebRTC, + transport: OwnOrDefault::Own(TransportMode::WebRTC), video_constraint_publish_webcam: OwnOrDefault::Own(VideoConstraint::P720), video_constraint_subscribe: OwnOrDefault::Default, video_max_concurrent_tracks: OwnOrDefault::Default, diff --git a/browser/src/participant/frontend/commands.rs b/browser/src/participant/frontend/commands.rs index 1b011d8..e696b61 100644 --- a/browser/src/participant/frontend/commands.rs +++ b/browser/src/participant/frontend/commands.rs @@ -2,6 +2,7 @@ use super::driver::BrowserDriver; use client_simulator_config::{ NoiseSuppression, OwnOrDefault, + TransportMode, VideoConstraint, VideoMaxConcurrentTracks, }; @@ -12,6 +13,7 @@ use eyre::{ }; use serde::{ de::DeserializeOwned, + Deserialize, Serialize, }; use serde_json::json; @@ -29,8 +31,6 @@ const PARTICIPANT_EDIT: &str = "hyper.settings.client.edit('participant', argume const VOLUME_NORMALIZATION_ON: &str = "const { report, edit } = hyper.settings.client; \ const levels = report.participant.audioSendVolumeNormalization ?? report.default.audioSendVolumeNormalization; \ edit('participant', { set: { audioSendVolumeNormalization: levels } });"; -const FORCE_WEBRTC_GET: &str = "return hyper.settings.sessionDebug.forceWebrtc;"; -const FORCE_WEBRTC_SET: &str = "hyper.settings.sessionDebug.actions.setForceWebrtc(arguments[0]);"; // The `SettingKey`s of the settings that the simulator reads and sets. const NOISE_SUPPRESSION: &str = "audioSendNoiseSuppression"; @@ -39,16 +39,27 @@ const BACKGROUND_BLUR: &str = "videoSendWebcamBackgroundBlur"; const WEBCAM_MAX_RESOLUTION_LEVEL: &str = "videoSendWebcamMaxResolutionLevel"; const RECEIVE_MAX_RESOLUTION_LEVEL: &str = "videoReceiveMaxResolutionLevel"; const MAX_CONCURRENT_TRACKS: &str = "videoSendMaxConcurrentTracks"; - -async fn get_bool(driver: &dyn BrowserDriver, js: &str) -> Result { - let value = driver.eval(js, None).await?; - serde_json::from_value(value).context("failed to read bool from eval result") +const TRANSPORT: &str = "transport"; + +/// A value of hyper.video's `transport` setting, its `TransportPreference`. +#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +enum TransportPreference { + /// hyper.video's default, which connects over WebTransport where the browser has it, and over WebRTC elsewhere. + Automatic, + /// WebTransport only, with no fallback to WebRTC. + WebTransport, + /// WebRTC data channels. + WebRtc, } -async fn set_value(driver: &dyn BrowserDriver, js: &str, value: S) -> Result<()> { - let arg = serde_json::to_value(value).context("failed to serialize eval argument")?; - driver.eval(js, Some(arg)).await?; - Ok(()) +impl From for TransportPreference { + fn from(mode: TransportMode) -> Self { + match mode { + TransportMode::WebTransport => Self::WebTransport, + TransportMode::WebRTC => Self::WebRtc, + } + } } /// Reads the value of the setting `key` that applies. @@ -174,12 +185,20 @@ pub(super) async fn set_video_max_concurrent_tracks( .await } -pub(super) async fn get_force_webrtc(driver: &dyn BrowserDriver) -> Result { - get_bool(driver, FORCE_WEBRTC_GET).await +/// The transport that applies, reading [`TransportPreference::Automatic`] as WebTransport, which it picks where the +/// browser has it. +pub(super) async fn get_transport(driver: &dyn BrowserDriver) -> Result { + let preference: TransportPreference = get_setting(driver, TRANSPORT).await?; + Ok(match preference { + TransportPreference::Automatic | TransportPreference::WebTransport => TransportMode::WebTransport, + TransportPreference::WebRtc => TransportMode::WebRTC, + }) } -pub(super) async fn set_force_webrtc(driver: &dyn BrowserDriver, value: bool) -> Result<()> { - set_value(driver, FORCE_WEBRTC_SET, value).await +/// Sets the participant's transport, or unsets it for [`OwnOrDefault::Default`], so that an admin's value for the +/// session applies, else [`TransportPreference::Automatic`]. +pub(super) async fn set_transport(driver: &dyn BrowserDriver, value: OwnOrDefault) -> Result<()> { + edit_setting(driver, TRANSPORT, value.map(TransportPreference::from)).await } #[cfg(test)] @@ -373,4 +392,39 @@ pub(in crate::participant::frontend) mod tests { assert_eq!(value, VideoMaxConcurrentTracks::Limit(2)); } + + #[tokio::test] + async fn transport_maps_to_and_from_hyper_video_preferences() { + let driver = RecordingDriver::default(); + + set_transport(&driver, OwnOrDefault::Own(TransportMode::WebRTC)) + .await + .unwrap(); + set_transport(&driver, OwnOrDefault::Own(TransportMode::WebTransport)) + .await + .unwrap(); + set_transport(&driver, OwnOrDefault::Default).await.unwrap(); + + assert_eq!( + driver.calls(), + vec![ + participant_edit(json!({ "transport": "webRtc" })), + participant_edit(json!({ "transport": "webTransport" })), + participant_unset("transport"), + ], + ); + + let transports = [ + ("webRtc", TransportMode::WebRTC), + ("webTransport", TransportMode::WebTransport), + ("automatic", TransportMode::WebTransport), + ]; + for (setting, expected) in transports { + let driver = RecordingDriver::with_result(json!(setting)); + assert_eq!(get_transport(&driver).await.unwrap(), expected); + } + assert!(get_transport(&RecordingDriver::with_result(json!("webSocket"))) + .await + .is_err()); + } } diff --git a/browser/src/participant/frontend/core.rs b/browser/src/participant/frontend/core.rs index bc903fc..b893262 100644 --- a/browser/src/participant/frontend/core.rs +++ b/browser/src/participant/frontend/core.rs @@ -8,15 +8,15 @@ use super::{ commands::{ get_auto_gain_control, get_background_blur, - get_force_webrtc, get_noise_suppression, + get_transport, get_video_constraint_publish_webcam, get_video_constraint_subscribe, get_video_max_concurrent_tracks, set_auto_gain_control, set_background_blur, - set_force_webrtc, set_noise_suppression, + set_transport, set_video_constraint_publish_webcam, set_video_constraint_subscribe, set_video_max_concurrent_tracks, @@ -32,7 +32,6 @@ use crate::auth::BorrowedCredentials; use client_simulator_config::{ NoiseSuppression, OwnOrDefault, - TransportMode, VideoConstraint, VideoMaxConcurrentTracks, }; @@ -162,10 +161,7 @@ impl ParticipantInner { "max concurrent video tracks", set_video_max_concurrent_tracks(driver, settings.video_max_concurrent_tracks).await, ); - self.log_failure( - "transport mode", - set_force_webrtc(driver, settings.transport == TransportMode::WebRTC).await, - ); + self.log_failure("transport mode", set_transport(driver, settings.transport).await); if !settings.audio_enabled { self.log_failure("muted microphone", self.toggle_audio_inner().await); @@ -318,10 +314,8 @@ impl ParticipantInner { } } - if let Ok(value) = get_force_webrtc(driver).await { - if value { - state.transport_mode = TransportMode::WebRTC; - } + if let Ok(value) = get_transport(driver).await { + state.transport_mode = value; } if let Ok(value) = get_video_constraint_publish_webcam(driver).await { diff --git a/browser/src/participant/remote_stub.rs b/browser/src/participant/remote_stub.rs index 9dd854d..69f6aa0 100644 --- a/browser/src/participant/remote_stub.rs +++ b/browser/src/participant/remote_stub.rs @@ -9,6 +9,7 @@ use crate::participant::shared::{ ParticipantState, }; use client_simulator_config::{ + TransportMode, VideoConstraint, VideoMaxConcurrentTracks, }; @@ -48,7 +49,7 @@ impl ParticipantDriverSession for RemoteStubSession { fn start(&mut self) -> BoxFuture<'_, Result<()>> { async move { // The stub applies none of hyper.video's settings, so a setting that the participant leaves to hyper.video - // reports hyper.video's default, and the track limit no limit. + // reports hyper.video's default, WebTransport for the transport, and no limit for the track limit. self.state = ParticipantState { username: self.launch_spec.username.clone(), running: true, @@ -58,7 +59,12 @@ impl ParticipantDriverSession for RemoteStubSession { video_activated: self.launch_spec.settings.video_enabled, auto_gain_control: self.launch_spec.settings.auto_gain_control.own().unwrap_or(true), noise_suppression: self.launch_spec.settings.noise_suppression, - transport_mode: self.launch_spec.settings.transport, + transport_mode: self + .launch_spec + .settings + .transport + .own() + .unwrap_or(TransportMode::WebTransport), video_constraint_publish_webcam: self .launch_spec .settings diff --git a/browser/src/participant/shared/spec.rs b/browser/src/participant/shared/spec.rs index 9dec04a..747f33e 100644 --- a/browser/src/participant/shared/spec.rs +++ b/browser/src/participant/shared/spec.rs @@ -32,7 +32,7 @@ pub(in crate::participant) struct ParticipantSettings { pub(in crate::participant) screenshare_enabled: bool, pub(in crate::participant) auto_gain_control: OwnOrDefault, pub(in crate::participant) noise_suppression: NoiseSuppression, - pub(in crate::participant) transport: TransportMode, + pub(in crate::participant) transport: OwnOrDefault, pub(in crate::participant) video_constraint_publish_webcam: OwnOrDefault, pub(in crate::participant) video_constraint_subscribe: OwnOrDefault, pub(in crate::participant) video_max_concurrent_tracks: OwnOrDefault, @@ -112,7 +112,7 @@ mod tests { screenshare_enabled: true, auto_gain_control: OwnOrDefault::Own(true), noise_suppression: NoiseSuppression::HyperVoice, - transport: TransportMode::WebRTC, + transport: OwnOrDefault::Own(TransportMode::WebRTC), video_constraint_publish_webcam: OwnOrDefault::Own(VideoConstraint::P480), video_constraint_subscribe: OwnOrDefault::Default, video_max_concurrent_tracks: OwnOrDefault::Own(VideoMaxConcurrentTracks::Limit(2)), @@ -131,7 +131,7 @@ mod tests { assert!(spec.settings.screenshare_enabled); assert_eq!(spec.settings.auto_gain_control, OwnOrDefault::Own(true)); assert_eq!(spec.settings.noise_suppression, NoiseSuppression::HyperVoice); - assert_eq!(spec.settings.transport, TransportMode::WebRTC); + assert_eq!(spec.settings.transport, OwnOrDefault::Own(TransportMode::WebRTC)); assert_eq!( spec.settings.video_constraint_publish_webcam, OwnOrDefault::Own(VideoConstraint::P480) diff --git a/config/src/default-config.yaml b/config/src/default-config.yaml index f99600f..401a82c 100644 --- a/config/src/default-config.yaml +++ b/config/src/default-config.yaml @@ -46,7 +46,7 @@ audio_enabled: true video_enabled: true auto_gain_control: default noise_suppression: none -transport: webtransport +transport: default video_constraint_publish_webcam: default video_constraint_subscribe: default video_max_concurrent_tracks: default diff --git a/config/src/lib.rs b/config/src/lib.rs index 2c9ca45..2c3868b 100644 --- a/config/src/lib.rs +++ b/config/src/lib.rs @@ -99,7 +99,7 @@ pub struct Config { #[serde(default)] pub noise_suppression: NoiseSuppression, #[serde(default)] - pub transport: TransportMode, + pub transport: OwnOrDefault, #[serde(default)] pub video_constraint_publish_webcam: OwnOrDefault, #[serde(default)] @@ -542,6 +542,7 @@ video_max_concurrent_tracks: 2 .add_source(config::File::from_str( r#" auto_gain_control: true +transport: webrtc video_constraint_publish_webcam: none video_constraint_subscribe: default video_max_concurrent_tracks: unlimited @@ -570,6 +571,7 @@ blur: false for config in [from_file, from_source, from_saved] { assert_eq!(config.auto_gain_control, OwnOrDefault::Own(true)); + assert_eq!(config.transport, OwnOrDefault::Own(TransportMode::WebRTC)); assert_eq!( config.video_constraint_publish_webcam, OwnOrDefault::Own(VideoConstraint::None) @@ -590,6 +592,7 @@ blur: false let config = Config::default(); assert_eq!(config.auto_gain_control, OwnOrDefault::Default); + assert_eq!(config.transport, OwnOrDefault::Default); assert_eq!(config.video_constraint_publish_webcam, OwnOrDefault::Default); assert_eq!(config.video_constraint_subscribe, OwnOrDefault::Default); assert_eq!(config.video_max_concurrent_tracks, OwnOrDefault::Default); diff --git a/docs/browser-driver.md b/docs/browser-driver.md index f608727..fdb4c6b 100644 --- a/docs/browser-driver.md +++ b/docs/browser-driver.md @@ -131,7 +131,7 @@ The current implementation has two frontend-specific automation layers: For Hyper Core parity, the driver needs hooks for: - join button, leave button, mute button, video button, screen share button, name input selectors, or equivalent control channels; see [`browser/src/participant/local/selectors.rs`](../browser/src/participant/local/selectors.rs) `:1-20` -- JS getters/setters for noise suppression, blur, camera resolution, and force-WebRTC transport; see [`browser/src/participant/local/commands.rs`](../browser/src/participant/local/commands.rs) `:80-124` +- JS getters/setters for noise suppression, blur, camera resolution, and transport; see [`browser/src/participant/local/commands.rs`](../browser/src/participant/local/commands.rs) `:80-124` - state scraping for mute/video/screenshare button state and advanced media settings; see [`browser/src/participant/local/core.rs`](../browser/src/participant/local/core.rs) `:271-317` For Hyper Lite parity, the driver needs the join/leave/mute/video/screenshare controls and state scraping, but the advanced settings can remain unsupported no-ops as they are today. See [`browser/src/participant/local/lite.rs`](../browser/src/participant/local/lite.rs) `:119-175`, `:193-223`. diff --git a/src/headless.rs b/src/headless.rs index 72407bc..eaa5032 100644 --- a/src/headless.rs +++ b/src/headless.rs @@ -59,8 +59,9 @@ pub struct HeadlessArgs { #[clap(long = "noise-suppression", value_name = "MODEL")] pub noise_suppression: Option, + /// Transport: webtransport, webrtc, or default to leave it to hyper.video. Cloudflare always uses webrtc. #[clap(long, value_name = "MODE")] - pub transport: Option, + pub transport: Option>, /// Outgoing webcam resolution limit, such as 720p, none for no limit, or default to leave it to hyper.video. #[clap(long = "video-constraint-publish-webcam", value_name = "CONSTRAINT")] @@ -94,7 +95,7 @@ struct ParticipantOverride { screenshare_enabled: Option, auto_gain_control: Option>, noise_suppression: Option, - transport: Option, + transport: Option>, video_constraint_publish_webcam: Option>, video_constraint_subscribe: Option>, video_max_concurrent_tracks: Option>, @@ -470,10 +471,19 @@ mod tests { /// The CLI and `--participant` JSON take `default` to leave a setting to hyper.video, next to explicit values. #[test] fn cli_and_participant_json_take_default() { - let cli = TestHeadlessCli::parse_from(["headless", "--blur", "default", "--auto-gain-control", "false"]); + let cli = TestHeadlessCli::parse_from([ + "headless", + "--blur", + "default", + "--auto-gain-control", + "false", + "--transport", + "webtransport", + ]); assert_eq!(cli.args.blur, Some(OwnOrDefault::Default)); assert_eq!(cli.args.auto_gain_control, Some(OwnOrDefault::Own(false))); + assert_eq!(cli.args.transport, Some(OwnOrDefault::Own(TransportMode::WebTransport))); let global_config = Config { video_constraint_subscribe: OwnOrDefault::Own(VideoConstraint::P720), @@ -482,12 +492,13 @@ mod tests { let configs = build_participant_configs( global_config, - &[r#"{"video_constraint_subscribe":"default","blur":true}"#.to_string()], + &[r#"{"video_constraint_subscribe":"default","blur":true,"transport":"default"}"#.to_string()], ) .expect("participant configs"); assert_eq!(configs[0].video_constraint_subscribe, OwnOrDefault::Default); assert_eq!(configs[0].blur, OwnOrDefault::Own(true)); + assert_eq!(configs[0].transport, OwnOrDefault::Default); } #[test] diff --git a/tui/src/tui/components/browser_start.rs b/tui/src/tui/components/browser_start.rs index 734b8cb..c56b45b 100644 --- a/tui/src/tui/components/browser_start.rs +++ b/tui/src/tui/components/browser_start.rs @@ -28,7 +28,7 @@ use crossterm::event::KeyCode; use ratatui::prelude::*; use strum::{ Display, - IntoEnumIterator as _, + IntoEnumIterator, }; use tokio::sync::mpsc::UnboundedSender; @@ -124,7 +124,7 @@ pub struct BrowserStart { editing: Option, fake_media_builtin_list: Option>, noise_suppression_list: Option>, - transport_list: Option>, + transport_list: Option>>, video_constraint_publish_webcam_list: Option>>, video_constraint_subscribe_list: Option>>, video_max_concurrent_tracks_list: Option>, @@ -132,9 +132,9 @@ pub struct BrowserStart { participant_store: ParticipantStore, } -/// The values of a video constraint that the start screen lists: hyper.video's default, then each constraint. -fn video_constraint_settings() -> impl Iterator> { - std::iter::once(OwnOrDefault::Default).chain(VideoConstraint::iter().map(OwnOrDefault::Own)) +/// The values that the start screen lists for a setting that takes a `T`: hyper.video's default, then each `T`. +fn own_or_default_values() -> impl Iterator> { + std::iter::once(OwnOrDefault::Default).chain(T::iter().map(OwnOrDefault::Own)) } /// The value after `setting` in the order that Enter cycles through: hyper.video's default, on, off. @@ -653,7 +653,7 @@ impl Component for BrowserStart { BrowserStartAction::StartSelectTransport => { self.transport_list = Some(EnumListInput::new( "Transport protocol to use", - TransportMode::iter(), + own_or_default_values::(), self.config.transport, )); return Ok(None); @@ -662,7 +662,7 @@ impl Component for BrowserStart { BrowserStartAction::StartSelectVideoConstraintPublishWebcam => { self.video_constraint_publish_webcam_list = Some(EnumListInput::new( "Outgoing webcam video constraint", - video_constraint_settings(), + own_or_default_values::(), self.config.video_constraint_publish_webcam, )); return Ok(None); @@ -671,7 +671,7 @@ impl Component for BrowserStart { BrowserStartAction::StartSelectVideoConstraintSubscribe => { self.video_constraint_subscribe_list = Some(EnumListInput::new( "Incoming video constraint", - video_constraint_settings(), + own_or_default_values::(), self.config.video_constraint_subscribe, )); return Ok(None);