201 lines
6.0 KiB
Rust
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));
|
||
|
|
}
|
||
|
|
}
|