lios 0.1.38

A polished GTK4/VTE Linux terminal emulator with themes, clickable links, live preferences, glass backgrounds, cursor accents, and desktop launcher install.
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"));
    }
}