caery 0.1.22

KnottDynamics-styled Linux desktop media converter for audio and video.
Documentation
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;

const CAERY_ICON_PNG: &[u8] = include_bytes!("../assets/logo.png");

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InstalledLauncher {
    pub desktop_entry: PathBuf,
    pub icon: PathBuf,
    pub desktop_shortcut: Option<PathBuf>,
}

#[derive(Debug, thiserror::Error)]
pub enum InstallError {
    #[error("Caery desktop installation is only supported on Linux")]
    UnsupportedPlatform,
    #[error("HOME is not set; cannot determine local desktop paths")]
    MissingHome,
    #[error("failed to resolve current executable: {0}")]
    CurrentExe(std::io::Error),
    #[error("failed to {action} {path}: {source}")]
    Io {
        action: &'static str,
        path: String,
        source: std::io::Error,
    },
}

pub fn install_desktop_launcher() -> Result<InstalledLauncher, InstallError> {
    if !cfg!(target_os = "linux") {
        return Err(InstallError::UnsupportedPlatform);
    }

    let exe = env::current_exe().map_err(InstallError::CurrentExe)?;
    let home = home_dir()?;
    let data_home = data_home(&home);
    let applications_dir = data_home.join("applications");
    let icon_dir = data_home.join("icons/hicolor/256x256/apps");
    let desktop_entry = applications_dir.join("caery.desktop");
    let icon = icon_dir.join("caery.png");

    create_dir_all(&applications_dir)?;
    create_dir_all(&icon_dir)?;
    write_bytes(&icon, CAERY_ICON_PNG)?;

    let entry = desktop_entry_contents(&exe, &icon);
    write_file(&desktop_entry, &entry)?;
    mark_executable(&desktop_entry)?;

    let desktop_shortcut = install_desktop_shortcut(&home, &entry)?;
    refresh_desktop_databases(&data_home);

    Ok(InstalledLauncher {
        desktop_entry,
        icon,
        desktop_shortcut,
    })
}

fn home_dir() -> Result<PathBuf, InstallError> {
    env::var_os("HOME")
        .map(PathBuf::from)
        .filter(|path| !path.as_os_str().is_empty())
        .ok_or(InstallError::MissingHome)
}

fn data_home(home: &Path) -> PathBuf {
    if let Some(path) = env::var_os("XDG_DATA_HOME") {
        if !path.is_empty() {
            return PathBuf::from(path);
        }
    }

    home.join(".local/share")
}

fn install_desktop_shortcut(home: &Path, entry: &str) -> Result<Option<PathBuf>, InstallError> {
    let desktop_dir = home.join("Desktop");
    if !desktop_dir.is_dir() {
        return Ok(None);
    }

    let shortcut = desktop_dir.join("Caery.desktop");
    write_file(&shortcut, entry)?;
    mark_executable(&shortcut)?;
    Ok(Some(shortcut))
}

fn create_dir_all(path: &Path) -> Result<(), InstallError> {
    fs::create_dir_all(path).map_err(|source| InstallError::Io {
        action: "create directory",
        path: path.display().to_string(),
        source,
    })
}

fn write_file(path: &Path, contents: &str) -> Result<(), InstallError> {
    fs::write(path, contents).map_err(|source| InstallError::Io {
        action: "write",
        path: path.display().to_string(),
        source,
    })
}

fn write_bytes(path: &Path, contents: &[u8]) -> Result<(), InstallError> {
    fs::write(path, contents).map_err(|source| InstallError::Io {
        action: "write",
        path: path.display().to_string(),
        source,
    })
}

fn mark_executable(path: &Path) -> Result<(), InstallError> {
    #[cfg(unix)]
    {
        use std::os::unix::fs::PermissionsExt;

        let mut permissions = fs::metadata(path)
            .map_err(|source| InstallError::Io {
                action: "read metadata for",
                path: path.display().to_string(),
                source,
            })?
            .permissions();
        permissions.set_mode(0o755);
        fs::set_permissions(path, permissions).map_err(|source| InstallError::Io {
            action: "set executable permissions on",
            path: path.display().to_string(),
            source,
        })?;
    }

    Ok(())
}

fn refresh_desktop_databases(data_home: &Path) {
    let _ = Command::new("update-desktop-database")
        .arg(data_home.join("applications"))
        .status();
    let _ = Command::new("gtk-update-icon-cache")
        .arg("-q")
        .arg(data_home.join("icons/hicolor"))
        .status();
}

fn desktop_entry_contents(exe: &Path, icon: &Path) -> String {
    format!(
        "[Desktop Entry]\n\
Type=Application\n\
Name=Caery\n\
Comment=Convert audio and video with ffmpeg\n\
Exec={}\n\
Icon={}\n\
Terminal=false\n\
Categories=AudioVideo;AudioVideoEditing;Utility;\n\
StartupNotify=true\n\
StartupWMClass=caery\n",
        desktop_exec_escape(exe),
        desktop_icon_path(icon)
    )
}

fn desktop_exec_escape(path: &Path) -> String {
    let escaped = path
        .display()
        .to_string()
        .replace('\\', "\\\\")
        .replace('"', "\\\"")
        .replace('$', "\\$")
        .replace('`', "\\`");
    format!("\"{escaped}\"")
}

fn desktop_icon_path(path: &Path) -> String {
    path.display().to_string()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn desktop_entry_points_to_escaped_executable() {
        let entry = desktop_entry_contents(
            Path::new("/tmp/Caery App/caery"),
            Path::new("/tmp/Caery App/caery.png"),
        );

        assert!(entry.contains("Name=Caery"));
        assert!(entry.contains("Exec=\"/tmp/Caery App/caery\""));
        assert!(entry.contains("Icon=/tmp/Caery App/caery.png"));
    }

    #[test]
    fn embedded_icon_is_png() {
        const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n";

        assert!(CAERY_ICON_PNG.starts_with(PNG_SIGNATURE));
    }
}