diff --git a/CHANGELOG.md b/CHANGELOG.md index f72754f..590b21f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,8 +13,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - GNOME: registers a custom keybinding via `gsettings` - KDE: appends a `[tmuxido]` section to `~/.config/kglobalshortcutsrc` - Conflict detection per DE (Hyprland via `hyprctl binds -j`, KDE via config file, GNOME via gsettings); suggests next free combo from a fallback list +- `--create-desktop-shortcut` flag to (re-)install the `.desktop` entry and icon at any time - `shortcut` module (`src/shortcut.rs`) with full unit and integration test coverage -- Icon and `.desktop` file installed by `install.sh` for launcher and window-rule integration +- Icon and `.desktop` file installed by `install.sh` and offered in the first-run wizard ## [0.7.1] - 2026-03-01 diff --git a/README.md b/README.md index af5c597..26a020a 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,12 @@ Set up a desktop keyboard shortcut (Hyprland, GNOME, KDE): tmuxido --setup-shortcut ``` -This is also offered automatically on first run. Re-run it any time to reconfigure the shortcut or after switching desktop environments. +Install the `.desktop` entry and icon (so tmuxido appears in app launchers like Walker/Rofi): +```bash +tmuxido --create-desktop-shortcut +``` + +Both are also offered automatically on first run. Re-run them any time to reconfigure. View help: ```bash diff --git a/src/config.rs b/src/config.rs index 559ae7c..00427b9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -130,6 +130,11 @@ impl Config { if let Err(e) = crate::shortcut::setup_shortcut_wizard() { eprintln!("Warning: shortcut setup failed: {}", e); } + + // Offer to install .desktop entry + icon (best-effort, non-fatal) + if let Err(e) = crate::shortcut::setup_desktop_integration_wizard() { + eprintln!("Warning: desktop integration failed: {}", e); + } } Ok(config_path) diff --git a/src/lib.rs b/src/lib.rs index b5ef4b7..52a78d9 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,10 @@ pub fn setup_shortcut_wizard() -> Result<()> { shortcut::setup_shortcut_wizard() } +pub fn setup_desktop_integration_wizard() -> Result<()> { + shortcut::setup_desktop_integration_wizard() +} + pub fn show_cache_status(config: &Config) -> Result<()> { if !config.cache_enabled { println!("Cache is disabled in configuration"); diff --git a/src/main.rs b/src/main.rs index c9c472e..d97c354 100644 --- a/src/main.rs +++ b/src/main.rs @@ -7,7 +7,10 @@ use tmuxido::config::Config; use tmuxido::deps::ensure_dependencies; use tmuxido::self_update; use tmuxido::update_check; -use tmuxido::{get_projects, launch_tmux_session, setup_shortcut_wizard, show_cache_status}; +use tmuxido::{ + get_projects, launch_tmux_session, setup_desktop_integration_wizard, setup_shortcut_wizard, + show_cache_status, +}; #[derive(Parser, Debug)] #[command( @@ -34,6 +37,10 @@ struct Args { /// Set up a keyboard shortcut to launch tmuxido #[arg(long)] setup_shortcut: bool, + + /// Install the .desktop entry and icon for app launcher integration + #[arg(long)] + create_desktop_shortcut: bool, } fn main() -> Result<()> { @@ -49,6 +56,11 @@ fn main() -> Result<()> { return setup_shortcut_wizard(); } + // Handle standalone desktop integration setup + if args.create_desktop_shortcut { + return setup_desktop_integration_wizard(); + } + // Check that fzf and tmux are installed; offer to install if missing ensure_dependencies()?; diff --git a/src/shortcut.rs b/src/shortcut.rs index 2cb08f3..434ee5a 100644 --- a/src/shortcut.rs +++ b/src/shortcut.rs @@ -594,6 +594,122 @@ pub fn setup_shortcut_wizard() -> Result<()> { Ok(()) } +// ============================================================================ +// Desktop integration (.desktop file + icon) +// ============================================================================ + +const ICON_URL: &str = "https://raw.githubusercontent.com/cinco/tmuxido/refs/heads/main/docs/assets/tmuxido-icon_96.png"; + +const DESKTOP_CONTENT: &str = "[Desktop Entry] +Name=Tmuxido +Comment=Quickly find and open projects in tmux +Exec=tmuxido +Icon=tmuxido +Type=Application +Categories=Development;Utility; +Terminal=true +Keywords=tmux;project;fzf;dev; +StartupWMClass=tmuxido +"; + +/// Path where the .desktop entry will be installed +pub fn desktop_file_path() -> Result { + let data_dir = dirs::data_dir().context("Could not determine data directory")?; + Ok(data_dir.join("applications").join("tmuxido.desktop")) +} + +/// Path where the 96×96 icon will be installed +pub fn icon_install_path() -> Result { + let data_dir = dirs::data_dir().context("Could not determine data directory")?; + Ok(data_dir + .join("icons") + .join("hicolor") + .join("96x96") + .join("apps") + .join("tmuxido.png")) +} + +/// Result of a desktop integration install +pub struct DesktopInstallResult { + pub desktop_path: PathBuf, + pub icon_path: PathBuf, + pub icon_downloaded: bool, +} + +/// Write the .desktop file and download the icon to the given paths. +/// Icon download is best-effort — does not fail if curl or network is unavailable. +pub fn install_desktop_integration_to( + desktop_path: &Path, + icon_path: &Path, +) -> Result { + // Write .desktop + if let Some(parent) = desktop_path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("Failed to create {}", parent.display()))?; + } + std::fs::write(desktop_path, DESKTOP_CONTENT) + .with_context(|| format!("Failed to write {}", desktop_path.display()))?; + + // Download icon (best-effort via curl) + let icon_downloaded = (|| -> Option<()> { + if let Some(parent) = icon_path.parent() { + std::fs::create_dir_all(parent).ok()?; + } + std::process::Command::new("curl") + .args(["-fsSL", ICON_URL, "-o", &icon_path.to_string_lossy()]) + .status() + .ok()? + .success() + .then_some(()) + })() + .is_some(); + + // Refresh desktop database (best-effort) + if let Some(apps_dir) = desktop_path.parent() { + let _ = std::process::Command::new("update-desktop-database") + .arg(apps_dir) + .status(); + } + + // Refresh icon cache (best-effort) + if icon_downloaded { + // Navigate up from …/96x96/apps → …/icons/hicolor + let hicolor_dir = icon_path + .parent() + .and_then(|p| p.parent()) + .and_then(|p| p.parent()); + if let Some(dir) = hicolor_dir { + let _ = std::process::Command::new("gtk-update-icon-cache") + .args(["-f", "-t", &dir.to_string_lossy()]) + .status(); + } + } + + Ok(DesktopInstallResult { + desktop_path: desktop_path.to_path_buf(), + icon_path: icon_path.to_path_buf(), + icon_downloaded, + }) +} + +/// Install .desktop and icon to the standard XDG locations +pub fn install_desktop_integration() -> Result { + install_desktop_integration_to(&desktop_file_path()?, &icon_install_path()?) +} + +/// Interactive wizard that asks the user and then installs desktop integration +pub fn setup_desktop_integration_wizard() -> Result<()> { + crate::ui::render_section_header("Desktop Integration"); + + if !crate::ui::render_desktop_integration_prompt()? { + return Ok(()); + } + + let result = install_desktop_integration()?; + crate::ui::render_desktop_integration_success(&result); + Ok(()) +} + // ============================================================================ // Tests // ============================================================================ @@ -821,4 +937,48 @@ mod tests { let c = KeyCombo::parse("super+shift+t").unwrap(); assert_eq!(c.normalized(), "SUPER+SHIFT+T"); } + + // --- desktop integration --- + + #[test] + fn should_write_desktop_file_to_given_path() { + let dir = tempfile::tempdir().unwrap(); + let desktop = dir.path().join("apps").join("tmuxido.desktop"); + let icon = dir.path().join("icons").join("tmuxido.png"); + + let result = install_desktop_integration_to(&desktop, &icon).unwrap(); + + assert!(result.desktop_path.exists()); + let content = std::fs::read_to_string(&result.desktop_path).unwrap(); + assert!(content.contains("[Desktop Entry]")); + assert!(content.contains("Exec=tmuxido")); + assert!(content.contains("Icon=tmuxido")); + assert!(content.contains("Terminal=true")); + } + + #[test] + fn should_create_parent_directories_for_desktop_file() { + let dir = tempfile::tempdir().unwrap(); + let desktop = dir + .path() + .join("nested") + .join("apps") + .join("tmuxido.desktop"); + let icon = dir.path().join("icons").join("tmuxido.png"); + + install_desktop_integration_to(&desktop, &icon).unwrap(); + + assert!(desktop.exists()); + } + + #[test] + fn desktop_content_contains_required_fields() { + assert!(DESKTOP_CONTENT.contains("[Desktop Entry]")); + assert!(DESKTOP_CONTENT.contains("Name=Tmuxido")); + assert!(DESKTOP_CONTENT.contains("Exec=tmuxido")); + assert!(DESKTOP_CONTENT.contains("Icon=tmuxido")); + assert!(DESKTOP_CONTENT.contains("Type=Application")); + assert!(DESKTOP_CONTENT.contains("Terminal=true")); + assert!(DESKTOP_CONTENT.contains("StartupWMClass=tmuxido")); + } } diff --git a/src/ui.rs b/src/ui.rs index c097892..b8cb37c 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -551,6 +551,66 @@ pub fn render_shortcut_success(de: &str, combo: &str, details: &str, reload_hint println!(); } +/// Ask the user whether to install the .desktop entry and icon +pub fn render_desktop_integration_prompt() -> Result { + let prompt_style = Style::new().bold(true).foreground(color_green()); + let hint_style = Style::new().italic(true).foreground(color_dark_gray()); + + println!( + "{}", + hint_style.render( + " Install a .desktop entry so tmuxido appears in app launchers (Walker, Rofi, etc.)?" + ) + ); + println!( + "{}", + hint_style + .render(" Also downloads the 96×96 icon from GitHub (requires internet access).") + ); + print!( + " {} ", + prompt_style.render("❯ Install desktop entry? (Y/n):") + ); + io::stdout().flush().context("Failed to flush stdout")?; + + let mut input = String::new(); + io::stdin() + .read_line(&mut input) + .context("Failed to read input")?; + + let trimmed = input.trim().to_lowercase(); + Ok(trimmed != "n" && trimmed != "no") +} + +/// Render a success message after desktop integration is installed +pub fn render_desktop_integration_success(result: &crate::shortcut::DesktopInstallResult) { + let success_style = Style::new().bold(true).foreground(color_green()); + let label_style = Style::new().foreground(color_light_gray()); + let value_style = Style::new().bold(true).foreground(color_blue()); + let warn_style = Style::new().italic(true).foreground(color_orange()); + + println!(); + println!("{}", success_style.render(" 🖥️ Desktop entry installed!")); + println!( + " {} {}", + label_style.render(".desktop:"), + value_style.render(&result.desktop_path.display().to_string()) + ); + if result.icon_downloaded { + println!( + " {} {}", + label_style.render("icon:"), + value_style.render(&result.icon_path.display().to_string()) + ); + } else { + println!( + " {}", + warn_style.render("icon: download skipped (no network or curl unavailable)") + ); + } + println!(); +} + // ============================================================================ /// Parse comma-separated list into Vec, filtering empty items @@ -794,4 +854,32 @@ mod tests { "Reload Hyprland with Super+Shift+R to activate.", ); } + + #[test] + fn render_desktop_integration_success_should_not_panic() { + use crate::shortcut::DesktopInstallResult; + use std::path::PathBuf; + let result = DesktopInstallResult { + desktop_path: PathBuf::from("/home/user/.local/share/applications/tmuxido.desktop"), + icon_path: PathBuf::from( + "/home/user/.local/share/icons/hicolor/96x96/apps/tmuxido.png", + ), + icon_downloaded: true, + }; + render_desktop_integration_success(&result); + } + + #[test] + fn render_desktop_integration_success_without_icon_should_not_panic() { + use crate::shortcut::DesktopInstallResult; + use std::path::PathBuf; + let result = DesktopInstallResult { + desktop_path: PathBuf::from("/home/user/.local/share/applications/tmuxido.desktop"), + icon_path: PathBuf::from( + "/home/user/.local/share/icons/hicolor/96x96/apps/tmuxido.png", + ), + icon_downloaded: false, + }; + render_desktop_integration_success(&result); + } } diff --git a/tests/shortcut.rs b/tests/shortcut.rs index cfd0391..b9e3319 100644 --- a/tests/shortcut.rs +++ b/tests/shortcut.rs @@ -1,6 +1,9 @@ use std::fs; use tempfile::tempdir; -use tmuxido::shortcut::{KeyCombo, check_kde_conflict, write_hyprland_binding, write_kde_shortcut}; +use tmuxido::shortcut::{ + KeyCombo, check_kde_conflict, install_desktop_integration_to, write_hyprland_binding, + write_kde_shortcut, +}; #[test] fn writes_hyprland_binding_to_new_file() { @@ -126,3 +129,37 @@ fn check_kde_conflict_returns_none_when_file_missing() { let combo = KeyCombo::parse("Super+Shift+T").unwrap(); assert!(check_kde_conflict(std::path::Path::new("/nonexistent/path"), &combo).is_none()); } + +#[test] +fn installs_desktop_file_to_given_path() { + let dir = tempdir().unwrap(); + let desktop_path = dir.path().join("applications").join("tmuxido.desktop"); + let icon_path = dir + .path() + .join("icons") + .join("hicolor") + .join("96x96") + .join("apps") + .join("tmuxido.png"); + + let result = install_desktop_integration_to(&desktop_path, &icon_path).unwrap(); + + assert!(result.desktop_path.exists(), ".desktop file should exist"); + let content = fs::read_to_string(&result.desktop_path).unwrap(); + assert!(content.contains("[Desktop Entry]")); + assert!(content.contains("Exec=tmuxido")); + assert!(content.contains("Icon=tmuxido")); + assert!(content.contains("Terminal=true")); + assert!(content.contains("StartupWMClass=tmuxido")); +} + +#[test] +fn desktop_install_creates_parent_dirs() { + let dir = tempdir().unwrap(); + let desktop_path = dir.path().join("a").join("b").join("tmuxido.desktop"); + let icon_path = dir.path().join("icons").join("tmuxido.png"); + + install_desktop_integration_to(&desktop_path, &icon_path).unwrap(); + + assert!(desktop_path.exists()); +}