From 0b4328755cfdd208ad660ed596b203c54e8c2d53 Mon Sep 17 00:00:00 2001 From: Julian Ostarek Date: Thu, 22 Oct 2026 12:45:02 +0200 Subject: [PATCH] Set the transport through hyper.video's transport setting hyper.video #6101 removes hyper.settings.sessionDebug.forceWebrtc and makes the transport a layered setting, transport, which takes automatic, webTransport or webRtc. Its default, automatic, connects over WebTransport where the browser has it, and over WebRTC elsewhere. The transport takes "default" next to webtransport and webrtc, and defaults to it, as the other settings that hyper.video decides do. "default" unsets the participant's transport, so that an admin's value for the session applies, else automatic. webtransport sets webTransport, and webrtc sets webRtc. The state reads webRtc as WebRTC, and automatic and webTransport as WebTransport. --- .codex/skills/run-headless-simulator/SKILL.md | 3 +- browser/src/participant/cloudflare/mod.rs | 11 +-- browser/src/participant/frontend/commands.rs | 82 +++++++++++++++---- browser/src/participant/frontend/core.rs | 16 ++-- browser/src/participant/remote_stub.rs | 10 ++- browser/src/participant/shared/spec.rs | 6 +- config/src/default-config.yaml | 2 +- config/src/lib.rs | 5 +- docs/browser-driver.md | 2 +- src/headless.rs | 19 ++++- tui/src/tui/components/browser_start.rs | 16 ++-- 11 files changed, 121 insertions(+), 51 deletions(-) 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);