lesavka/client/src/live_media_control.rs

201 lines
6.0 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},
};
pub const MEDIA_CONTROL_ENV: &str = "LESAVKA_MEDIA_CONTROL";
pub const DEFAULT_MEDIA_CONTROL_PATH: &str = "/tmp/lesavka-media.control";
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct MediaControlState {
pub camera: bool,
pub microphone: bool,
pub audio: bool,
}
impl MediaControlState {
#[must_use]
pub const fn new(camera: bool, microphone: bool, audio: bool) -> Self {
Self {
camera,
microphone,
audio,
}
}
}
#[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
}
}
/// Writes one atomic-ish soft-pause 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={} {}\n",
bool_flag(state.camera),
bool_flag(state.microphone),
bool_flag(state.audio),
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;
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)?),
_ => {}
}
}
Some(MediaControlState {
camera: camera?,
microphone: microphone?,
audio: audio?,
})
}
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 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));
}
}