fix(providers): improve app discovery and launch reliability

- Add Keywords field from desktop files to searchable tags
  (fixes apps like Nautilus not found when searching by legacy name)
- Respect XDG_DATA_DIRS with proper fallbacks for app directories
- Add Flatpak, Snap, and Nix application directory support
- Simplify desktop file launch to use gio directly (guaranteed by GTK4)
- Add desktop notifications for launch failures
- Check desktop file existence before launch attempt
This commit is contained in:
2026-01-02 16:18:00 +01:00
parent 3ef9398655
commit 6d8d4a9f89
3 changed files with 180 additions and 38 deletions

View File

@@ -99,23 +99,57 @@ pub fn frecency_file() -> Option<PathBuf> {
// =============================================================================
/// System data directories for applications (XDG_DATA_DIRS)
///
/// Follows the XDG Base Directory Specification:
/// - $XDG_DATA_HOME/applications (defaults to ~/.local/share/applications)
/// - $XDG_DATA_DIRS/*/applications (defaults to /usr/local/share:/usr/share)
/// - Additional Flatpak and Snap directories
pub fn system_data_dirs() -> Vec<PathBuf> {
let mut dirs = Vec::new();
let mut seen = std::collections::HashSet::new();
// User data directory first
// Helper to add unique directories
let mut add_dir = |path: PathBuf| {
if seen.insert(path.clone()) {
dirs.push(path);
}
};
// 1. User data directory first (highest priority)
if let Some(data) = data_home() {
dirs.push(data.join("applications"));
add_dir(data.join("applications"));
}
// System directories
dirs.push(PathBuf::from("/usr/share/applications"));
dirs.push(PathBuf::from("/usr/local/share/applications"));
// 2. XDG_DATA_DIRS - parse the environment variable
// Default per spec: /usr/local/share:/usr/share
let xdg_data_dirs = std::env::var("XDG_DATA_DIRS")
.unwrap_or_else(|_| "/usr/local/share:/usr/share".to_string());
// Flatpak directories
if let Some(data) = data_home() {
dirs.push(data.join("flatpak/exports/share/applications"));
for dir in xdg_data_dirs.split(':') {
if !dir.is_empty() {
add_dir(PathBuf::from(dir).join("applications"));
}
}
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
// 3. Always include standard system directories as fallback
// Some environments set XDG_DATA_DIRS without including these
add_dir(PathBuf::from("/usr/share/applications"));
add_dir(PathBuf::from("/usr/local/share/applications"));
// 4. Flatpak directories (user and system)
if let Some(data) = data_home() {
add_dir(data.join("flatpak/exports/share/applications"));
}
add_dir(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
// 5. Snap directories
add_dir(PathBuf::from("/var/lib/snapd/desktop/applications"));
// 6. Nix directories (common on NixOS)
if let Some(home) = dirs::home_dir() {
add_dir(home.join(".nix-profile/share/applications"));
}
add_dir(PathBuf::from("/run/current-system/sw/share/applications"));
dirs
}

View File

@@ -98,6 +98,15 @@ impl Provider for ApplicationProvider {
// Empty locale list for default locale
let locales: &[&str] = &[];
// Get current desktop environment(s) for OnlyShowIn/NotShowIn filtering
// XDG_CURRENT_DESKTOP can be colon-separated (e.g., "ubuntu:GNOME")
let current_desktops: Vec<String> = std::env::var("XDG_CURRENT_DESKTOP")
.unwrap_or_default()
.split(':')
.filter(|s| !s.is_empty())
.map(|s| s.to_string())
.collect();
for path in Iter::new(dirs.into_iter()) {
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
@@ -125,6 +134,24 @@ impl Provider for ApplicationProvider {
continue;
}
// Apply OnlyShowIn/NotShowIn filters only if we know the current desktop
// If XDG_CURRENT_DESKTOP is not set, show all apps (don't filter)
if !current_desktops.is_empty() {
// OnlyShowIn: if set, current desktop must be in the list
if desktop_entry.only_show_in().is_some_and(|only| {
!current_desktops.iter().any(|de| only.contains(&de.as_str()))
}) {
continue;
}
// NotShowIn: if current desktop is in the list, skip
if desktop_entry.not_show_in().is_some_and(|not| {
current_desktops.iter().any(|de| not.contains(&de.as_str()))
}) {
continue;
}
}
let name = match desktop_entry.name(locales) {
Some(n) => n.to_string(),
None => continue,
@@ -135,12 +162,17 @@ impl Provider for ApplicationProvider {
None => continue,
};
// Extract categories as tags (lowercase for consistency)
let tags: Vec<String> = desktop_entry
// Extract categories and keywords as tags (lowercase for consistency)
let mut tags: Vec<String> = desktop_entry
.categories()
.map(|cats| cats.into_iter().map(|s| s.to_lowercase()).collect())
.unwrap_or_default();
// Add keywords for searchability (e.g., Nautilus has Name=Files but Keywords contains "nautilus")
if let Some(keywords) = desktop_entry.keywords(locales) {
tags.extend(keywords.into_iter().map(|s| s.to_lowercase()));
}
let item = LaunchItem {
id: path.to_string_lossy().to_string(),
name,
@@ -157,6 +189,13 @@ impl Provider for ApplicationProvider {
debug!("Found {} applications", self.items.len());
#[cfg(feature = "dev-logging")]
debug!(
"XDG_CURRENT_DESKTOP={:?}, scanned dirs count={}",
current_desktops,
Self::get_application_dirs().len()
);
// Sort alphabetically by name
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
}
@@ -210,4 +249,18 @@ mod tests {
"bash -c 'echo %u'"
);
}
#[test]
fn test_clean_desktop_exec_preserves_env() {
// env VAR=value pattern should be preserved
assert_eq!(
clean_desktop_exec_field("env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity %F"),
"env GDK_BACKEND=x11 UBUNTU_MENUPROXY=0 audacity"
);
// Multiple env vars
assert_eq!(
clean_desktop_exec_field("env FOO=bar BAZ=qux myapp %u"),
"env FOO=bar BAZ=qux myapp"
);
}
}

View File

@@ -1286,17 +1286,81 @@ impl MainWindow {
info!("Launching: {} ({})", item.name, item.command);
#[cfg(feature = "dev-logging")]
debug!("[UI] Launch details: terminal={}, provider={:?}", item.terminal, item.provider);
debug!("[UI] Launch details: terminal={}, provider={:?}, id={}", item.terminal, item.provider, item.id);
let cmd = if item.terminal {
let terminal = config.general.terminal_command.as_deref().unwrap_or("xterm");
format!("{} -e {}", terminal, item.command)
// Check if this is a desktop application (has .desktop file as ID)
let is_desktop_app = matches!(item.provider, ProviderType::Application)
&& item.id.ends_with(".desktop");
// Desktop files should be launched via proper launchers that implement the
// freedesktop Desktop Entry spec (D-Bus activation, field codes, env vars, etc.)
// We delegate to: uwsm (if configured), gio launch, or gtk-launch as fallback.
//
// Non-desktop items (commands, plugins) use sh -c for shell execution.
let result = if is_desktop_app {
Self::launch_desktop_file(&item.id, config)
} else {
item.command.clone()
Self::launch_command(&item.command, item.terminal, config)
};
// Detect if this is a shell command vs an application launch
// Shell commands: playerctl, dbus-send, systemctl, journalctl, or anything with shell operators
if let Err(e) = result {
let msg = format!("Failed to launch '{}': {}", item.name, e);
log::error!("{}", msg);
crate::notify::notify("Launch failed", &msg);
}
}
/// Launch a .desktop file using gio (GLib's desktop entry launcher).
///
/// gio is always available as it's part of glib2, which is a hard dependency
/// of GTK4. It handles D-Bus activation, field codes, Terminal flag, etc.
/// per the freedesktop Desktop Entry specification.
///
/// Optionally wraps with a session manager (uwsm, hyprctl) for proper
/// process tracking in Wayland compositors.
fn launch_desktop_file(desktop_path: &str, config: &Config) -> std::io::Result<std::process::Child> {
use std::path::Path;
// Check if desktop file exists
if !Path::new(desktop_path).exists() {
let msg = format!("Desktop file not found: {}", desktop_path);
log::error!("{}", msg);
crate::notify::notify("Launch failed", &msg);
return Err(std::io::Error::new(std::io::ErrorKind::NotFound, msg));
}
match &config.general.launch_wrapper {
// With wrapper: wrapper manages the process, gio handles the .desktop file
Some(wrapper) if !wrapper.is_empty() => {
info!("Launching via {} + gio launch: {}", wrapper, desktop_path);
let mut parts: Vec<&str> = wrapper.split_whitespace().collect();
let cmd = parts.remove(0);
Command::new(cmd)
.args(&parts)
.args(["gio", "launch"])
.arg(desktop_path)
.spawn()
}
// No wrapper: use gio directly
_ => {
info!("Launching via gio launch: {}", desktop_path);
Command::new("gio")
.args(["launch", desktop_path])
.spawn()
}
}
}
/// Launch a shell command (for non-desktop items like PATH commands, plugins, etc.)
fn launch_command(command: &str, terminal: bool, config: &Config) -> std::io::Result<std::process::Child> {
let cmd = if terminal {
let terminal_cmd = config.general.terminal_command.as_deref().unwrap_or("xterm");
format!("{} -e {}", terminal_cmd, command)
} else {
command.to_string()
};
// Detect shell commands that shouldn't use a wrapper
let is_shell_command = cmd.starts_with("playerctl ")
|| cmd.starts_with("dbus-send ")
|| cmd.starts_with("systemctl ")
@@ -1307,28 +1371,19 @@ impl MainWindow {
|| cmd.contains(" > ")
|| cmd.contains(" < ");
// Use launch wrapper if configured (uwsm, hyprctl, etc.)
// But skip wrapper for shell commands - they need sh -c
let result = match &config.general.launch_wrapper {
match &config.general.launch_wrapper {
Some(wrapper) if !wrapper.is_empty() && !is_shell_command => {
info!("Using launch wrapper: {}", wrapper);
// Split wrapper into command and args (e.g., "uwsm app --" -> ["uwsm", "app", "--"])
let mut wrapper_parts: Vec<&str> = wrapper.split_whitespace().collect();
if wrapper_parts.is_empty() {
Command::new("sh").arg("-c").arg(&cmd).spawn()
} else {
let wrapper_cmd = wrapper_parts.remove(0);
Command::new(wrapper_cmd)
.args(&wrapper_parts)
.arg(&cmd)
.spawn()
}
info!("Launching command via {}: {}", wrapper, cmd);
let mut parts: Vec<&str> = wrapper.split_whitespace().collect();
let wrapper_cmd = parts.remove(0);
Command::new(wrapper_cmd)
.args(&parts)
.args(["sh", "-c", &cmd])
.spawn()
}
_ => {
Command::new("sh").arg("-c").arg(&cmd).spawn()
}
_ => Command::new("sh").arg("-c").arg(&cmd).spawn(),
};
if let Err(e) = result {
log::error!("Failed to launch '{}': {}", item.name, e);
}
}
}