lios-bar 0.1.9

A native GTK4/VTE sidebar workspace for navigating live Lios terminal sessions.
Documentation
use crate::config::atomic_write;
use std::env;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::{Command, Stdio};

const DESKTOP_FILE: &str = "dev.lios.Bar.desktop";

pub(crate) fn install() -> Result<String, String> {
    let executable = env::current_exe()
        .map_err(|error| format!("could not determine current executable: {error}"))?
        .canonicalize()
        .map_err(|error| format!("could not resolve current executable: {error}"))?;
    let applications = applications_dir()?;
    let path = applications.join(DESKTOP_FILE);
    let contents = desktop_entry(&executable);
    atomic_write(&path, contents.as_bytes(), 0o644)?;
    refresh(&applications);
    Ok(format!(
        "installed LiosBar desktop launcher at {}",
        path.display()
    ))
}

pub(crate) fn uninstall() -> Result<String, String> {
    let applications = applications_dir()?;
    let path = applications.join(DESKTOP_FILE);
    match fs::remove_file(&path) {
        Ok(()) => {
            refresh(&applications);
            Ok(format!(
                "removed LiosBar desktop launcher at {}",
                path.display()
            ))
        }
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(format!(
            "LiosBar desktop launcher is not installed at {}",
            path.display()
        )),
        Err(error) => Err(format!("could not remove '{}': {error}", path.display())),
    }
}

fn applications_dir() -> Result<PathBuf, String> {
    if let Some(root) = env::var_os("XDG_DATA_HOME").filter(|value| !value.is_empty()) {
        return Ok(PathBuf::from(root).join("applications"));
    }
    let home = env::var_os("HOME")
        .filter(|value| !value.is_empty())
        .ok_or_else(|| "set HOME or XDG_DATA_HOME to install the launcher".to_string())?;
    Ok(PathBuf::from(home).join(".local/share/applications"))
}

fn desktop_entry(executable: &Path) -> String {
    let executable = desktop_exec(executable);
    format!(
        "[Desktop Entry]\nType=Application\nName=LiosBar\nGenericName=Terminal Workspace\nComment=Navigate live terminal sessions from a sidebar\nExec={executable}\nTryExec={executable}\nIcon=utilities-terminal\nTerminal=false\nCategories=System;TerminalEmulator;Utility;\nKeywords=shell;terminal;session;sidebar;workspace;\nStartupNotify=true\nStartupWMClass=dev.lios.Bar\n"
    )
}

fn desktop_exec(path: &Path) -> String {
    let text = path.to_string_lossy();
    if text
        .chars()
        .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '/' | '.' | '_' | '-' | '+'))
    {
        return text.replace('%', "%%");
    }
    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 refresh(applications: &Path) {
    let _ = Command::new("update-desktop-database")
        .arg(applications)
        .stdin(Stdio::null())
        .stdout(Stdio::null())
        .stderr(Stdio::null())
        .status();
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn launcher_has_terminal_categories_and_safe_literal_exec() {
        let entry = desktop_entry(Path::new("/opt/Lios Bar/lios-bar%stable"));
        assert!(entry.contains("Name=LiosBar"));
        assert!(entry.contains("Terminal=false"));
        assert!(entry.contains("Exec=\"/opt/Lios Bar/lios-bar%%stable\""));
        assert!(!entry.contains("\nExec=/bin/sh"));
    }
}