Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 1608582cbd | |||
| 34145d5fbe | |||
| e94eb2050c | |||
| 254af3f0b2 | |||
| 884f871d7f | |||
| 43b30a54b3 | |||
| a81bacce10 | |||
| 26d8bab34d | |||
| 01f09b26ff | |||
| 0daacebca5 |
2
Cargo.lock
generated
2
Cargo.lock
generated
@@ -859,7 +859,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
|
||||
|
||||
[[package]]
|
||||
name = "owlry"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"dirs",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "owlry"
|
||||
version = "0.1.0"
|
||||
version = "0.1.4"
|
||||
edition = "2024"
|
||||
rust-version = "1.90"
|
||||
description = "A lightweight, owl-themed application launcher for Wayland"
|
||||
|
||||
31
README.md
31
README.md
@@ -1,5 +1,6 @@
|
||||
# Owlry
|
||||
|
||||
[](https://aur.archlinux.org/packages/owlry)
|
||||
[](https://www.rust-lang.org/)
|
||||
[](LICENSE)
|
||||
[](https://gtk.org/)
|
||||
@@ -19,7 +20,19 @@ A lightweight, owl-themed application launcher for Wayland, built with GTK4 and
|
||||
|
||||
## Installation
|
||||
|
||||
### Dependencies
|
||||
### Arch Linux (AUR)
|
||||
|
||||
```bash
|
||||
# Using yay
|
||||
yay -S owlry
|
||||
|
||||
# Using paru
|
||||
paru -S owlry
|
||||
```
|
||||
|
||||
### Build from source
|
||||
|
||||
#### Dependencies
|
||||
|
||||
```bash
|
||||
# Arch Linux
|
||||
@@ -32,7 +45,7 @@ sudo apt install libgtk-4-dev libgtk4-layer-shell-dev
|
||||
sudo dnf install gtk4-devel gtk4-layer-shell-devel
|
||||
```
|
||||
|
||||
### Build from source
|
||||
#### Build
|
||||
|
||||
Requires Rust 1.90 or later.
|
||||
|
||||
@@ -92,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
|
||||
@@ -118,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)
|
||||
|
||||
15
justfile
15
justfile
@@ -49,6 +49,10 @@ show-version:
|
||||
bump new_version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if [ "{{version}}" = "{{new_version}}" ]; then
|
||||
echo "Version is already {{new_version}}, skipping bump"
|
||||
exit 0
|
||||
fi
|
||||
echo "Bumping version from {{version}} to {{new_version}}"
|
||||
sed -i 's/^version = ".*"/version = "{{new_version}}"/' Cargo.toml
|
||||
cargo check
|
||||
@@ -60,6 +64,10 @@ bump new_version:
|
||||
tag:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
if git rev-parse "v{{version}}" >/dev/null 2>&1; then
|
||||
echo "Tag v{{version}} already exists, skipping"
|
||||
exit 0
|
||||
fi
|
||||
echo "Creating tag v{{version}}"
|
||||
git tag -a "v{{version}}" -m "Release v{{version}}"
|
||||
git push origin "v{{version}}"
|
||||
@@ -71,13 +79,16 @@ aur-update:
|
||||
set -euo pipefail
|
||||
cd "{{aur_dir}}"
|
||||
|
||||
url="https://somegit.dev/Owlibou/owlry"
|
||||
|
||||
echo "Updating PKGBUILD to version {{version}}"
|
||||
sed -i 's/^pkgver=.*/pkgver={{version}}/' PKGBUILD
|
||||
sed -i 's/^pkgrel=.*/pkgrel=1/' PKGBUILD
|
||||
|
||||
# Update checksums
|
||||
# Update checksums (b2sums)
|
||||
echo "Updating checksums..."
|
||||
updpkgsums
|
||||
b2sum=$(curl -sL "$url/archive/v{{version}}.tar.gz" | b2sum | cut -d' ' -f1)
|
||||
sed -i "s/^b2sums=.*/b2sums=('$b2sum')/" PKGBUILD
|
||||
|
||||
# Generate .SRCINFO
|
||||
echo "Generating .SRCINFO..."
|
||||
|
||||
@@ -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,
|
||||
@@ -182,6 +214,7 @@ impl Config {
|
||||
Ok(config)
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn save(&self) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let path = Self::config_path().ok_or("Could not determine config path")?;
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ impl ProviderFilter {
|
||||
}
|
||||
|
||||
/// Default filter: apps only
|
||||
#[allow(dead_code)]
|
||||
pub fn apps_only() -> Self {
|
||||
Self {
|
||||
enabled: HashSet::from([ProviderType::Application]),
|
||||
@@ -115,6 +116,7 @@ impl ProviderFilter {
|
||||
}
|
||||
|
||||
/// Get current active prefix if any
|
||||
#[allow(dead_code)]
|
||||
pub fn active_prefix(&self) -> Option<ProviderType> {
|
||||
self.active_prefix
|
||||
}
|
||||
|
||||
14
src/main.rs
14
src/main.rs
@@ -8,7 +8,7 @@ mod ui;
|
||||
|
||||
use app::OwlryApp;
|
||||
use cli::CliArgs;
|
||||
use log::info;
|
||||
use log::{info, warn};
|
||||
|
||||
fn main() {
|
||||
env_logger::Builder::from_env(env_logger::Env::default().default_filter_or("info")).init();
|
||||
@@ -17,6 +17,18 @@ fn main() {
|
||||
|
||||
info!("Starting Owlry launcher");
|
||||
|
||||
// Diagnostic: log critical environment variables
|
||||
let home = std::env::var("HOME").unwrap_or_else(|_| "<not set>".to_string());
|
||||
let path = std::env::var("PATH").unwrap_or_else(|_| "<not set>".to_string());
|
||||
let xdg_data = std::env::var("XDG_DATA_HOME").unwrap_or_else(|_| "<not set>".to_string());
|
||||
info!("HOME={}", home);
|
||||
info!("PATH={}", path);
|
||||
info!("XDG_DATA_HOME={}", xdg_data);
|
||||
|
||||
if home == "<not set>" || path == "<not set>" {
|
||||
warn!("Critical environment variables missing! Items may not load correctly.");
|
||||
}
|
||||
|
||||
let app = OwlryApp::new(args);
|
||||
std::process::exit(app.run());
|
||||
}
|
||||
|
||||
@@ -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'"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,17 +17,37 @@ impl DmenuProvider {
|
||||
}
|
||||
|
||||
/// Check if stdin has data (non-blocking check)
|
||||
/// Returns true only if stdin is a pipe or regular file with data available.
|
||||
/// Returns false for TTYs, /dev/null, and other character devices.
|
||||
pub fn has_stdin_data() -> bool {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
let stdin_fd = io::stdin().as_raw_fd();
|
||||
|
||||
// First check if stdin is a pipe or regular file (valid dmenu input sources)
|
||||
// Character devices (TTY, /dev/null) should NOT trigger dmenu mode
|
||||
let mut stat_buf: libc::stat = unsafe { std::mem::zeroed() };
|
||||
let stat_result = unsafe { libc::fstat(stdin_fd, &mut stat_buf) };
|
||||
if stat_result != 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
let mode = stat_buf.st_mode;
|
||||
let is_pipe = (mode & libc::S_IFMT) == libc::S_IFIFO;
|
||||
let is_file = (mode & libc::S_IFMT) == libc::S_IFREG;
|
||||
|
||||
// Only check for data if stdin is a pipe or file
|
||||
if !is_pipe && !is_file {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Non-blocking poll to check if data is available
|
||||
let mut poll_fd = libc::pollfd {
|
||||
fd: stdin_fd,
|
||||
events: libc::POLLIN,
|
||||
revents: 0,
|
||||
};
|
||||
|
||||
// Non-blocking poll with 0 timeout
|
||||
let result = unsafe { libc::poll(&mut poll_fd, 1, 0) };
|
||||
result > 0 && (poll_fd.revents & libc::POLLIN) != 0
|
||||
}
|
||||
@@ -37,6 +57,7 @@ impl DmenuProvider {
|
||||
self.enabled = true;
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
|
||||
@@ -10,10 +10,12 @@ pub use uuctl::UuctlProvider;
|
||||
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
use log::info;
|
||||
|
||||
/// Represents a single searchable/launchable item
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchItem {
|
||||
#[allow(dead_code)]
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
@@ -58,6 +60,7 @@ impl std::fmt::Display for ProviderType {
|
||||
|
||||
/// Trait for all search providers
|
||||
pub trait Provider: Send {
|
||||
#[allow(dead_code)]
|
||||
fn name(&self) -> &str;
|
||||
fn provider_type(&self) -> ProviderType;
|
||||
fn refresh(&mut self);
|
||||
@@ -98,6 +101,7 @@ impl ProviderManager {
|
||||
manager
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn is_dmenu_mode(&self) -> bool {
|
||||
self.providers
|
||||
.iter()
|
||||
@@ -107,9 +111,15 @@ impl ProviderManager {
|
||||
pub fn refresh_all(&mut self) {
|
||||
for provider in &mut self.providers {
|
||||
provider.refresh();
|
||||
info!(
|
||||
"Provider '{}' loaded {} items",
|
||||
provider.name(),
|
||||
provider.items().len()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> {
|
||||
if query.is_empty() {
|
||||
// Return recent/popular items when query is empty
|
||||
@@ -197,6 +207,7 @@ impl ProviderManager {
|
||||
}
|
||||
|
||||
/// Get all available provider types (for UI tabs)
|
||||
#[allow(dead_code)]
|
||||
pub fn available_providers(&self) -> Vec<ProviderType> {
|
||||
self.providers.iter().map(|p| p.provider_type()).collect()
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ pub struct UuctlProvider {
|
||||
}
|
||||
|
||||
/// Represents the state of a systemd service
|
||||
#[allow(dead_code)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceState {
|
||||
pub unit_name: String,
|
||||
|
||||
@@ -505,8 +505,8 @@ impl MainWindow {
|
||||
let results_list = self.results_list.clone();
|
||||
let scrolled = self.scrolled.clone();
|
||||
let search_entry = self.search_entry.clone();
|
||||
let current_results = self.current_results.clone();
|
||||
let config = self.config.clone();
|
||||
let _current_results = self.current_results.clone();
|
||||
let _config = self.config.clone();
|
||||
let filter = self.filter.clone();
|
||||
let filter_buttons = self.filter_buttons.clone();
|
||||
let mode_label = self.mode_label.clone();
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@ use crate::providers::LaunchItem;
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{Box as GtkBox, Image, Label, ListBoxRow, Orientation};
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub struct ResultRow {
|
||||
row: ListBoxRow,
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user