use crate::plugins::external_plugin_gui_compatible;
use crate::video::{PlaybackBackend, PlaybackPhase, PlaybackStatus, VideoFrameSink};
use eframe::egui;
use guth_media::{PlayerCommand, PlayerEvent};
use std::fs::File;
use std::io::{self, BufReader};
use std::os::fd::AsRawFd;
use std::os::unix::fs::MetadataExt;
use std::os::unix::process::CommandExt;
use std::path::Path;
use std::process::{Child, ChildStdin, Command, Stdio};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::{Duration, Instant};
#[derive(Default)]
struct ProcessBackend {
child: Option<Child>,
input: Option<ChildStdin>,
reader: Option<JoinHandle<()>>,
status: Arc<Mutex<PlaybackStatus>>,
error: Option<String>,
}
pub fn create() -> Result<Box<dyn PlaybackBackend>, String> {
Ok(Box::<ProcessBackend>::default())
}
fn verified_executable(path: &Path) -> io::Result<File> {
let file = File::from(rustix::fs::open(
path,
rustix::fs::OFlags::RDONLY
| rustix::fs::OFlags::NOFOLLOW
| rustix::fs::OFlags::NONBLOCK
| rustix::fs::OFlags::CLOEXEC,
rustix::fs::Mode::empty(),
)?);
let metadata = file.metadata()?;
if !metadata.is_file()
|| (metadata.uid() != rustix::process::geteuid().as_raw() && metadata.uid() != 0)
|| metadata.mode() & 0o7022 != 0
|| metadata.mode() & 0o111 == 0
{
return Err(io::Error::new(
io::ErrorKind::PermissionDenied,
"untrusted guth-media executable",
));
}
Ok(file)
}
impl ProcessBackend {
fn launch(
&mut self,
program: &Path,
path: &Path,
frames: VideoFrameSink,
volume: u8,
) -> Result<(), String> {
self.stop();
self.error = None;
let executable = verified_executable(program).map_err(|e| e.to_string())?;
rustix::io::fcntl_setfd(&executable, rustix::io::FdFlags::empty())
.map_err(|e| e.to_string())?;
let child = Command::new(format!("/proc/self/fd/{}", executable.as_raw_fd()))
.args([guth_media::PROTOCOL_ARGUMENT, guth_media::PROTOCOL_VERSION])
.arg(frames.max_texture_side.to_string())
.arg(volume.min(100).to_string())
.arg(path)
.process_group(0)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.map_err(|e| format!("Cannot start guth-media: {e}"))?;
self.child = Some(child);
let child = self.child.as_mut().unwrap();
self.input = child.stdin.take();
let stdout = child
.stdout
.take()
.ok_or("Missing guth-media output pipe")?;
let input = self
.input
.as_ref()
.ok_or("Missing guth-media control pipe")?;
rustix::fs::fcntl_setfl(input, rustix::fs::OFlags::NONBLOCK).map_err(|e| e.to_string())?;
self.status = Arc::new(Mutex::new(PlaybackStatus {
phase: PlaybackPhase::Loading,
..Default::default()
}));
let snapshot = Arc::clone(&self.status);
self.reader = Some(
thread::Builder::new()
.name("guth-media-frames".into())
.spawn(move || {
let mut reader = BufReader::new(stdout);
loop {
match guth_media::read_event(&mut reader, frames.max_texture_side) {
Ok(PlayerEvent::Status(status)) => {
*snapshot.lock().unwrap_or_else(|e| e.into_inner()) = status;
}
Ok(PlayerEvent::Frame(frame)) => {
if !frames.is_current() {
break;
}
frames.publish(egui::ColorImage::from_rgba_unmultiplied(
frame.size,
&frame.rgba,
));
}
Err(error) => {
let mut status = snapshot.lock().unwrap_or_else(|e| e.into_inner());
if frames.is_current() && status.phase != PlaybackPhase::Failed {
status.phase = PlaybackPhase::Failed;
status.error = Some(format!(
"guth-media stopped or sent invalid playback data: {error}"
));
}
break;
}
}
}
})
.map_err(|e| e.to_string())?,
);
Ok(())
}
fn send(&mut self, command: PlayerCommand) {
if let Some(input) = &mut self.input {
if let Err(error) = guth_media::write_command(input, command) {
self.error = Some(format!("guth-media is not accepting controls: {error}"));
}
}
}
}
impl PlaybackBackend for ProcessBackend {
fn load(&mut self, path: &Path, frames: VideoFrameSink, volume: u8) -> Result<(), String> {
let enabled = guth_cli::load_enabled_plugins().map_err(|e| e.to_string())?;
let plugins = guth_cli::discover_plugins().map_err(|e| e.to_string())?;
let plugin = plugins
.iter()
.find(|plugin| {
enabled.contains(&plugin.id)
&& external_plugin_gui_compatible(
plugin,
guth_media::PLUGIN_ID,
guth_media::CAPABILITY,
)
})
.ok_or("Install and enable guth-media in Settings → Plugins to play video inline.")?;
self.launch(&plugin.executable, path, frames, volume)
.inspect_err(|_| self.stop())
}
fn status(&mut self) -> PlaybackStatus {
if let Some(error) = &self.error {
return PlaybackStatus {
phase: PlaybackPhase::Failed,
error: Some(error.clone()),
..Default::default()
};
}
self.status
.lock()
.unwrap_or_else(|e| e.into_inner())
.clone()
}
fn set_paused(&mut self, paused: bool) {
self.send(PlayerCommand::Pause(paused));
}
fn set_volume(&mut self, volume: u8) {
self.send(PlayerCommand::Volume(volume.min(100)));
}
fn seek(&mut self, milliseconds: u64) {
self.send(PlayerCommand::Seek(milliseconds));
}
fn stop(&mut self) {
self.input = None;
if let Some(mut child) = self.child.take() {
if let Some(group) = i32::try_from(child.id())
.ok()
.and_then(rustix::process::Pid::from_raw)
{
let _ = rustix::process::kill_process_group(group, rustix::process::Signal::TERM);
let deadline = Instant::now() + Duration::from_millis(250);
while matches!(child.try_wait(), Ok(None)) && Instant::now() < deadline {
thread::sleep(Duration::from_millis(10));
}
let _ = rustix::process::kill_process_group(group, rustix::process::Signal::KILL);
}
let _ = child.kill();
let _ = child.wait();
}
if let Some(reader) = self.reader.take() {
let _ = reader.join();
}
}
}
impl Drop for ProcessBackend {
fn drop(&mut self) {
self.stop();
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::video::VideoPlayer;
#[test]
fn plugin_dispatch_rejects_unsafe_permissions_symlinks_and_special_files() {
use std::os::unix::fs::{symlink, PermissionsExt};
let directory = std::env::temp_dir().join(format!(
"guth-media-dispatch-{}-{}",
std::process::id(),
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_nanos()
));
std::fs::create_dir(&directory).unwrap();
let executable = directory.join("player");
std::fs::write(&executable, b"test executable placeholder").unwrap();
for mode in [0o600, 0o722, 0o4700] {
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(mode)).unwrap();
assert!(verified_executable(&executable).is_err());
}
std::fs::set_permissions(&executable, std::fs::Permissions::from_mode(0o700)).unwrap();
assert!(verified_executable(&executable).is_ok());
let link = directory.join("link");
symlink(&executable, &link).unwrap();
assert!(verified_executable(&link).is_err());
assert!(verified_executable(&directory).is_err());
let fifo = directory.join("fifo");
rustix::fs::mknodat(
rustix::fs::CWD,
&fifo,
rustix::fs::FileType::Fifo,
rustix::fs::Mode::RWXU,
0,
)
.unwrap();
assert!(verified_executable(&fifo).is_err());
std::fs::remove_file(link).unwrap();
std::fs::remove_file(fifo).unwrap();
std::fs::remove_file(executable).unwrap();
std::fs::remove_dir(directory).unwrap();
}
#[test]
#[ignore = "requires an installed/enabled guth-media plugin, VLC 3 codecs, and GUTH_VIDEO_TEST_FILE (1920x1080, at least 6 seconds)"]
fn native_playback_decodes_pauses_seeks_replays_and_stops() {
let path = std::env::var_os("GUTH_VIDEO_TEST_FILE").expect("set GUTH_VIDEO_TEST_FILE");
let ctx = egui::Context::default();
let mut video = VideoPlayer::with_backend(create);
video.muted = true;
video.start(Path::new(&path), &ctx);
fn wait(
video: &mut VideoPlayer,
ctx: &egui::Context,
predicate: impl Fn(&VideoPlayer) -> bool,
) {
let started = Instant::now();
loop {
video.poll(ctx);
assert_ne!(
video.status.phase,
PlaybackPhase::Failed,
"{:?}",
video.status.error
);
if predicate(video) {
return;
}
assert!(
started.elapsed() < Duration::from_secs(15),
"timed out: {:?}, dimensions {:?}",
video.status,
video.dimensions
);
std::thread::sleep(Duration::from_millis(20));
}
}
wait(&mut video, &ctx, |v| {
v.dimensions == Some([1920, 1080]) && v.status.position_ms > 300
});
assert_eq!(video.texture().unwrap().size(), [1920, 1080]);
assert!(video.status.seekable);
video.toggle(&ctx);
wait(&mut video, &ctx, |v| {
v.status.phase == PlaybackPhase::Paused
});
let paused_at = video.status.position_ms;
std::thread::sleep(Duration::from_millis(200));
video.poll(&ctx);
assert!(video.status.position_ms.abs_diff(paused_at) < 100);
let frames_before_seek = video.presented_frames;
video.seek(3_000);
wait(&mut video, &ctx, |v| {
v.status.position_ms >= 2_900 && v.presented_frames > frames_before_seek
});
assert_eq!(video.status.phase, PlaybackPhase::Paused);
std::thread::sleep(Duration::from_millis(500));
video.poll(&ctx);
assert!(
video.status.position_ms.abs_diff(3_000) < 150,
"Paused seek timecode drifted: {:?}",
video.status
);
video.toggle(&ctx);
wait(&mut video, &ctx, |v| {
v.status.phase == PlaybackPhase::Playing
});
video.seek(video.status.duration_ms.saturating_sub(300));
wait(&mut video, &ctx, |v| v.status.phase == PlaybackPhase::Ended);
video.toggle(&ctx);
wait(&mut video, &ctx, |v| {
v.status.phase == PlaybackPhase::Playing && v.dimensions == Some([1920, 1080])
});
assert!(video.status.position_ms < 2_000);
video.stop();
assert!(!video.has_source());
assert!(video.texture().is_none());
assert_eq!(video.status.phase, PlaybackPhase::Idle);
}
}