From 254af3f0b2ae0cd207c9f8a446b8e17a49fe5394 Mon Sep 17 00:00:00 2001 From: vikingowl Date: Sun, 28 Dec 2025 15:35:29 +0100 Subject: [PATCH] feat: add uwsm/hyprland launch wrapper and fix CLI args MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add launch_wrapper config option with auto-detection for uwsm and hyprland sessions, ensuring apps launch with proper session management - Fix CLI argument parsing by preventing GTK from intercepting clap-parsed args (--mode, --providers) - Improve desktop file Exec field parsing to properly handle quoted arguments and FreeDesktop field codes (%u, %F, etc.) - Add unit tests for Exec field parsing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- README.md | 14 +++++ src/app.rs | 4 +- src/config/mod.rs | 32 ++++++++++ src/providers/application.rs | 117 ++++++++++++++++++++++++++++++++--- src/ui/main_window.rs | 21 ++++++- 5 files changed, 179 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index dc5dd4b..83f62b8 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ Configuration file: `~/.config/owlry/config.toml` show_icons = true max_results = 10 # terminal_command = "kitty" # Auto-detected if not set +# launch_wrapper = "uwsm app --" # Auto-detected for uwsm/hyprland sessions [appearance] width = 600 @@ -131,12 +132,25 @@ uuctl = true | `show_icons` | `true` | | `max_results` | `10` | | `terminal_command` | Auto-detected ($TERMINAL → xdg-terminal-exec → kitty/alacritty/etc) | +| `launch_wrapper` | Auto-detected (uwsm → hyprctl → none) | | `width` | `600` | | `height` | `400` | | `font_size` | `14` | | `border_radius` | `12` | | `theme` | None (GTK default) | +### Launch Wrapper + +When running in uwsm-managed or Hyprland sessions, owlry auto-detects and uses the appropriate launch wrapper for proper session integration: + +| Session | Wrapper | Purpose | +|---------|---------|---------| +| uwsm | `uwsm app --` | Proper systemd scope and session management | +| Hyprland | `hyprctl dispatch exec --` | Native Hyprland window management | +| Other | None (direct `sh -c`) | Standard shell execution | + +You can override this with `launch_wrapper` in config, or set to empty string `""` to disable. + ## Theming ### GTK Theme (Default) diff --git a/src/app.rs b/src/app.rs index 862fae4..ddec144 100644 --- a/src/app.rs +++ b/src/app.rs @@ -30,7 +30,9 @@ impl OwlryApp { } pub fn run(&self) -> i32 { - self.app.run().into() + // Use empty args since clap already parsed our CLI arguments. + // This prevents GTK from trying to parse --mode, --providers, etc. + self.app.run_with_args(&[] as &[&str]).into() } fn on_activate(app: &Application, args: &CliArgs) { diff --git a/src/config/mod.rs b/src/config/mod.rs index 794cab2..7aca0a4 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -15,6 +15,11 @@ pub struct GeneralConfig { pub show_icons: bool, pub max_results: usize, pub terminal_command: String, + /// Launch wrapper command for app execution. + /// Examples: "uwsm app --", "hyprctl dispatch exec --", "systemd-run --user --" + /// If None or empty, launches directly via sh -c + #[serde(default)] + pub launch_wrapper: Option, } /// User-customizable theme colors @@ -56,6 +61,32 @@ pub struct ProvidersConfig { pub uuctl: bool, } +/// Detect the best launch wrapper for the current session +/// Checks for uwsm (Universal Wayland Session Manager) and hyprland +fn detect_launch_wrapper() -> Option { + // Check if running under uwsm (has UWSM_FINALIZE_VARNAMES or similar uwsm env vars) + if std::env::var("UWSM_FINALIZE_VARNAMES").is_ok() + || std::env::var("__UWSM_SELECT_TAG").is_ok() + { + if command_exists("uwsm") { + debug!("Detected uwsm session, using 'uwsm app --' wrapper"); + return Some("uwsm app --".to_string()); + } + } + + // Check if running under Hyprland + if std::env::var("HYPRLAND_INSTANCE_SIGNATURE").is_ok() { + if command_exists("hyprctl") { + debug!("Detected Hyprland session, using 'hyprctl dispatch exec --' wrapper"); + return Some("hyprctl dispatch exec --".to_string()); + } + } + + // No wrapper needed for other environments + debug!("No launch wrapper detected, using direct execution"); + None +} + /// Detect the best available terminal emulator /// Fallback chain: /// 1. $TERMINAL env var (user's explicit preference) @@ -127,6 +158,7 @@ impl Default for Config { show_icons: true, max_results: 10, terminal_command: terminal, + launch_wrapper: detect_launch_wrapper(), }, appearance: AppearanceConfig { width: 600, diff --git a/src/providers/application.rs b/src/providers/application.rs index 76fc4f7..48f9ac5 100644 --- a/src/providers/application.rs +++ b/src/providers/application.rs @@ -3,6 +3,69 @@ use freedesktop_desktop_entry::{DesktopEntry, Iter}; use log::{debug, warn}; use std::path::PathBuf; +/// Clean desktop file field codes from command string. +/// Removes %f, %F, %u, %U, %d, %D, %n, %N, %i, %c, %k, %v, %m field codes +/// while preserving quoted arguments and %% (literal percent). +/// See: https://specifications.freedesktop.org/desktop-entry-spec/latest/exec-variables.html +fn clean_desktop_exec_field(cmd: &str) -> String { + let mut result = String::with_capacity(cmd.len()); + let mut chars = cmd.chars().peekable(); + let mut in_single_quote = false; + let mut in_double_quote = false; + + while let Some(c) = chars.next() { + match c { + '\'' if !in_double_quote => { + in_single_quote = !in_single_quote; + result.push(c); + } + '"' if !in_single_quote => { + in_double_quote = !in_double_quote; + result.push(c); + } + '%' if !in_single_quote => { + // Check the next character for field code + if let Some(&next) = chars.peek() { + match next { + // Standard field codes to remove (with following space if present) + 'f' | 'F' | 'u' | 'U' | 'd' | 'D' | 'n' | 'N' | 'i' | 'c' | 'k' | 'v' + | 'm' => { + chars.next(); // consume the field code letter + // Skip trailing whitespace after the field code + while chars.peek() == Some(&' ') { + chars.next(); + } + } + // %% is escaped percent, output single % + '%' => { + chars.next(); + result.push('%'); + } + // Unknown % sequence, keep as-is + _ => { + result.push('%'); + } + } + } else { + // % at end of string, keep it + result.push('%'); + } + } + _ => { + result.push(c); + } + } + } + + // Clean up any double spaces that may have resulted from removing field codes + let mut cleaned = result.trim().to_string(); + while cleaned.contains(" ") { + cleaned = cleaned.replace(" ", " "); + } + + cleaned +} + pub struct ApplicationProvider { items: Vec, } @@ -85,13 +148,7 @@ impl Provider for ApplicationProvider { }; let run_cmd = match desktop_entry.exec() { - Some(e) => { - // Clean up run command (remove %u, %U, %f, %F, etc.) - e.split_whitespace() - .filter(|s| !s.starts_with('%')) - .collect::>() - .join(" ") - } + Some(e) => clean_desktop_exec_field(e), None => continue, }; @@ -118,3 +175,49 @@ impl Provider for ApplicationProvider { &self.items } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_clean_desktop_exec_simple() { + assert_eq!(clean_desktop_exec_field("firefox"), "firefox"); + assert_eq!(clean_desktop_exec_field("firefox %u"), "firefox"); + assert_eq!(clean_desktop_exec_field("code %F"), "code"); + } + + #[test] + fn test_clean_desktop_exec_multiple_placeholders() { + assert_eq!(clean_desktop_exec_field("app %f %u %U"), "app"); + assert_eq!(clean_desktop_exec_field("app --flag %u --other"), "app --flag --other"); + } + + #[test] + fn test_clean_desktop_exec_preserves_quotes() { + // Double quotes preserve spacing but field codes are still processed + assert_eq!( + clean_desktop_exec_field(r#"bash -c "echo hello""#), + r#"bash -c "echo hello""# + ); + // Field codes in double quotes are stripped (per FreeDesktop spec: undefined behavior, + // but practical implementations strip them) + assert_eq!( + clean_desktop_exec_field(r#"bash -c "test %u value""#), + r#"bash -c "test value""# + ); + } + + #[test] + fn test_clean_desktop_exec_escaped_percent() { + assert_eq!(clean_desktop_exec_field("echo 100%%"), "echo 100%"); + } + + #[test] + fn test_clean_desktop_exec_single_quotes() { + assert_eq!( + clean_desktop_exec_field("bash -c 'echo %u'"), + "bash -c 'echo %u'" + ); + } +} diff --git a/src/ui/main_window.rs b/src/ui/main_window.rs index 96af719..6cdc94c 100644 --- a/src/ui/main_window.rs +++ b/src/ui/main_window.rs @@ -777,7 +777,26 @@ impl MainWindow { item.command.clone() }; - if let Err(e) = Command::new("sh").arg("-c").arg(&cmd).spawn() { + // Use launch wrapper if configured (uwsm, hyprctl, etc.) + let result = match &config.general.launch_wrapper { + Some(wrapper) if !wrapper.is_empty() => { + 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() + } + } + _ => Command::new("sh").arg("-c").arg(&cmd).spawn(), + }; + + if let Err(e) = result { log::error!("Failed to launch '{}': {}", item.name, e); } }