feat: add desktop integration wizard and --create-desktop-shortcut

- install_desktop_integration_to() writes .desktop and downloads icon
- setup_desktop_integration_wizard() prompts and installs to XDG paths
- --create-desktop-shortcut flag to re-run at any time
- First-run wizard now also offers desktop integration after shortcut setup
- 164 tests passing
This commit is contained in:
Cinco Euzebio 2026-03-01 19:40:15 -03:00
parent 3aacf30697
commit d7246298b1
8 changed files with 316 additions and 4 deletions

View File

@ -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` - GNOME: registers a custom keybinding via `gsettings`
- KDE: appends a `[tmuxido]` section to `~/.config/kglobalshortcutsrc` - 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 - 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 - `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 ## [0.7.1] - 2026-03-01

View File

@ -112,7 +112,12 @@ Set up a desktop keyboard shortcut (Hyprland, GNOME, KDE):
tmuxido --setup-shortcut 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: View help:
```bash ```bash

View File

@ -130,6 +130,11 @@ impl Config {
if let Err(e) = crate::shortcut::setup_shortcut_wizard() { if let Err(e) = crate::shortcut::setup_shortcut_wizard() {
eprintln!("Warning: shortcut setup failed: {}", e); 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) Ok(config_path)

View File

@ -20,6 +20,10 @@ pub fn setup_shortcut_wizard() -> Result<()> {
shortcut::setup_shortcut_wizard() shortcut::setup_shortcut_wizard()
} }
pub fn setup_desktop_integration_wizard() -> Result<()> {
shortcut::setup_desktop_integration_wizard()
}
pub fn show_cache_status(config: &Config) -> Result<()> { pub fn show_cache_status(config: &Config) -> Result<()> {
if !config.cache_enabled { if !config.cache_enabled {
println!("Cache is disabled in configuration"); println!("Cache is disabled in configuration");

View File

@ -7,7 +7,10 @@ use tmuxido::config::Config;
use tmuxido::deps::ensure_dependencies; use tmuxido::deps::ensure_dependencies;
use tmuxido::self_update; use tmuxido::self_update;
use tmuxido::update_check; 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)] #[derive(Parser, Debug)]
#[command( #[command(
@ -34,6 +37,10 @@ struct Args {
/// Set up a keyboard shortcut to launch tmuxido /// Set up a keyboard shortcut to launch tmuxido
#[arg(long)] #[arg(long)]
setup_shortcut: bool, setup_shortcut: bool,
/// Install the .desktop entry and icon for app launcher integration
#[arg(long)]
create_desktop_shortcut: bool,
} }
fn main() -> Result<()> { fn main() -> Result<()> {
@ -49,6 +56,11 @@ fn main() -> Result<()> {
return setup_shortcut_wizard(); 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 // Check that fzf and tmux are installed; offer to install if missing
ensure_dependencies()?; ensure_dependencies()?;

View File

@ -594,6 +594,122 @@ pub fn setup_shortcut_wizard() -> Result<()> {
Ok(()) 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<PathBuf> {
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<PathBuf> {
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<DesktopInstallResult> {
// 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<DesktopInstallResult> {
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 // Tests
// ============================================================================ // ============================================================================
@ -821,4 +937,48 @@ mod tests {
let c = KeyCombo::parse("super+shift+t").unwrap(); let c = KeyCombo::parse("super+shift+t").unwrap();
assert_eq!(c.normalized(), "SUPER+SHIFT+T"); 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"));
}
} }

View File

@ -551,6 +551,66 @@ pub fn render_shortcut_success(de: &str, combo: &str, details: &str, reload_hint
println!(); println!();
} }
/// Ask the user whether to install the .desktop entry and icon
pub fn render_desktop_integration_prompt() -> Result<bool> {
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<String>, filtering empty items /// Parse comma-separated list into Vec<String>, filtering empty items
@ -794,4 +854,32 @@ mod tests {
"Reload Hyprland with Super+Shift+R to activate.", "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);
}
} }

View File

@ -1,6 +1,9 @@
use std::fs; use std::fs;
use tempfile::tempdir; 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] #[test]
fn writes_hyprland_binding_to_new_file() { 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(); let combo = KeyCombo::parse("Super+Shift+T").unwrap();
assert!(check_kde_conflict(std::path::Path::new("/nonexistent/path"), &combo).is_none()); 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());
}