470 lines
16 KiB
Rust
470 lines
16 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 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 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,
|
|
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),
|
|
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,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
#[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={} 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_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 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)?,
|
|
"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,
|
|
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_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));
|
|
}
|
|
}
|