From a9495389e8a7fcfd743f71518a54b223e87cc17f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C4=81nis?= Date: Sat, 16 May 2026 22:12:16 +0300 Subject: [PATCH] Add --install-desktop to install a desktop entry Detects an installed terminal emulator (kitty, alacritty, wezterm, foot, ghostty, gnome-terminal, konsole, xfce4-terminal, tilix, x-terminal-emulator, xterm) and bakes an explicit launch command into the entry, since Terminal=true is unreliable on modern desktops. Co-Authored-By: Claude Opus 4.7 (1M context) --- assets/emby-client.desktop | 9 +++ src/config.rs | 4 ++ src/install.rs | 117 +++++++++++++++++++++++++++++++++++++ src/main.rs | 8 +++ 4 files changed, 138 insertions(+) create mode 100644 assets/emby-client.desktop create mode 100644 src/install.rs diff --git a/assets/emby-client.desktop b/assets/emby-client.desktop new file mode 100644 index 0000000..b5e0193 --- /dev/null +++ b/assets/emby-client.desktop @@ -0,0 +1,9 @@ +[Desktop Entry] +Type=Application +Name=Emby Client +GenericName=Emby TUI Client +Comment=TUI Emby client with libmpv playback +Exec={EXEC} +Terminal={TERMINAL} +Categories=AudioVideo;Player; +Keywords=emby;media;player;tui; diff --git a/src/config.rs b/src/config.rs index 2096660..34863ba 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,10 @@ pub struct CliArgs { /// Path to config file. Defaults: ./config.toml then $XDG_CONFIG_HOME/emby-client/config.toml #[arg(short, long)] pub config: Option, + + /// Install a desktop entry for the current executable and exit + #[arg(long)] + pub install_desktop: bool, } #[derive(Debug, Clone, Default, Serialize, Deserialize)] diff --git a/src/install.rs b/src/install.rs new file mode 100644 index 0000000..e0dca6b --- /dev/null +++ b/src/install.rs @@ -0,0 +1,117 @@ +use anyhow::{Context, Result}; +use directories::BaseDirs; +use std::env; +use std::fs; +use std::path::PathBuf; + +const DESKTOP_TEMPLATE: &str = include_str!("../assets/emby-client.desktop"); + +/// A terminal emulator we know how to invoke. `args` are the tokens placed +/// between the terminal binary and the command to run. +struct TerminalSpec { + bin: &'static str, + args: &'static [&'static str], +} + +const TERMINALS: &[TerminalSpec] = &[ + TerminalSpec { bin: "kitty", args: &[] }, + TerminalSpec { bin: "alacritty", args: &["-e"] }, + TerminalSpec { bin: "wezterm", args: &["start", "--"] }, + TerminalSpec { bin: "foot", args: &[] }, + TerminalSpec { bin: "ghostty", args: &["-e"] }, + TerminalSpec { bin: "gnome-terminal", args: &["--"] }, + TerminalSpec { bin: "konsole", args: &["-e"] }, + TerminalSpec { bin: "xfce4-terminal", args: &["-x"] }, + TerminalSpec { bin: "tilix", args: &["-e"] }, + TerminalSpec { bin: "x-terminal-emulator", args: &["-e"] }, + TerminalSpec { bin: "xterm", args: &["-e"] }, +]; + +fn which(bin: &str) -> Option { + let path = env::var_os("PATH")?; + for dir in env::split_paths(&path) { + let candidate = dir.join(bin); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +fn find_terminal() -> Option<(PathBuf, &'static [&'static str])> { + // Honor $TERMINAL if it matches a spec we know. + if let Ok(t) = env::var("TERMINAL") + && let Some(spec) = TERMINALS.iter().find(|s| s.bin == t) + && let Some(p) = which(spec.bin) + { + return Some((p, spec.args)); + } + for spec in TERMINALS { + if let Some(p) = which(spec.bin) { + return Some((p, spec.args)); + } + } + None +} + +/// Quote a token for a `.desktop` Exec line per the freedesktop spec. +fn quote_desktop(s: &str) -> String { + let needs_quote = s.is_empty() + || s.chars().any(|c| { + c.is_whitespace() + || matches!( + c, + '"' | '\\' | '$' | '`' | '\'' | '|' | '&' | ';' | '<' | '>' + | '(' | ')' | '#' | '~' | '*' | '?' + ) + }); + if !needs_quote { + return s.to_string(); + } + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + if matches!(c, '"' | '\\' | '$' | '`') { + out.push('\\'); + } + out.push(c); + } + out.push('"'); + out +} + +/// Write a `.desktop` entry for the current executable to +/// `$XDG_DATA_HOME/applications/emby-client.desktop` (typically +/// `~/.local/share/applications/`). Returns the path it was written to. +pub fn install_desktop_entry() -> Result { + let exe = env::current_exe().context("failed to locate current executable")?; + let exe = exe.canonicalize().unwrap_or(exe); + let exe_str = exe.to_string_lossy().into_owned(); + + let (exec_line, terminal_flag) = match find_terminal() { + Some((term, args)) => { + let mut parts: Vec = Vec::with_capacity(2 + args.len()); + parts.push(quote_desktop(&term.to_string_lossy())); + for a in args { + parts.push(quote_desktop(a)); + } + parts.push(quote_desktop(&exe_str)); + (parts.join(" "), "false") + } + None => (quote_desktop(&exe_str), "true"), + }; + + let content = DESKTOP_TEMPLATE + .replace("{EXEC}", &exec_line) + .replace("{TERMINAL}", terminal_flag); + + let dirs = BaseDirs::new().context("could not determine user data directory")?; + let dir = dirs.data_dir().join("applications"); + fs::create_dir_all(&dir) + .with_context(|| format!("failed to create {}", dir.display()))?; + + let target = dir.join("emby-client.desktop"); + fs::write(&target, content) + .with_context(|| format!("failed to write {}", target.display()))?; + Ok(target) +} diff --git a/src/main.rs b/src/main.rs index dbd06e8..16385a4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,7 @@ mod app; mod config; mod emby; +mod install; mod login; mod player; mod ui; @@ -11,6 +12,13 @@ use clap::Parser; #[tokio::main] async fn main() -> Result<()> { let args = config::CliArgs::parse(); + + if args.install_desktop { + let path = install::install_desktop_entry()?; + println!("Installed desktop entry: {}", path.display()); + return Ok(()); + } + let (cfg, save_target) = config::load(&args)?; let cfg = match cfg {