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 { "