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