feat: add uwsm/hyprland launch wrapper and fix CLI args
- 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 <noreply@anthropic.com>
This commit is contained in:
14
README.md
14
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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
/// 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<String> {
|
||||
// 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,
|
||||
|
||||
@@ -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<LaunchItem>,
|
||||
}
|
||||
@@ -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::<Vec<_>>()
|
||||
.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'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user