use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};
const DESKTOP_FILE_NAME: &str = "dev.lios.Terminal.desktop";
const ICON_NAME: &str = "dev.lios.Terminal";
const ICON_FILE_NAME: &str = "dev.lios.Terminal.png";
const ICON_BYTES: &[u8] = include_bytes!("../assets/logo.png");
const HICOLOR_INDEX_THEME: &str = "[Icon Theme]\nName=Hicolor\nComment=Fallback icon theme\nDirectories=256x256/apps\n\n[256x256/apps]\nSize=256\nContext=Applications\nType=Fixed\n";
pub struct UserIcon {
pub file: PathBuf,
pub changed: bool,
}
pub fn install_desktop_entry() -> Result<String, String> {
let executable = env::current_exe()
.map_err(|error| format!("failed to determine current executable: {error}"))?
.canonicalize()
.map_err(|error| format!("failed to resolve current executable path: {error}"))?;
let applications_dir = applications_dir()?;
let desktop_file = applications_dir.join(DESKTOP_FILE_NAME);
let icon_file = icon_file_path()?;
fs::create_dir_all(&applications_dir).map_err(|error| {
format!(
"failed to create applications directory '{}': {error}",
applications_dir.display()
)
})?;
write_user_icon(&icon_file)?;
fs::write(&desktop_file, desktop_entry(&executable)).map_err(|error| {
format!(
"failed to write desktop launcher '{}': {error}",
desktop_file.display()
)
})?;
refresh_desktop_environment(&applications_dir, &icon_file);
Ok(format!(
"installed desktop launcher\n file: {}\n icon: {}\n exec: {}\n refresh: requested when desktop cache tools are available\n",
desktop_file.display(),
icon_file.display(),
executable.display()
))
}
pub fn uninstall_desktop_entry() -> Result<String, String> {
let applications_dir = applications_dir()?;
let desktop_file = applications_dir.join(DESKTOP_FILE_NAME);
let icon_file = icon_file_path()?;
if !desktop_file.exists() && !icon_file.exists() {
return Ok(format!(
"desktop launcher is not installed at {}\n",
desktop_file.display()
));
}
if desktop_file.exists() {
fs::remove_file(&desktop_file).map_err(|error| {
format!(
"failed to remove desktop launcher '{}': {error}",
desktop_file.display()
)
})?;
}
if icon_file.exists() {
fs::remove_file(&icon_file).map_err(|error| {
format!(
"failed to remove desktop icon '{}': {error}",
icon_file.display()
)
})?;
}
refresh_desktop_environment(&applications_dir, &icon_file);
Ok(format!(
"removed desktop launcher\n file: {}\n icon: {}\n refresh: requested when desktop cache tools are available\n",
desktop_file.display(),
icon_file.display()
))
}
fn desktop_entry(executable: &Path) -> String {
format!(
"[Desktop Entry]\nType=Application\nName=Lios\nGenericName=Terminal\nComment=GTK/VTE terminal emulator\nExec={}\nTryExec={}\nIcon={ICON_NAME}\nTerminal=false\nCategories=System;TerminalEmulator;Utility;\nKeywords=shell;prompt;command;terminal;\nStartupNotify=true\nStartupWMClass=dev.lios.Terminal\n",
desktop_exec_path(executable),
desktop_exec_path(executable),
)
}
pub fn icon_name() -> &'static str {
ICON_NAME
}
pub fn ensure_user_icon() -> Result<UserIcon, String> {
let icon_file = icon_file_path()?;
let changed = write_user_icon(&icon_file)?;
Ok(UserIcon {
file: icon_file,
changed,
})
}
pub fn icon_search_path() -> Result<PathBuf, String> {
Ok(data_home()?.join("icons"))
}
pub fn refresh_user_icon_cache(icon_file: &Path) {
refresh_icon_cache(icon_file);
}
fn applications_dir() -> Result<PathBuf, String> {
if let Ok(data_home) = env::var("XDG_DATA_HOME") {
if !data_home.is_empty() {
return Ok(PathBuf::from(data_home).join("applications"));
}
}
let home = env::var("HOME").map_err(|_| {
"unable to determine applications directory; set HOME or XDG_DATA_HOME".to_string()
})?;
if home.is_empty() {
return Err("unable to determine applications directory; HOME is empty".to_string());
}
Ok(PathBuf::from(home).join(".local/share/applications"))
}
fn icon_file_path() -> Result<PathBuf, String> {
Ok(data_home()?
.join("icons/hicolor/256x256/apps")
.join(ICON_FILE_NAME))
}
fn data_home() -> Result<PathBuf, String> {
if let Ok(data_home) = env::var("XDG_DATA_HOME") {
if !data_home.is_empty() {
return Ok(PathBuf::from(data_home));
}
}
let home = env::var("HOME")
.map_err(|_| "unable to determine data directory; set HOME or XDG_DATA_HOME".to_string())?;
if home.is_empty() {
return Err("unable to determine data directory; HOME is empty".to_string());
}
Ok(PathBuf::from(home).join(".local/share"))
}
fn desktop_exec_path(path: &Path) -> String {
let text = path.to_string_lossy();
if text
.chars()
.all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '+'))
{
escape_percent(&text)
} else {
let escaped = text.chars().fold(String::new(), |mut output, ch| {
match ch {
'%' => output.push_str("%%"),
'\\' | '"' | '$' | '`' => {
output.push('\\');
output.push(ch);
}
_ => output.push(ch),
}
output
});
format!("\"{escaped}\"")
}
}
fn escape_percent(text: &str) -> String {
text.replace('%', "%%")
}
fn write_user_icon(icon_file: &Path) -> Result<bool, String> {
if let Some(icon_dir) = icon_file.parent() {
fs::create_dir_all(icon_dir).map_err(|error| {
format!(
"failed to create icon directory '{}': {error}",
icon_dir.display()
)
})?;
}
let mut changed = ensure_hicolor_index(icon_file)?;
if !matches!(fs::read(icon_file), Ok(contents) if contents.as_slice() == ICON_BYTES) {
fs::write(icon_file, ICON_BYTES).map_err(|error| {
format!(
"failed to write desktop icon '{}': {error}",
icon_file.display()
)
})?;
changed = true;
}
Ok(changed)
}
fn ensure_hicolor_index(icon_file: &Path) -> Result<bool, String> {
let Some(icon_theme_dir) = hicolor_theme_dir(icon_file) else {
return Ok(false);
};
let index_file = icon_theme_dir.join("index.theme");
if index_file.exists() {
return Ok(false);
}
fs::write(&index_file, HICOLOR_INDEX_THEME).map_err(|error| {
format!(
"failed to write icon theme index '{}': {error}",
index_file.display()
)
})?;
Ok(true)
}
fn hicolor_theme_dir(icon_file: &Path) -> Option<&Path> {
icon_file.parent()?.parent()?.parent()
}
fn refresh_desktop_environment(applications_dir: &Path, icon_file: &Path) {
let mut desktop_database = Command::new("update-desktop-database");
desktop_database.arg(applications_dir);
run_refresh_command(desktop_database);
refresh_icon_cache(icon_file);
}
fn refresh_icon_cache(icon_file: &Path) {
if let Some(icon_theme_dir) = hicolor_theme_dir(icon_file) {
let mut icon_cache = Command::new("gtk-update-icon-cache");
icon_cache.args(["-q", "-t", "-f"]).arg(icon_theme_dir);
run_refresh_command(icon_cache);
}
}
fn run_refresh_command(mut command: Command) {
let _ = command.stdout(Stdio::null()).stderr(Stdio::null()).status();
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desktop_exec_path_leaves_simple_paths_unquoted() {
assert_eq!(
desktop_exec_path(Path::new("/usr/bin/lios")),
"/usr/bin/lios"
);
}
#[test]
fn desktop_exec_path_quotes_paths_with_spaces() {
assert_eq!(
desktop_exec_path(Path::new("/tmp/Lios Build/lios")),
"\"/tmp/Lios Build/lios\""
);
}
#[test]
fn desktop_exec_path_escapes_field_codes() {
assert_eq!(
desktop_exec_path(Path::new("/tmp/%/lios")),
"\"/tmp/%%/lios\""
);
}
#[test]
fn desktop_entry_uses_lios_icon_name() {
assert!(desktop_entry(Path::new("/usr/bin/lios")).contains("Icon=dev.lios.Terminal\n"));
}
}