lesavka/client/src/live_media_control.rs

553 lines
19 KiB
Rust

//! 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};
use lesavka_common::audio_transport::{UpstreamAudioCodec, parse_upstream_audio_codec};
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),
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum MediaAudioCodecChoice {
Inherit,
Selected(UpstreamAudioCodec),
}
impl MediaAudioCodecChoice {
#[must_use]
pub fn selected(codec: UpstreamAudioCodec) -> Self {
Self::Selected(codec)
}
#[must_use]
pub fn resolve(&self, fallback: UpstreamAudioCodec) -> UpstreamAudioCodec {
match self {
Self::Inherit => fallback,
Self::Selected(codec) => *codec,
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum MediaCameraCodecChoice {
Inherit,
Selected(String),
}
impl MediaCameraCodecChoice {
#[must_use]
pub fn selected(codec: Option<String>) -> Self {
codec
.filter(|value| !value.trim().is_empty())
.map(|value| Self::Selected(value.trim().to_ascii_lowercase()))
.unwrap_or(Self::Inherit)
}
#[must_use]
pub fn resolve(&self, fallback: Option<&str>) -> Option<String> {
match self {
Self::Inherit => fallback.map(str::to_string),
Self::Selected(codec) => Some(codec.clone()),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub(crate) enum MediaNoiseSuppressionChoice {
Inherit,
Enabled,
Disabled,
}
impl MediaNoiseSuppressionChoice {
#[must_use]
pub fn selected(enabled: bool) -> Self {
if enabled {
Self::Enabled
} else {
Self::Disabled
}
}
#[must_use]
pub fn resolve(&self, fallback: bool) -> bool {
match self {
Self::Inherit => fallback,
Self::Enabled => true,
Self::Disabled => false,
}
}
}
impl MediaDeviceChoice {
#[must_use]
pub fn from_selection(selection: Option<String>) -> Self {
selection
.filter(|value| !value.trim().is_empty())
.map(Self::Selected)
.unwrap_or(Self::Auto)
}
#[must_use]
/// Keeps `resolve` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
/// Inputs are the typed parameters; output is the return value or side effect.
pub fn resolve(&self, fallback: Option<&str>) -> Option<String> {
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,
pub camera_codec: MediaCameraCodecChoice,
pub audio_codec: MediaAudioCodecChoice,
pub noise_suppression: MediaNoiseSuppressionChoice,
}
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,
camera_codec: MediaCameraCodecChoice::Inherit,
audio_codec: MediaAudioCodecChoice::Inherit,
noise_suppression: MediaNoiseSuppressionChoice::Inherit,
}
}
#[must_use]
pub fn with_devices(
camera: bool,
microphone: bool,
audio: bool,
camera_source: Option<String>,
camera_profile: Option<String>,
microphone_source: Option<String>,
audio_sink: Option<String>,
) -> 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),
camera_codec: MediaCameraCodecChoice::Inherit,
audio_codec: MediaAudioCodecChoice::Inherit,
noise_suppression: MediaNoiseSuppressionChoice::Inherit,
}
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn with_devices_and_audio(
camera: bool,
microphone: bool,
audio: bool,
camera_source: Option<String>,
camera_profile: Option<String>,
microphone_source: Option<String>,
audio_sink: Option<String>,
audio_codec: UpstreamAudioCodec,
noise_suppression: bool,
) -> Self {
Self {
audio_codec: MediaAudioCodecChoice::selected(audio_codec),
noise_suppression: MediaNoiseSuppressionChoice::selected(noise_suppression),
..Self::with_devices(
camera,
microphone,
audio,
camera_source,
camera_profile,
microphone_source,
audio_sink,
)
}
}
#[must_use]
#[allow(clippy::too_many_arguments)]
pub fn with_devices_and_codecs(
camera: bool,
microphone: bool,
audio: bool,
camera_source: Option<String>,
camera_profile: Option<String>,
microphone_source: Option<String>,
audio_sink: Option<String>,
camera_codec: Option<String>,
audio_codec: UpstreamAudioCodec,
noise_suppression: bool,
) -> Self {
Self {
camera_codec: MediaCameraCodecChoice::selected(camera_codec),
audio_codec: MediaAudioCodecChoice::selected(audio_codec),
noise_suppression: MediaNoiseSuppressionChoice::selected(noise_suppression),
..Self::with_devices(
camera,
microphone,
audio,
camera_source,
camera_profile,
microphone_source,
audio_sink,
)
}
}
}
#[derive(Clone, Debug)]
pub(crate) struct LiveMediaControls {
path: PathBuf,
inner: Arc<Mutex<LiveMediaControlsInner>>,
}
#[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={} camera_codec={} audio_codec={} noise_suppression={} 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),
encode_camera_codec_choice(&state.camera_codec),
encode_audio_codec_choice(&state.audio_codec),
encode_noise_suppression_choice(&state.noise_suppression),
control_request_nonce(),
),
)
}
/// Parses the small launcher control-file grammar used across process boundaries.
fn parse_media_control_state(raw: &str) -> Option<MediaControlState> {
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;
let mut camera_codec = MediaCameraCodecChoice::Inherit;
let mut audio_codec = MediaAudioCodecChoice::Inherit;
let mut noise_suppression = MediaNoiseSuppressionChoice::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)?,
"camera_codec" | "uplink_camera_codec" | "webcam_transport" => {
camera_codec = parse_camera_codec_choice(value)?;
}
"audio_codec" | "uplink_audio_codec" => {
audio_codec = parse_audio_codec_choice(value)?;
}
"noise_suppression" | "mic_noise_suppression" => {
noise_suppression = parse_noise_suppression_choice(value)?;
}
_ => {}
}
}
Some(MediaControlState {
camera: camera?,
microphone: microphone?,
audio: audio?,
camera_source,
camera_profile,
microphone_source,
audio_sink,
camera_codec,
audio_codec,
noise_suppression,
})
}
/// Keeps `encode_choice` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
/// Inputs are the typed parameters; output is the return value or side effect.
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())),
}
}
/// Keeps `parse_choice` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
/// Inputs are the typed parameters; output is the return value or side effect.
fn parse_choice(value: &str) -> Option<MediaDeviceChoice> {
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 encode_camera_codec_choice(choice: &MediaCameraCodecChoice) -> &str {
match choice {
MediaCameraCodecChoice::Inherit => "inherit",
MediaCameraCodecChoice::Selected(codec) => codec.as_str(),
}
}
fn parse_camera_codec_choice(value: &str) -> Option<MediaCameraCodecChoice> {
let value = value.trim();
if value.eq_ignore_ascii_case("inherit") || value.is_empty() {
return Some(MediaCameraCodecChoice::Inherit);
}
match value.to_ascii_lowercase().as_str() {
"mjpeg" | "mjpg" | "jpeg" => Some(MediaCameraCodecChoice::Selected("mjpeg".to_string())),
"hevc" | "h265" | "h.265" => Some(MediaCameraCodecChoice::Selected("hevc".to_string())),
"h264" => Some(MediaCameraCodecChoice::Selected("h264".to_string())),
_ => None,
}
}
fn encode_audio_codec_choice(choice: &MediaAudioCodecChoice) -> &'static str {
match choice {
MediaAudioCodecChoice::Inherit => "inherit",
MediaAudioCodecChoice::Selected(codec) => codec.as_id(),
}
}
fn parse_audio_codec_choice(value: &str) -> Option<MediaAudioCodecChoice> {
let value = value.trim();
if value.eq_ignore_ascii_case("inherit") || value.is_empty() {
return Some(MediaAudioCodecChoice::Inherit);
}
parse_upstream_audio_codec(value).map(MediaAudioCodecChoice::Selected)
}
fn encode_noise_suppression_choice(choice: &MediaNoiseSuppressionChoice) -> &'static str {
match choice {
MediaNoiseSuppressionChoice::Inherit => "inherit",
MediaNoiseSuppressionChoice::Enabled => "1",
MediaNoiseSuppressionChoice::Disabled => "0",
}
}
fn parse_noise_suppression_choice(value: &str) -> Option<MediaNoiseSuppressionChoice> {
if value.trim().eq_ignore_ascii_case("inherit") || value.trim().is_empty() {
return Some(MediaNoiseSuppressionChoice::Inherit);
}
parse_bool_flag(value).map(MediaNoiseSuppressionChoice::selected)
}
/// Keeps `parse_bool_flag` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
/// Inputs are the typed parameters; output is the return value or side effect.
fn parse_bool_flag(value: &str) -> Option<bool> {
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]
/// Keeps `parses_media_control_state_with_live_device_choices` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
/// Inputs are the typed parameters; output is the return value or side effect.
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]
/// Keeps `live_media_controls_refresh_after_file_changes` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
/// Inputs are the typed parameters; output is the return value or side effect.
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]
/// Keeps `refresh_falls_back_to_all_enabled_if_lock_is_poisoned` explicit because it sits on this module contract, where hidden behavior would make regressions difficult to diagnose.
/// Inputs are the typed parameters; output is the return value or side effect.
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));
}
}