caery 0.1.1

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;

#[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/scalable/apps");
    let desktop_entry = applications_dir.join("caery.desktop");
    let icon = icon_dir.join("caery.svg");

    create_dir_all(&applications_dir)?;
    create_dir_all(&icon_dir)?;
    write_file(&icon, caery_icon_svg())?;

    let entry = desktop_entry_contents(&exe);
    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 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) -> String {
    format!(
        "[Desktop Entry]\n\
Type=Application\n\
Name=Caery\n\
Comment=Convert audio and video with ffmpeg\n\
Exec={}\n\
Icon=caery\n\
Terminal=false\n\
Categories=AudioVideo;AudioVideoEditing;Utility;\n\
StartupNotify=true\n\
StartupWMClass=caery\n",
        desktop_exec_escape(exe)
    )
}

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

fn caery_icon_svg() -> &'static str {
    r##"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256">
  <rect width="256" height="256" rx="28" fill="#000"/>
  <path d="M34 34h64M34 34v64M222 34h-64M222 34v64M34 222h64M34 222v-64M222 222h-64M222 222v-64" fill="none" stroke="#f4f4f4" stroke-width="8" stroke-linecap="square"/>
  <circle cx="128" cy="128" r="76" fill="none" stroke="#737373" stroke-width="5"/>
  <path d="M87 159c24 26 83 22 93-20 8-35-27-69-68-56-32 10-45 51-19 73" fill="none" stroke="#fff" stroke-width="13" stroke-linecap="round"/>
  <path d="M72 128h112M128 72v112" stroke="#00ff88" stroke-width="6" stroke-linecap="round" opacity=".7"/>
  <text x="128" y="145" text-anchor="middle" font-family="monospace" font-size="68" font-weight="700" fill="#fff">C</text>
</svg>
"##
}

#[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"));

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

    #[test]
    fn icon_svg_contains_caery_mark() {
        let svg = caery_icon_svg();

        assert!(svg.contains("<svg"));
        assert!(svg.contains(">C</text>"));
    }
}