12 Commits

Author SHA1 Message Date
cc1ad7bbb7 chore: bump version to 0.1.5 2025-12-28 16:16:40 +01:00
16ba5b642a docs: add example config and CLAUDE.md
- config.example.toml with all available options documented
- CLAUDE.md with release workflow instructions

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 16:16:31 +01:00
1608582cbd fix: detect dmenu mode correctly using fstat
Previously, poll() on /dev/null returned "readable" (EOF),
causing dmenu mode to trigger when launched from keybinds.

Now uses fstat() to check if stdin is a pipe or regular file
before checking for data. Character devices (TTY, /dev/null)
no longer trigger dmenu mode.

Fixes items not showing when launched from window manager keybinds.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 16:07:59 +01:00
34145d5fbe feat: add startup diagnostics for environment issues
- Log HOME, PATH, XDG_DATA_HOME at startup
- Warn when critical env vars are missing
- Log item count per provider after refresh

This helps diagnose why items may not load when launched
from window manager keybinds vs terminal.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 15:52:08 +01:00
e94eb2050c chore: bump version to 0.1.2 2025-12-28 15:35:35 +01:00
254af3f0b2 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>
2025-12-28 15:35:29 +01:00
884f871d7f docs: add AUR installation instructions
- Add AUR badge to shields
- Add Arch Linux (AUR) as recommended install method
- Reorganize build-from-source section

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 15:05:30 +01:00
43b30a54b3 chore: bump version to 0.1.1 2025-12-28 15:00:57 +01:00
a81bacce10 chore: fix all compiler warnings
Add #[allow(dead_code)] to unused but potentially useful methods:
- config: save()
- filter: apps_only(), active_prefix()
- providers: name(), search(), is_dmenu_mode(), available_providers()
- dmenu: is_enabled()
- uuctl: ServiceState struct
- result_row: ResultRow struct

Prefix unused variables with underscore in main_window.rs

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-28 15:00:30 +01:00
26d8bab34d chore: update PKGBUILD to follow Rust guidelines, use b2sums 2025-12-28 14:49:39 +01:00
01f09b26ff fix: skip tag creation if tag already exists 2025-12-28 14:43:08 +01:00
0daacebca5 fix: handle same-version bump gracefully in justfile 2025-12-28 14:42:32 +01:00
16 changed files with 336 additions and 19 deletions

32
CLAUDE.md Normal file
View File

@@ -0,0 +1,32 @@
# Owlry - Claude Code Instructions
## Release Workflow
Always use `just` for releases and AUR deployment:
```bash
# Bump version (updates Cargo.toml + Cargo.lock, commits)
just bump 0.x.y
# Push and create tag
git push && just tag
# Update AUR package
just aur-update
# Review changes, then publish
just aur-publish
```
Do NOT manually edit Cargo.toml for version bumps - use `just bump`.
## Available just recipes
- `just build` / `just release` - Build debug/release
- `just check` - Run cargo check + clippy
- `just test` - Run tests
- `just bump <version>` - Bump version
- `just tag` - Create and push git tag
- `just aur-update` - Update PKGBUILD checksums
- `just aur-publish` - Commit and push to AUR
- `just aur-test` - Test PKGBUILD locally

2
Cargo.lock generated
View File

@@ -859,7 +859,7 @@ checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "owlry"
version = "0.1.0"
version = "0.1.5"
dependencies = [
"clap",
"dirs",

View File

@@ -1,6 +1,6 @@
[package]
name = "owlry"
version = "0.1.0"
version = "0.1.5"
edition = "2024"
rust-version = "1.90"
description = "A lightweight, owl-themed application launcher for Wayland"

View File

@@ -1,5 +1,6 @@
# Owlry
[![AUR](https://img.shields.io/aur/version/owlry?logo=archlinux&label=AUR)](https://aur.archlinux.org/packages/owlry)
[![Rust](https://img.shields.io/badge/rust-1.90%2B-orange.svg)](https://www.rust-lang.org/)
[![License](https://img.shields.io/badge/license-GPL--3.0-blue.svg)](LICENSE)
[![GTK4](https://img.shields.io/badge/GTK-4.12-green.svg)](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)

42
config.example.toml Normal file
View File

@@ -0,0 +1,42 @@
# Owlry Configuration
# Copy to ~/.config/owlry/config.toml
[general]
show_icons = true
max_results = 10
terminal_command = "kitty" # Auto-detected if not set
# Launch wrapper for app execution (auto-detected if not set)
# Examples:
# "uwsm app --" # For uwsm sessions
# "hyprctl dispatch exec --" # For Hyprland
# "" # Direct execution
# launch_wrapper = "uwsm app --"
[appearance]
width = 600
height = 400
font_size = 14
border_radius = 12
# Theme: "owl" for built-in dark theme, or leave unset for GTK default
# theme = "owl"
# Individual color overrides (CSS color values)
# [appearance.colors]
# background = "#1a1b26"
# background_secondary = "#24283b"
# border = "#414868"
# text = "#c0caf5"
# text_secondary = "#565f89"
# accent = "#7aa2f7"
# accent_bright = "#89b4fa"
# badge_app = "#9ece6a"
# badge_cmd = "#7aa2f7"
# badge_dmenu = "#bb9af7"
# badge_uuctl = "#f7768e"
[providers]
applications = true
commands = true
uuctl = true

View File

@@ -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..."

View File

@@ -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) {

View File

@@ -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")?;

View File

@@ -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
}

View File

@@ -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());
}

View File

@@ -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'"
);
}
}

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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,

View File

@@ -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);
}
}

View File

@@ -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,
}