guth 0.2.45

Native Rust desktop file manager for fast, bounded local file workflows.
Documentation
//! Optional media-plugin transport. No native decoder is loaded in Guth.

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())?;
        // Execute the checked inode, not a replaceable pathname. This follows the
        // same verified-descriptor dispatch contract as other Guth plugins.
        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")?;
        // Controls are tiny atomic pipe writes. An unresponsive plugin must not
        // block the playback worker or prevent close/disable from terminating it.
        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();
        }
        // The killed process group closes all output pipe writers.
        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);
    }
}