From bc47a724af41803a2831eb4b892c07dbe1c615e8 Mon Sep 17 00:00:00 2001 From: Hampus Date: Sat, 19 Sep 2026 02:17:25 +0200 Subject: [PATCH] fix(voice): stop screen shares failing to reach their viewers (#2835) --- fluxer_admin/openapi-admin.json | 54 + fluxer_admin/src/api/types/instance_config.rs | 42 + fluxer_admin/src/routes/system_actions.rs | 102 +- .../src/templates/pages/instance_config.rs | 111 +- fluxer_admin/tests/api_deserialization.rs | 8 + fluxer_admin/tests/htmx_acceptance.rs | 1 + .../tests/voice_restriction_writes.rs | 33 + .../InstanceConfigAdminController.ts | 16 + .../api/experiment/ExperimentController.ts | 8 +- .../tests/ExperimentController.test.ts | 95 +- .../instance/InstanceConfigRepository.test.ts | 73 + .../api/instance/InstanceConfigRepository.ts | 21 + fluxer_api/src/api/openapi/openapi.json | 14 +- .../src/room/PCTransport.test.ts | 75 +- .../livekit-client/src/room/PCTransport.ts | 10 +- .../livekit-client/src/room/RTCEngine.test.ts | 27 +- .../pkgs/livekit-client/src/room/RTCEngine.ts | 16 +- .../src/features/i18n/locales/ar/messages.po | 686 +-- .../locales/auto-i18n-reviewed-unchanged.json | 141 + .../src/features/i18n/locales/bg/messages.po | 686 +-- .../src/features/i18n/locales/cs/messages.po | 686 +-- .../src/features/i18n/locales/da/messages.po | 686 +-- .../src/features/i18n/locales/de/messages.po | 686 +-- .../src/features/i18n/locales/el/messages.po | 686 +-- .../features/i18n/locales/en-GB/messages.po | 689 +-- .../features/i18n/locales/en-US/messages.po | 686 +-- .../features/i18n/locales/es-419/messages.po | 686 +-- .../features/i18n/locales/es-ES/messages.po | 686 +-- .../src/features/i18n/locales/fi/messages.po | 686 +-- .../src/features/i18n/locales/fr/messages.po | 686 +-- .../src/features/i18n/locales/he/messages.po | 686 +-- .../src/features/i18n/locales/hi/messages.po | 686 +-- .../src/features/i18n/locales/hr/messages.po | 686 +-- .../src/features/i18n/locales/hu/messages.po | 686 +-- .../src/features/i18n/locales/id/messages.po | 686 +-- .../src/features/i18n/locales/it/messages.po | 686 +-- .../src/features/i18n/locales/ja/messages.po | 686 +-- .../src/features/i18n/locales/ko/messages.po | 686 +-- .../src/features/i18n/locales/lt/messages.po | 686 +-- .../src/features/i18n/locales/nl/messages.po | 686 +-- .../src/features/i18n/locales/no/messages.po | 686 +-- .../src/features/i18n/locales/pl/messages.po | 686 +-- .../features/i18n/locales/pt-BR/messages.po | 686 +-- .../src/features/i18n/locales/ro/messages.po | 686 +-- .../src/features/i18n/locales/ru/messages.po | 686 +-- .../features/i18n/locales/sv-SE/messages.po | 686 +-- .../src/features/i18n/locales/th/messages.po | 686 +-- .../src/features/i18n/locales/tr/messages.po | 686 +-- .../src/features/i18n/locales/uk/messages.po | 686 +-- .../src/features/i18n/locales/vi/messages.po | 686 +-- .../features/i18n/locales/zh-CN/messages.po | 686 +-- .../features/i18n/locales/zh-TW/messages.po | 686 +-- .../components/modals/tabs/UserVideoTab.tsx | 185 +- .../modals/tabs/UserVideoTabState.ts | 107 + .../AdvancedVideoControls.tsx | 109 +- .../AdvancedVideoControlsState.ts | 18 + .../search_index/VoiceVideoIndex.ts | 2 +- .../voice/commands/VoiceSettingsCommands.ts | 37 +- .../ActiveScreenShareMenu.module.css | 24 + .../components/ActiveScreenShareMenu.tsx | 77 +- .../features/voice/components/StreamKeys.ts | 27 + .../components/StreamSettingsMenuContent.tsx | 320 +- .../StreamSettingsMenuContentStateMachine.ts | 140 + .../VoiceConnectionStatus.module.css | 11 + .../voice/components/VoiceParticipantTile.tsx | 75 +- .../VoiceParticipantTileStateMachine.ts | 20 + .../modals/ScreenSharePickerModal.tsx | 60 +- .../useScreenShareUnderperformance.ts | 107 - .../voice/components/useStatsForNerds.ts | 2 - .../LocalParticipantControls.tsx | 17 +- .../LocalParticipantControlsStateMachine.ts | 7 + .../voice_participant_tile/shared.ts | 21 - .../voice/engine/MediaEngineFacade.ts | 58 +- .../engine/ScreenShareCodecNegotiation.ts | 337 +- .../ScreenShareDeliveryProofAdmission.test.ts | 1242 +++++ .../engine/ScreenShareDeliveryProofHarness.ts | 3555 ++++++++++++++ .../ScreenShareDeliveryProofRuntimeA.test.ts | 29 + .../ScreenShareDeliveryProofRuntimeB.test.ts | 37 + .../ScreenShareDeliveryProofRuntimeC.test.ts | 31 + .../ScreenShareDeliveryProofRuntimeD.test.ts | 39 + .../ScreenShareDeliveryProofViewers.test.ts | 101 + .../engine/ScreenShareUnderperformance.ts | 1139 ++++- .../voice/engine/VoiceMediaEngineBridge.ts | 9 + .../voice/engine/VoiceRoomEventBinder.ts | 34 +- ...gineV2AppScreenShareDeliveryWiring.test.ts | 4180 +++++++++++++++++ ...EngineV2AppScreenShareExecutionAdapter.tsx | 526 ++- ...VoiceEngineV2AppScreenShareLiveKitFlows.ts | 63 +- ...oiceEngineV2AppScreenShareTrackPlumbing.ts | 143 +- .../voice_screen_share_manager/shared.ts | 494 +- .../voice/state/ActiveScreenShareSource.ts | 10 + .../voice/state/ScreenShareDelivery.ts | 252 + .../voice/state/ScreenShareDeliveryRollout.ts | 20 + .../src/features/voice/state/VoiceSettings.ts | 98 +- .../voice/utils/CodecCapabilityDetector.ts | 183 +- .../voice/utils/GpuEncoderCapabilities.ts | 42 +- .../voice/utils/ScreenShareCodecSelection.ts | 259 + .../voice/utils/ScreenShareLifecycleLog.ts | 28 +- .../voice/utils/ScreenShareOptions.ts | 395 +- .../voice/utils/ScreenShareStartFlow.ts | 244 +- .../features/voice/utils/StatsForNerdsCopy.ts | 4 - .../utils/VoiceMessageDescriptors.test.ts | 621 +++ .../voice/utils/VoiceMessageDescriptors.ts | 284 ++ .../utils/VoiceStatsForNerdsPresenter.ts | 2 - fluxer_desktop/package.json | 3 +- .../src/main/DesktopDebugInfo.test.mjs | 141 + fluxer_desktop/src/main/DesktopDebugInfo.ts | 26 +- fluxer_desktop/src/main/IpcHandlers.ts | 12 +- fluxer_desktop/src/main/PlatformInfo.test.mjs | 72 +- fluxer_desktop/src/main/PlatformInfo.ts | 12 +- fluxer_desktop/src/main/Troubleshooting.ts | 10 +- fluxer_desktop/src/main/index.ts | 11 +- .../src/content/docs/admin-api/instance.mdx | 29 +- .../src/content/docs/http-api/experiments.mdx | 34 +- .../schema/src/domains/admin/AdminSchemas.ts | 6 + .../experiment/ClientBehaviorExperiment.ts | 58 + .../domains/experiment/ExperimentSchemas.ts | 11 + .../experiment/ScreenShareDeliverySchemas.ts | 30 + tools/ci/src/ci_workflow.rs | 5 + tools/dev/src/tasks.rs | 7 +- 119 files changed, 27942 insertions(+), 12524 deletions(-) create mode 100644 fluxer_app/src/features/user/components/modals/tabs/UserVideoTabState.ts create mode 100644 fluxer_app/src/features/user/components/modals/tabs/advanced_settings_tab/AdvancedVideoControlsState.ts create mode 100644 fluxer_app/src/features/voice/components/ActiveScreenShareMenu.module.css delete mode 100644 fluxer_app/src/features/voice/components/useScreenShareUnderperformance.ts create mode 100644 fluxer_app/src/features/voice/engine/ScreenShareDeliveryProofAdmission.test.ts create mode 100644 fluxer_app/src/features/voice/engine/ScreenShareDeliveryProofHarness.ts create mode 100644 fluxer_app/src/features/voice/engine/ScreenShareDeliveryProofRuntimeA.test.ts create mode 100644 fluxer_app/src/features/voice/engine/ScreenShareDeliveryProofRuntimeB.test.ts create mode 100644 fluxer_app/src/features/voice/engine/ScreenShareDeliveryProofRuntimeC.test.ts create mode 100644 fluxer_app/src/features/voice/engine/ScreenShareDeliveryProofRuntimeD.test.ts create mode 100644 fluxer_app/src/features/voice/engine/ScreenShareDeliveryProofViewers.test.ts create mode 100644 fluxer_app/src/features/voice/engine/v2/VoiceEngineV2AppScreenShareDeliveryWiring.test.ts create mode 100644 fluxer_app/src/features/voice/state/ScreenShareDelivery.ts create mode 100644 fluxer_app/src/features/voice/state/ScreenShareDeliveryRollout.ts create mode 100644 fluxer_app/src/features/voice/utils/ScreenShareCodecSelection.ts create mode 100644 fluxer_app/src/features/voice/utils/VoiceMessageDescriptors.test.ts create mode 100644 fluxer_desktop/src/main/DesktopDebugInfo.test.mjs create mode 100644 packages/schema/src/domains/experiment/ClientBehaviorExperiment.ts create mode 100644 packages/schema/src/domains/experiment/ScreenShareDeliverySchemas.ts diff --git a/fluxer_admin/openapi-admin.json b/fluxer_admin/openapi-admin.json index 353d30e22..b56b332d9 100644 --- a/fluxer_admin/openapi-admin.json +++ b/fluxer_admin/openapi-admin.json @@ -10524,6 +10524,7 @@ }, "gateway_rollout": {"$ref": "#/components/schemas/GatewayRolloutConfigResponse"}, "voice_noise_suppression": {"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigResponse"}, + "screen_share_delivery": {"$ref": "#/components/schemas/ScreenShareDeliveryConfigResponse"}, "experiment_delivery": {"$ref": "#/components/schemas/ExperimentDeliveryConfigResponse"}, "registration": { "type": "object", @@ -10951,6 +10952,7 @@ "sso", "gateway_rollout", "voice_noise_suppression", + "screen_share_delivery", "experiment_delivery", "registration", "self_hosted", @@ -11085,6 +11087,10 @@ "nullable": true, "allOf": [{"$ref": "#/components/schemas/VoiceNoiseSuppressionConfigUpdateRequest"}] }, + "screen_share_delivery": { + "nullable": true, + "allOf": [{"$ref": "#/components/schemas/ScreenShareDeliveryConfigUpdateRequest"}] + }, "experiment_delivery": { "nullable": true, "allOf": [{"$ref": "#/components/schemas/ExperimentDeliveryConfigUpdateRequest"}] @@ -15178,6 +15184,24 @@ "poll_jitter_percent": {"type": "integer", "minimum": 0, "maximum": 50} } }, + "ScreenShareDeliveryConfigUpdateRequest": { + "type": "object", + "properties": { + "enabled": {"type": "boolean"}, + "rollout_basis_points": {"type": "integer", "minimum": 0, "maximum": 10000}, + "rollout_salt": {"type": "string", "minLength": 1, "maxLength": 64}, + "included_user_ids": { + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "excluded_user_ids": { + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + } + } + }, "VoiceNoiseSuppressionConfigUpdateRequest": { "type": "object", "properties": { @@ -15244,6 +15268,36 @@ "required": ["poll_interval_seconds", "poll_jitter_percent"], "additionalProperties": false }, + "ScreenShareDeliveryConfigResponse": { + "type": "object", + "properties": { + "enabled": {"default": false, "type": "boolean"}, + "config_version": {"default": 0, "type": "integer", "minimum": 0, "maximum": 9007199254740991}, + "rollout_basis_points": {"default": 0, "type": "integer", "minimum": 0, "maximum": 10000}, + "rollout_salt": {"default": "screen-share-delivery-v1", "type": "string", "minLength": 1, "maxLength": 64}, + "included_user_ids": { + "default": [], + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + }, + "excluded_user_ids": { + "default": [], + "maxItems": 1000, + "type": "array", + "items": {"type": "string", "pattern": "^\\d{1,20}$"} + } + }, + "required": [ + "enabled", + "config_version", + "rollout_basis_points", + "rollout_salt", + "included_user_ids", + "excluded_user_ids" + ], + "additionalProperties": false + }, "VoiceNoiseSuppressionConfigResponse": { "type": "object", "properties": { diff --git a/fluxer_admin/src/api/types/instance_config.rs b/fluxer_admin/src/api/types/instance_config.rs index f70d959d1..3e2ac54ba 100644 --- a/fluxer_admin/src/api/types/instance_config.rs +++ b/fluxer_admin/src/api/types/instance_config.rs @@ -23,6 +23,8 @@ pub struct InstanceConfigResponse { #[serde(default)] pub voice_noise_suppression: VoiceNoiseSuppressionConfigResponse, #[serde(default)] + pub screen_share_delivery: ScreenShareDeliveryConfigResponse, + #[serde(default)] pub experiment_delivery: ExperimentDeliveryConfigResponse, } @@ -515,6 +517,44 @@ impl Default for VoiceNoiseSuppressionConfigResponse { } } +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(default)] +pub struct ScreenShareDeliveryConfigResponse { + pub enabled: bool, + pub config_version: u64, + pub rollout_basis_points: u32, + pub rollout_salt: String, + pub included_user_ids: Vec, + pub excluded_user_ids: Vec, +} + +impl Default for ScreenShareDeliveryConfigResponse { + fn default() -> Self { + Self { + enabled: false, + config_version: 0, + rollout_basis_points: 0, + rollout_salt: "screen-share-delivery-v1".to_owned(), + included_user_ids: Vec::new(), + excluded_user_ids: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Default, Serialize)] +pub struct ScreenShareDeliveryConfigUpdateRequest { + #[serde(skip_serializing_if = "Option::is_none")] + pub enabled: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rollout_basis_points: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub rollout_salt: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub included_user_ids: Option>, + #[serde(skip_serializing_if = "Option::is_none")] + pub excluded_user_ids: Option>, +} + #[derive(Clone, Debug, Default, Serialize)] pub struct VoiceNoiseSuppressionConfigUpdateRequest { #[serde(skip_serializing_if = "Option::is_none")] @@ -657,6 +697,8 @@ pub struct InstanceConfigUpdateRequest { #[serde(skip_serializing_if = "Option::is_none")] pub voice_noise_suppression: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub screen_share_delivery: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub experiment_delivery: Option, } diff --git a/fluxer_admin/src/routes/system_actions.rs b/fluxer_admin/src/routes/system_actions.rs index 5e85166d6..4e7021610 100644 --- a/fluxer_admin/src/routes/system_actions.rs +++ b/fluxer_admin/src/routes/system_actions.rs @@ -18,9 +18,9 @@ use crate::{ InstanceRegistrationConfigUpdateRequest, InstanceServicesUpdateRequest, InstanceYoutubeIntegrationUpdateRequest, LimitConfigUpdateRequest, LimitRule, LimitRuleFilters, NoiseSuppressionBackend, PremiumMode, RegistrationMode, - SsoConfigUpdateRequest, VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS, - VoiceE2eeScope, VoiceNoiseSuppressionConfigUpdateRequest, - VoiceNoiseSuppressionGuildOverride, + ScreenShareDeliveryConfigUpdateRequest, SsoConfigUpdateRequest, + VOICE_NS_MAX_GUILD_OVERRIDES, VOICE_NS_MAX_TARGETED_USERS, VoiceE2eeScope, + VoiceNoiseSuppressionConfigUpdateRequest, VoiceNoiseSuppressionGuildOverride, }, }, config::AdminConfig, @@ -207,6 +207,10 @@ pub async fn instance_config_post( Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), }, + "update_screen_share_delivery" => match build_screen_share_delivery_update(&form) { + Ok(update) => instance_config_result(client.update_instance_config(&update).await), + Err(message) => FlashData::error(message), + }, "update_experiment_delivery" => match build_experiment_delivery_update(&form) { Ok(update) => instance_config_result(client.update_instance_config(&update).await), Err(message) => FlashData::error(message), @@ -447,10 +451,10 @@ fn build_gateway_rollout_update(form: &MultiValueForm) -> InstanceConfigUpdateRe } } -const VOICE_NS_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000; +const EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX: u32 = 10_000; const VOICE_NS_SUPPRESSION_STRENGTH_MAX: u32 = 100; -const VOICE_NS_MAX_ROLLOUT_SALT_CHARS: usize = 64; -const VOICE_NS_MAX_SNOWFLAKE_LENGTH: usize = 20; +const EXPERIMENT_MAX_ROLLOUT_SALT_CHARS: usize = 64; +const EXPERIMENT_MAX_SNOWFLAKE_LENGTH: usize = 20; const EXPERIMENT_MIN_POLL_INTERVAL_SECONDS: u64 = 60; const EXPERIMENT_MAX_POLL_INTERVAL_SECONDS: u64 = 86_400; const EXPERIMENT_MAX_POLL_JITTER_PERCENT: u32 = 50; @@ -476,35 +480,36 @@ where Ok(Some(value)) } -fn parse_voice_noise_suppression_rollout_salt( +fn parse_experiment_rollout_salt( form: &MultiValueForm, + key: &str, ) -> Result, String> { - let Some(raw) = form.first("voice_ns_rollout_salt") else { + let Some(raw) = form.first(key) else { return Ok(None); }; let salt = raw.trim(); - if salt.is_empty() || salt.encode_utf16().count() > VOICE_NS_MAX_ROLLOUT_SALT_CHARS { + if salt.is_empty() || salt.encode_utf16().count() > EXPERIMENT_MAX_ROLLOUT_SALT_CHARS { return Err(format!( - "Rollout salt must be between 1 and {VOICE_NS_MAX_ROLLOUT_SALT_CHARS} characters" + "Rollout salt must be between 1 and {EXPERIMENT_MAX_ROLLOUT_SALT_CHARS} characters" )); } Ok(Some(salt.to_owned())) } -fn is_voice_noise_suppression_snowflake(value: &str) -> bool { +fn is_experiment_snowflake(value: &str) -> bool { !value.is_empty() - && value.len() <= VOICE_NS_MAX_SNOWFLAKE_LENGTH + && value.len() <= EXPERIMENT_MAX_SNOWFLAKE_LENGTH && value.bytes().all(|byte| byte.is_ascii_digit()) } -fn parse_voice_noise_suppression_user_ids(value: &str, label: &str) -> Result, String> { +fn parse_experiment_user_ids(value: &str, label: &str) -> Result, String> { let mut ids: Vec = Vec::new(); for (index, candidate) in value.split([',', '\n', '\r']).enumerate() { let candidate = candidate.trim(); if candidate.is_empty() { continue; } - if !is_voice_noise_suppression_snowflake(candidate) { + if !is_experiment_snowflake(candidate) { return Err(format!( "{label} entry {} must contain 1 to 20 decimal digits", index + 1 @@ -536,7 +541,7 @@ fn parse_voice_noise_suppression_guild_overrides( format!("Guild overrides line {line_number} must use guild_id=backend") })?; let guild_id = guild_id.trim(); - if !is_voice_noise_suppression_snowflake(guild_id) { + if !is_experiment_snowflake(guild_id) { return Err(format!( "Guild overrides line {line_number} must use a guild ID with 1 to 20 decimal digits" )); @@ -568,6 +573,38 @@ fn parse_voice_noise_suppression_guild_overrides( Ok(overrides) } +fn build_screen_share_delivery_update( + form: &MultiValueForm, +) -> Result { + Ok(InstanceConfigUpdateRequest { + screen_share_delivery: Some(ScreenShareDeliveryConfigUpdateRequest { + enabled: Some(form.bool_value("screen_share_delivery_enabled")), + rollout_basis_points: parse_form_number( + form, + "screen_share_delivery_rollout_basis_points", + "Rollout basis points", + 0, + EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX, + )?, + rollout_salt: parse_experiment_rollout_salt( + form, + "screen_share_delivery_rollout_salt", + )?, + included_user_ids: Some(parse_experiment_user_ids( + form.first("screen_share_delivery_included_user_ids") + .unwrap_or_default(), + "Included user IDs", + )?), + excluded_user_ids: Some(parse_experiment_user_ids( + form.first("screen_share_delivery_excluded_user_ids") + .unwrap_or_default(), + "Excluded user IDs", + )?), + }), + ..Default::default() + }) +} + fn build_voice_noise_suppression_update( form: &MultiValueForm, ) -> Result { @@ -602,14 +639,14 @@ fn build_voice_noise_suppression_update( "voice_ns_rollout_basis_points", "Rollout basis points", 0, - VOICE_NS_ROLLOUT_BASIS_POINTS_MAX, + EXPERIMENT_ROLLOUT_BASIS_POINTS_MAX, )?, - rollout_salt: parse_voice_noise_suppression_rollout_salt(form)?, - included_user_ids: Some(parse_voice_noise_suppression_user_ids( + rollout_salt: parse_experiment_rollout_salt(form, "voice_ns_rollout_salt")?, + included_user_ids: Some(parse_experiment_user_ids( form.first("voice_ns_included_user_ids").unwrap_or_default(), "Included user IDs", )?), - excluded_user_ids: Some(parse_voice_noise_suppression_user_ids( + excluded_user_ids: Some(parse_experiment_user_ids( form.first("voice_ns_excluded_user_ids").unwrap_or_default(), "Excluded user IDs", )?), @@ -1307,9 +1344,9 @@ mod tests { } #[test] - fn parse_voice_noise_suppression_user_ids_splits_newlines_and_commas() { + fn parse_experiment_user_ids_splits_newlines_and_commas() { assert_eq!( - parse_voice_noise_suppression_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs") + parse_experiment_user_ids(" 1 ,2\n3\r\n 4 ,, 5 ", "Included user IDs") .expect("valid IDs"), vec![ "1".to_owned(), @@ -1322,16 +1359,15 @@ mod tests { } #[test] - fn parse_voice_noise_suppression_user_ids_dedupes_preserving_order() { + fn parse_experiment_user_ids_dedupes_preserving_order() { assert_eq!( - parse_voice_noise_suppression_user_ids("20,10,20,10,30", "Included user IDs") - .expect("valid IDs"), + parse_experiment_user_ids("20,10,20,10,30", "Included user IDs").expect("valid IDs"), vec!["20".to_owned(), "10".to_owned(), "30".to_owned()] ); } #[test] - fn parse_voice_noise_suppression_user_ids_rejects_non_digit_and_overlong_values() { + fn parse_experiment_user_ids_rejects_non_digit_and_overlong_values() { for value in [ "abc", "12a", @@ -1341,11 +1377,8 @@ mod tests { "