//! Live media feed controls shared by the launcher and relay child. use std::{ fs, path::{Path, PathBuf}, sync::{Arc, Mutex}, time::{SystemTime, UNIX_EPOCH}, }; use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; pub const MEDIA_CONTROL_ENV: &str = "LESAVKA_MEDIA_CONTROL"; pub const DEFAULT_MEDIA_CONTROL_PATH: &str = "/tmp/lesavka-media.control"; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) enum MediaDeviceChoice { Inherit, Auto, Selected(String), } impl MediaDeviceChoice { #[must_use] pub fn from_selection(selection: Option) -> Self { selection .filter(|value| !value.trim().is_empty()) .map(Self::Selected) .unwrap_or(Self::Auto) } #[must_use] pub fn resolve(&self, fallback: Option<&str>) -> Option { match self { Self::Inherit => fallback.map(str::to_string), Self::Auto => None, Self::Selected(value) => Some(value.clone()), } } } #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct MediaControlState { pub camera: bool, pub microphone: bool, pub audio: bool, pub camera_source: MediaDeviceChoice, pub camera_profile: MediaDeviceChoice, pub microphone_source: MediaDeviceChoice, pub audio_sink: MediaDeviceChoice, } impl MediaControlState { #[must_use] pub fn new(camera: bool, microphone: bool, audio: bool) -> Self { Self { camera, microphone, audio, camera_source: MediaDeviceChoice::Inherit, camera_profile: MediaDeviceChoice::Inherit, microphone_source: MediaDeviceChoice::Inherit, audio_sink: MediaDeviceChoice::Inherit, } } #[must_use] pub fn with_devices( camera: bool, microphone: bool, audio: bool, camera_source: Option, camera_profile: Option, microphone_source: Option, audio_sink: Option, ) -> Self { Self { camera, microphone, audio, camera_source: MediaDeviceChoice::from_selection(camera_source), camera_profile: MediaDeviceChoice::from_selection(camera_profile), microphone_source: MediaDeviceChoice::from_selection(microphone_source), audio_sink: MediaDeviceChoice::from_selection(audio_sink), } } } #[derive(Clone, Debug)] pub(crate) struct LiveMediaControls { path: PathBuf, inner: Arc>, } #[derive(Debug)] struct LiveMediaControlsInner { state: MediaControlState, } impl LiveMediaControls { #[must_use] pub fn from_env(initial: MediaControlState) -> Self { let path = std::env::var(MEDIA_CONTROL_ENV) .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from(DEFAULT_MEDIA_CONTROL_PATH)); let controls = Self { path, inner: Arc::new(Mutex::new(LiveMediaControlsInner { state: initial })), }; let _ = controls.refresh(); controls } /// Reloads the launcher-written soft-pause switches, falling back safely on read errors. pub fn refresh(&self) -> MediaControlState { let Ok(mut inner) = self.inner.lock() else { return MediaControlState::new(true, true, true); }; if let Ok(raw) = fs::read_to_string(&self.path) && let Some(state) = parse_media_control_state(&raw) { inner.state = state; } inner.state.clone() } } /// Writes one atomic-ish soft-pause/device request for the running relay child to poll. pub(crate) fn write_media_control_request( path: &Path, state: MediaControlState, ) -> std::io::Result<()> { fs::write( path, format!( "camera={} microphone={} audio={} camera_source={} camera_profile={} microphone_source={} audio_sink={} nonce={}\n", bool_flag(state.camera), bool_flag(state.microphone), bool_flag(state.audio), encode_choice(&state.camera_source), encode_choice(&state.camera_profile), encode_choice(&state.microphone_source), encode_choice(&state.audio_sink), control_request_nonce(), ), ) } /// Parses the small launcher control-file grammar used across process boundaries. fn parse_media_control_state(raw: &str) -> Option { let mut camera = None; let mut microphone = None; let mut audio = None; let mut camera_source = MediaDeviceChoice::Inherit; let mut camera_profile = MediaDeviceChoice::Inherit; let mut microphone_source = MediaDeviceChoice::Inherit; let mut audio_sink = MediaDeviceChoice::Inherit; for token in raw.split_ascii_whitespace() { let Some((key, value)) = token.split_once('=') else { continue; }; match key { "camera" => camera = Some(parse_bool_flag(value)?), "microphone" | "mic" => microphone = Some(parse_bool_flag(value)?), "audio" | "speaker" => audio = Some(parse_bool_flag(value)?), "camera_source" | "camera_source_b64" => camera_source = parse_choice(value)?, "camera_profile" | "camera_quality" => camera_profile = parse_choice(value)?, "microphone_source" | "mic_source" | "microphone_source_b64" => { microphone_source = parse_choice(value)?; } "audio_sink" | "speaker_sink" | "audio_sink_b64" => audio_sink = parse_choice(value)?, _ => {} } } Some(MediaControlState { camera: camera?, microphone: microphone?, audio: audio?, camera_source, camera_profile, microphone_source, audio_sink, }) } fn encode_choice(choice: &MediaDeviceChoice) -> String { match choice { MediaDeviceChoice::Inherit => "inherit".to_string(), MediaDeviceChoice::Auto => "auto".to_string(), MediaDeviceChoice::Selected(value) => format!("b64:{}", B64.encode(value.as_bytes())), } } fn parse_choice(value: &str) -> Option { let value = value.trim(); if value.is_empty() || value.eq_ignore_ascii_case("auto") { return Some(MediaDeviceChoice::Auto); } if value.eq_ignore_ascii_case("inherit") { return Some(MediaDeviceChoice::Inherit); } if let Some(encoded) = value.strip_prefix("b64:") { let decoded = B64.decode(encoded).ok()?; let decoded = String::from_utf8(decoded).ok()?; return Some(MediaDeviceChoice::from_selection(Some(decoded))); } Some(MediaDeviceChoice::from_selection(Some(value.to_string()))) } fn parse_bool_flag(value: &str) -> Option { match value.trim().to_ascii_lowercase().as_str() { "1" | "true" | "on" | "yes" => Some(true), "0" | "false" | "off" | "no" => Some(false), _ => None, } } const fn bool_flag(enabled: bool) -> &'static str { if enabled { "1" } else { "0" } } fn control_request_nonce() -> u128 { SystemTime::now() .duration_since(UNIX_EPOCH) .map(|duration| duration.as_nanos()) .unwrap_or_default() } #[cfg(test)] mod tests { use super::*; #[test] fn parses_media_control_state_from_launcher_file() { assert_eq!( parse_media_control_state("camera=1 microphone=0 audio=true 123"), Some(MediaControlState::new(true, false, true)) ); } #[test] fn parses_media_control_state_with_live_device_choices() { let state = MediaControlState::with_devices( true, true, true, Some("Logitech BRIO".to_string()), Some("1280x720@30".to_string()), Some("alsa_input.usb-Neat Microphones".to_string()), None, ); let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("media.control"); write_media_control_request(&path, state.clone()).expect("write controls"); let raw = fs::read_to_string(path).expect("read controls"); assert_eq!(parse_media_control_state(&raw), Some(state)); } #[test] fn device_choices_resolve_inherit_auto_and_selected() { assert_eq!( MediaDeviceChoice::Inherit.resolve(Some("env-device")), Some("env-device".to_string()) ); assert_eq!(MediaDeviceChoice::Auto.resolve(Some("env-device")), None); assert_eq!( MediaDeviceChoice::Selected("chosen".to_string()).resolve(Some("env-device")), Some("chosen".to_string()) ); } #[test] fn live_media_controls_refresh_after_file_changes() { let dir = tempfile::tempdir().expect("tempdir"); let path = dir.path().join("media.control"); write_media_control_request(&path, MediaControlState::new(true, true, false)) .expect("write initial controls"); let controls = LiveMediaControls { path: path.clone(), inner: Arc::new(Mutex::new(LiveMediaControlsInner { state: MediaControlState::new(false, false, false), })), }; assert_eq!( controls.refresh(), MediaControlState::new(true, true, false) ); std::thread::sleep(std::time::Duration::from_millis(5)); write_media_control_request(&path, MediaControlState::new(false, true, true)) .expect("write updated controls"); assert_eq!( controls.refresh(), MediaControlState::new(false, true, true) ); } #[test] fn from_env_default_path_fallback_is_safe() { let controls = LiveMediaControls::from_env(MediaControlState::new(true, false, true)); let _ = controls.refresh(); } #[test] fn parser_tolerates_unknown_tokens_and_rejects_invalid_flags() { assert_eq!( parse_media_control_state("camera=on extra=ignored microphone=no audio=off"), Some(MediaControlState::new(true, false, false)) ); assert_eq!( parse_media_control_state("camera=maybe microphone=1 audio=1"), None ); } #[test] fn refresh_falls_back_to_all_enabled_if_lock_is_poisoned() { let controls = LiveMediaControls { path: PathBuf::from("/definitely/not/a/real/lesavka-media.control"), inner: Arc::new(Mutex::new(LiveMediaControlsInner { state: MediaControlState::new(false, false, false), })), }; let inner = Arc::clone(&controls.inner); let _ = std::panic::catch_unwind(move || { let _guard = inner.lock().expect("lock"); panic!("poison media controls lock"); }); assert_eq!(controls.refresh(), MediaControlState::new(true, true, true)); } }