free-launch 0.2.8

A simple fuzzy launcher written in Rust.
Documentation
use freedesktop_desktop_entry::DesktopEntry;
use serde::ser::StdError;
use serde_yaml::to_string;
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::sync::LazyLock;
use std::sync::mpsc::Sender;
use tracing::{debug, error, info};

use crate::free_launch::free_launch::{self, LOCALES};
use crate::launch_entries::action_name::ActionName;
use crate::launch_entries::action_result::ActionResult;
use crate::launch_entries::launch_entry_trait::{LaunchAction, LaunchId, Launchable};
use crate::signaling::item_update::ItemUpdate;
use crate::signaling::request::Request;
use crate::util::reveal;

#[derive(Debug, Clone)]
pub struct DesktopItem {
    pub name: String,
    pub exec: String,
    pub icon: Option<String>,
    pub comment: Option<String>,
    pub selected: bool,
    pub desktop_file_path: PathBuf,
}

impl DesktopItem {
    // TODO take a DesktopEntry directly
    pub(crate) fn from_desktop_file(path: &Path) -> Option<Self> {
        let desktop_entry = DesktopEntry::from_path(path, Some(&LOCALES)).ok()?;

        // Check that this desktop entry is valid
        // More info here: https://specifications.freedesktop.org/desktop-entry-spec/latest/recognized-keys.html

        // Hidden: Hidden should have been called Deleted. It means the user deleted (at their level) something that was present (at an upper level, e.g. in the system dirs). It's strictly equivalent to the .desktop file not existing at all, as far as that user is concerned.
        if desktop_entry.hidden() {
            return None;
        }

        // NoDisplay: NoDisplay means "this application exists, but don't display it in the menus". This can be useful to e.g. associate this application with MIME types, so that it gets launched from a file manager (or other apps), without having a menu entry for it (there are tons of good reasons for this, including e.g. the netscape -remote, or kfmclient openURL kind of stuff).
        if desktop_entry.no_display() {
            return None;
        }

        // Exec: Program to execute, possibly with arguments.
        // might want to skip items where this is blank or "false"

        let name = desktop_entry.name(&LOCALES)?.to_string();
        let exec = desktop_entry.exec()?.to_string();
        // TODO use the IconCache exclusively
        let icon = desktop_entry.icon().map(|i| to_string(i).ok()).flatten();
        let comment = desktop_entry
            .comment(&free_launch::LOCALES)
            .map(|s| s.to_string());

        Some(DesktopItem {
            name,
            exec,
            icon,
            comment,
            selected: false,
            desktop_file_path: path.to_path_buf(),
        })
    }

    pub(crate) fn name(&self) -> &str {
        &self.name
    }

    // TODO consider logging via the tracing crate
    fn log_directory_open_error(&self, directory: &Path, error: &Box<dyn StdError>) {
        let log_path = "/var/log/free-launch.log";
        let timestamp = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        let log_entry = format!(
            "[{}] ERROR: Failed to open directory '{}' for item '{}': {}\n",
            timestamp,
            directory.display(),
            self.name,
            error
        );

        // Try to write to the log file, fall back to stderr if that fails
        match OpenOptions::new().create(true).append(true).open(log_path) {
            Ok(mut file) => {
                if let Err(write_err) = file.write_all(log_entry.as_bytes()) {
                    error!(
                        "Failed to write to log file {}: {}: {}",
                        log_path,
                        write_err,
                        log_entry.trim()
                    );
                }
            }
            Err(open_err) => {
                error!(
                    "Failed to open log file {}: {}: {}",
                    log_path,
                    open_err,
                    log_entry.trim()
                );
            }
        }
    }

    // TODO consider logging via the tracing crate
    fn log_launch_error(&self, program: &str, args: &[&str], error: &std::io::Error) {
        let log_path = "/var/log/free-launch.log";
        let timestamp = std::time::SystemTime::now()
            .duration_since(std::time::UNIX_EPOCH)
            .unwrap_or_default()
            .as_secs();

        let log_entry = format!(
            "[{}] ERROR: Failed to launch '{}' with args {:?} from item '{}': {}\n",
            timestamp, program, args, self.name, error
        );

        // Try to write to the log file, fall back to stderr if that fails
        match OpenOptions::new().create(true).append(true).open(log_path) {
            Ok(mut file) => {
                if let Err(write_err) = file.write_all(log_entry.as_bytes()) {
                    error!(
                        "Failed to write to log file {}: {}: {}",
                        log_path,
                        write_err,
                        log_entry.trim()
                    );
                }
            }
            Err(open_err) => {
                error!(
                    "Failed to open log file {}: {}: {}",
                    log_path,
                    open_err,
                    log_entry.trim()
                );
            }
        }
    }

    fn exec_name(&self) -> Option<&str> {
        self.exec
            .split_whitespace()
            .next()
            .and_then(|e| e.rsplit('/').next())
    }

    fn path_name(&self) -> Option<&str> {
        self.desktop_file_path
            .iter()
            .last()
            .and_then(|f| f.to_str())
    }

    fn launch(&self) {
        // check for field codes: https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html

        // NOTE: A real implementation would need to handle desktop file field codes.
        // For a simple case, we will just remove any common field codes.
        // TODO properly handle field codes
        let sanitized = self
            .exec
            .replace("%u", "")
            .replace("%U", "")
            .replace("%f", "")
            .replace("%F", "")
            .trim()
            .to_string();

        // Split the command string into the program and its arguments.
        // This simple splitting won't work if there are quoted spaces or escaped characters.
        // Consider using a shell-like parser (for example, the [`shell-words`](https://crates.io/crates/shell-words) crate) if needed.
        info!("Launching item: {}", sanitized);
        // TODO use proper shell splitting
        let mut parts = sanitized.split_whitespace();
        if let Some(program) = parts.next() {
            let args: Vec<&str> = parts.collect();

            // Launch the process.
            // You might want to use .spawn() as above to run it concurrently, or .output() if you need to wait for it to finish.
            match Command::new(program).args(&args).spawn() {
                Ok(_) => {
                    // Command launched successfully
                }
                Err(e) => {
                    // Log the error to the standard NixOS log path
                    self.log_launch_error(program, &args, &e);
                }
            }
            // TODO ensure that we wait for the command or properly detach it if we are running in daemon mode
        };
    }

    fn open_directory(&self) {
        // Try to open the directory with the default file manager
        let path_buf = &self.desktop_file_path;
        info!("Revealing item: {:?}", path_buf);
        if let Err(e) = reveal(path_buf) {
            self.log_directory_open_error(path_buf, &e);
        }
    }
}

impl LaunchId for DesktopItem {
    /// Returns an ID for correlating Invocations and DesktopItems that combines "desktop", name, and the pathname of the desktop file
    fn id(&self) -> String {
        // Extract first part of exec using simple whitespace splitting
        let exec_name = self.path_name().unwrap_or("NONE");

        format!("desktop-{}-{}", self.name, exec_name)
    }

    fn launchable(&self) -> bool {
        self.desktop_file_path.exists()
    }

    fn file_path(&self) -> &Path {
        &self.desktop_file_path
    }

    fn icon_name(&self) -> Option<&str> {
        self.icon.as_deref()
    }

    fn comment(&self) -> Option<&str> {
        self.comment.as_deref()
    }

    fn debug_ui(&self, ui: &mut egui::Ui, count: usize) {
        ui.label(format!("  [{}] ID: {}", count, self.id()));
        ui.label(format!("      Name: {}", self.name));
        ui.label(format!("      Exec: {}", self.exec));
        ui.label(format!("      Path: {}", self.file_path().display()));
        if let Some(comment) = self.comment() {
            ui.label(format!("      Comment: {}", comment));
        }
        ui.label(format!("      Icon: {:?}", self.icon_name()));
    }
}

impl Launchable for DesktopItem {}

static DISPATCH_TABLE: LazyLock<HashMap<&'static str, fn(&DesktopItem)>> = LazyLock::new(|| {
    let mut m: HashMap<&'static str, fn(&DesktopItem)> = HashMap::new();
    m.insert("launch", DesktopItem::launch);
    m.insert("reveal", DesktopItem::open_directory);
    m
});

impl LaunchAction for DesktopItem {
    fn actions(&self) -> Vec<&'static str> {
        DISPATCH_TABLE.keys().copied().collect()
    }

    fn default_action(&self) -> &'static str {
        "launch"
    }

    fn secondary_action(&self) -> &'static str {
        "reveal"
    }

    fn invoke(&self, action_name: &ActionName) -> ActionResult {
        // Unwrap action name or substitute default action
        let action = match action_name {
            ActionName::Default => self.default_action(),
            ActionName::Action(name) => name,
        };
        if let Some(func) = DISPATCH_TABLE.get(action) {
            func(self);
            ActionResult::Success
        } else {
            let error_message = format!(
                "Unknown action '{}' for DesktopItem '{}'",
                action, self.name
            );
            error!(error_message);
            ActionResult::Error(error_message)
        }
    }

    fn invoke_default(&self) -> ActionResult {
        self.invoke(&ActionName::Default)
    }
}

pub(crate) fn load_desktop_files(request_sender: Sender<Box<Request>>) {
    let desktop_entries =
        freedesktop_desktop_entry::desktop_entries(&free_launch::LOCALES.map(|l| l.to_owned()));

    let total_desktop_entries = desktop_entries.len();

    info!("Loading {} desktop entries", total_desktop_entries);
    for entry in desktop_entries {
        // indexed_entries += 1;
        debug!("Loading desktop entry: {}", entry);
        if let Some(desktop_item) = DesktopItem::from_desktop_file(&entry.path) {
            request_sender
                .send(Box::new(Request::UpdateIndexItem {
                    item: ItemUpdate::DesktopItem(desktop_item),
                }))
                .expect("should be able to send item update");
        }
    }

    // close the sender so we can terminate the icon loader thread
    drop(request_sender);
}