lesavka/client/src/live_media_control.rs

331 lines
11 KiB
Rust
Raw Normal View History

2026-04-30 15:04:00 -03:00
//! 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};
2026-04-30 15:04:00 -03:00
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<String>) -> 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<String> {
match self {
Self::Inherit => fallback.map(str::to_string),
Self::Auto => None,
Self::Selected(value) => Some(value.clone()),
}
}
}
#[derive(Clone, Debug, PartialEq, Eq)]
2026-04-30 15:04:00 -03:00
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,
2026-04-30 15:04:00 -03:00
}
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<String>,
camera_profile: Option<String>,
microphone_source: Option<String>,
audio_sink: Option<String>,
) -> Self {
2026-04-30 15:04:00 -03:00
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),
2026-04-30 15:04:00 -03:00
}
}
}
#[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()
2026-04-30 15:04:00 -03:00
}
}
/// Writes one atomic-ish soft-pause/device request for the running relay child to poll.
2026-04-30 15:04:00 -03:00
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",
2026-04-30 15:04:00 -03:00
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(),
2026-04-30 15:04:00 -03:00
),
)
}
/// 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;
2026-04-30 15:04:00 -03:00
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)?,
2026-04-30 15:04:00 -03:00
_ => {}
}
}
Some(MediaControlState {
camera: camera?,
microphone: microphone?,
audio: audio?,
camera_source,
camera_profile,
microphone_source,
audio_sink,
2026-04-30 15:04:00 -03:00
})
}
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<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())))
}
2026-04-30 15:04:00 -03:00
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]
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())
);
}
2026-04-30 15:04:00 -03:00
#[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));
}
}