From 6d8d4a9f89537a0bc90a20199f69a97402805e06 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Fri, 2 Jan 2026 16:18:00 +0100 Subject: [PATCH] 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 --- crates/owlry/src/paths.rs | 52 +++++++++-- crates/owlry/src/providers/application.rs | 57 ++++++++++- crates/owlry/src/ui/main_window.rs | 109 ++++++++++++++++------ 3 files changed, 180 insertions(+), 38 deletions(-) diff --git a/crates/owlry/src/paths.rs b/crates/owlry/src/paths.rs index 0965814..a846063 100644 --- a/crates/owlry/src/paths.rs +++ b/crates/owlry/src/paths.rs @@ -99,23 +99,57 @@ pub fn frecency_file() -> Option { // ============================================================================= /// 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 { 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 } diff --git a/crates/owlry/src/providers/application.rs b/crates/owlry/src/providers/application.rs index 9415120..3236e64 100644 --- a/crates/owlry/src/providers/application.rs +++ b/crates/owlry/src/providers/application.rs @@ -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 = 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 = desktop_entry + // Extract categories and keywords as tags (lowercase for consistency) + let mut tags: Vec = 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" + ); + } } diff --git a/crates/owlry/src/ui/main_window.rs b/crates/owlry/src/ui/main_window.rs index 9bad596..8903193 100644 --- a/crates/owlry/src/ui/main_window.rs +++ b/crates/owlry/src/ui/main_window.rs @@ -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 { + 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 { + 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); } } }