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));
}
}