feat: initial owlry application launcher
Owl-themed Wayland application launcher with GTK4 and layer-shell. Features: - Provider-based architecture (apps, commands, systemd user services) - Filter tabs and prefix shortcuts (:app, :cmd, :uuctl) - Submenu actions for systemd services (start/stop/restart/status/journal) - Smart terminal detection with fallback chain - CLI options for mode selection (--mode, --providers) - Fuzzy search with configurable max results - Custom owl-inspired dark theme 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
120
src/providers/application.rs
Normal file
120
src/providers/application.rs
Normal file
@@ -0,0 +1,120 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use freedesktop_desktop_entry::{DesktopEntry, Iter};
|
||||
use log::{debug, warn};
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct ApplicationProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl ApplicationProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
fn get_application_dirs() -> Vec<PathBuf> {
|
||||
let mut dirs = Vec::new();
|
||||
|
||||
// User applications
|
||||
if let Some(data_home) = dirs::data_dir() {
|
||||
dirs.push(data_home.join("applications"));
|
||||
}
|
||||
|
||||
// System applications
|
||||
dirs.push(PathBuf::from("/usr/share/applications"));
|
||||
dirs.push(PathBuf::from("/usr/local/share/applications"));
|
||||
|
||||
// Flatpak applications
|
||||
if let Some(data_home) = dirs::data_dir() {
|
||||
dirs.push(data_home.join("flatpak/exports/share/applications"));
|
||||
}
|
||||
dirs.push(PathBuf::from("/var/lib/flatpak/exports/share/applications"));
|
||||
|
||||
dirs
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for ApplicationProvider {
|
||||
fn name(&self) -> &str {
|
||||
"Applications"
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Application
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
let dirs = Self::get_application_dirs();
|
||||
debug!("Scanning application directories: {:?}", dirs);
|
||||
|
||||
// Empty locale list for default locale
|
||||
let locales: &[&str] = &[];
|
||||
|
||||
for path in Iter::new(dirs.into_iter()) {
|
||||
let content = match std::fs::read_to_string(&path) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
warn!("Failed to read {:?}: {}", path, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let desktop_entry = match DesktopEntry::from_str(&path, &content, Some(locales)) {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
warn!("Failed to parse {:?}: {}", path, e);
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
// Skip entries marked as hidden or no-display
|
||||
if desktop_entry.no_display() || desktop_entry.hidden() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only include Application type entries
|
||||
if desktop_entry.type_() != Some("Application") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = match desktop_entry.name(locales) {
|
||||
Some(n) => n.to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
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(" ")
|
||||
}
|
||||
None => continue,
|
||||
};
|
||||
|
||||
let item = LaunchItem {
|
||||
id: path.to_string_lossy().to_string(),
|
||||
name,
|
||||
description: desktop_entry.comment(locales).map(|s| s.to_string()),
|
||||
icon: desktop_entry.icon().map(|s| s.to_string()),
|
||||
provider: ProviderType::Application,
|
||||
command: run_cmd,
|
||||
terminal: desktop_entry.terminal(),
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
}
|
||||
|
||||
debug!("Found {} applications", self.items.len());
|
||||
|
||||
// Sort alphabetically by name
|
||||
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
105
src/providers/command.rs
Normal file
105
src/providers/command.rs
Normal file
@@ -0,0 +1,105 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use log::debug;
|
||||
use std::collections::HashSet;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub struct CommandProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
impl CommandProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
fn get_path_dirs() -> Vec<PathBuf> {
|
||||
std::env::var("PATH")
|
||||
.unwrap_or_default()
|
||||
.split(':')
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.exists())
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn is_executable(path: &std::path::Path) -> bool {
|
||||
if let Ok(metadata) = path.metadata() {
|
||||
let permissions = metadata.permissions();
|
||||
permissions.mode() & 0o111 != 0
|
||||
} else {
|
||||
false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for CommandProvider {
|
||||
fn name(&self) -> &str {
|
||||
"Commands"
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Command
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
let dirs = Self::get_path_dirs();
|
||||
let mut seen_names: HashSet<String> = HashSet::new();
|
||||
|
||||
debug!("Scanning PATH directories for commands");
|
||||
|
||||
for dir in dirs {
|
||||
let entries = match std::fs::read_dir(&dir) {
|
||||
Ok(e) => e,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
for entry in entries.filter_map(Result::ok) {
|
||||
let path = entry.path();
|
||||
|
||||
// Skip directories and non-executable files
|
||||
if path.is_dir() || !Self::is_executable(&path) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let name = match path.file_name() {
|
||||
Some(n) => n.to_string_lossy().to_string(),
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Skip duplicates (first one in PATH wins)
|
||||
if seen_names.contains(&name) {
|
||||
continue;
|
||||
}
|
||||
seen_names.insert(name.clone());
|
||||
|
||||
// Skip hidden files
|
||||
if name.starts_with('.') {
|
||||
continue;
|
||||
}
|
||||
|
||||
let item = LaunchItem {
|
||||
id: path.to_string_lossy().to_string(),
|
||||
name: name.clone(),
|
||||
description: Some(format!("Run {}", path.display())),
|
||||
icon: Some("utilities-terminal".to_string()),
|
||||
provider: ProviderType::Command,
|
||||
command: name,
|
||||
terminal: false,
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
debug!("Found {} commands in PATH", self.items.len());
|
||||
|
||||
// Sort alphabetically
|
||||
self.items.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
94
src/providers/dmenu.rs
Normal file
94
src/providers/dmenu.rs
Normal file
@@ -0,0 +1,94 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use log::debug;
|
||||
use std::io::{self, BufRead};
|
||||
|
||||
/// Provider for dmenu-style input from stdin
|
||||
pub struct DmenuProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
enabled: bool,
|
||||
}
|
||||
|
||||
impl DmenuProvider {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
items: Vec::new(),
|
||||
enabled: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if stdin has data (non-blocking check)
|
||||
pub fn has_stdin_data() -> bool {
|
||||
use std::os::unix::io::AsRawFd;
|
||||
|
||||
let stdin_fd = io::stdin().as_raw_fd();
|
||||
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
|
||||
}
|
||||
|
||||
/// Enable dmenu mode (called when stdin has data)
|
||||
pub fn enable(&mut self) {
|
||||
self.enabled = true;
|
||||
}
|
||||
|
||||
pub fn is_enabled(&self) -> bool {
|
||||
self.enabled
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for DmenuProvider {
|
||||
fn name(&self) -> &str {
|
||||
"dmenu"
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Dmenu
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
if !self.enabled {
|
||||
return;
|
||||
}
|
||||
|
||||
debug!("Reading dmenu items from stdin");
|
||||
|
||||
let stdin = io::stdin();
|
||||
for (idx, line) in stdin.lock().lines().enumerate() {
|
||||
let line = match line {
|
||||
Ok(l) => l,
|
||||
Err(_) => continue,
|
||||
};
|
||||
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
let item = LaunchItem {
|
||||
id: format!("dmenu:{}", idx),
|
||||
name: line.to_string(),
|
||||
description: None,
|
||||
icon: None,
|
||||
provider: ProviderType::Dmenu,
|
||||
command: line.to_string(),
|
||||
terminal: false,
|
||||
};
|
||||
|
||||
self.items.push(item);
|
||||
}
|
||||
|
||||
debug!("Read {} items from stdin", self.items.len());
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
203
src/providers/mod.rs
Normal file
203
src/providers/mod.rs
Normal file
@@ -0,0 +1,203 @@
|
||||
mod application;
|
||||
mod command;
|
||||
mod dmenu;
|
||||
mod uuctl;
|
||||
|
||||
pub use application::ApplicationProvider;
|
||||
pub use command::CommandProvider;
|
||||
pub use dmenu::DmenuProvider;
|
||||
pub use uuctl::UuctlProvider;
|
||||
|
||||
use fuzzy_matcher::FuzzyMatcher;
|
||||
use fuzzy_matcher::skim::SkimMatcherV2;
|
||||
|
||||
/// Represents a single searchable/launchable item
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct LaunchItem {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub description: Option<String>,
|
||||
pub icon: Option<String>,
|
||||
pub provider: ProviderType,
|
||||
pub command: String,
|
||||
pub terminal: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum ProviderType {
|
||||
Application,
|
||||
Command,
|
||||
Dmenu,
|
||||
Uuctl,
|
||||
}
|
||||
|
||||
impl std::str::FromStr for ProviderType {
|
||||
type Err = String;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
match s.to_lowercase().as_str() {
|
||||
"app" | "apps" | "application" | "applications" => Ok(ProviderType::Application),
|
||||
"cmd" | "command" | "commands" => Ok(ProviderType::Command),
|
||||
"uuctl" => Ok(ProviderType::Uuctl),
|
||||
"dmenu" => Ok(ProviderType::Dmenu),
|
||||
_ => Err(format!("Unknown provider: '{}'. Valid: app, cmd, uuctl", s)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ProviderType {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
ProviderType::Application => write!(f, "app"),
|
||||
ProviderType::Command => write!(f, "cmd"),
|
||||
ProviderType::Dmenu => write!(f, "dmenu"),
|
||||
ProviderType::Uuctl => write!(f, "uuctl"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait for all search providers
|
||||
pub trait Provider: Send {
|
||||
fn name(&self) -> &str;
|
||||
fn provider_type(&self) -> ProviderType;
|
||||
fn refresh(&mut self);
|
||||
fn items(&self) -> &[LaunchItem];
|
||||
}
|
||||
|
||||
/// Manages all providers and handles searching
|
||||
pub struct ProviderManager {
|
||||
providers: Vec<Box<dyn Provider>>,
|
||||
matcher: SkimMatcherV2,
|
||||
}
|
||||
|
||||
impl ProviderManager {
|
||||
pub fn new() -> Self {
|
||||
let mut manager = Self {
|
||||
providers: Vec::new(),
|
||||
matcher: SkimMatcherV2::default(),
|
||||
};
|
||||
|
||||
// Check if running in dmenu mode (stdin has data)
|
||||
let dmenu_mode = DmenuProvider::has_stdin_data();
|
||||
|
||||
if dmenu_mode {
|
||||
// In dmenu mode, only use dmenu provider
|
||||
let mut dmenu = DmenuProvider::new();
|
||||
dmenu.enable();
|
||||
manager.providers.push(Box::new(dmenu));
|
||||
} else {
|
||||
// Normal mode: use all standard providers
|
||||
manager.providers.push(Box::new(ApplicationProvider::new()));
|
||||
manager.providers.push(Box::new(CommandProvider::new()));
|
||||
manager.providers.push(Box::new(UuctlProvider::new()));
|
||||
}
|
||||
|
||||
// Initial refresh
|
||||
manager.refresh_all();
|
||||
|
||||
manager
|
||||
}
|
||||
|
||||
pub fn is_dmenu_mode(&self) -> bool {
|
||||
self.providers
|
||||
.iter()
|
||||
.any(|p| p.provider_type() == ProviderType::Dmenu)
|
||||
}
|
||||
|
||||
pub fn refresh_all(&mut self) {
|
||||
for provider in &mut self.providers {
|
||||
provider.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn search(&self, query: &str, max_results: usize) -> Vec<(LaunchItem, i64)> {
|
||||
if query.is_empty() {
|
||||
// Return recent/popular items when query is empty
|
||||
return self.providers
|
||||
.iter()
|
||||
.flat_map(|p| p.items().iter().cloned())
|
||||
.take(max_results)
|
||||
.map(|item| (item, 0))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = self.providers
|
||||
.iter()
|
||||
.flat_map(|provider| {
|
||||
provider.items().iter().filter_map(|item| {
|
||||
// Match against name and description
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
let score = match (name_score, desc_score) {
|
||||
(Some(n), Some(d)) => Some(n.max(d)),
|
||||
(Some(n), None) => Some(n),
|
||||
(None, Some(d)) => Some(d / 2), // Lower weight for description matches
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
score.map(|s| (item.clone(), s))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Sort by score (descending)
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
|
||||
/// Search with provider filtering
|
||||
pub fn search_filtered(
|
||||
&self,
|
||||
query: &str,
|
||||
max_results: usize,
|
||||
filter: &crate::filter::ProviderFilter,
|
||||
) -> Vec<(LaunchItem, i64)> {
|
||||
if query.is_empty() {
|
||||
return self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|p| filter.is_active(p.provider_type()))
|
||||
.flat_map(|p| p.items().iter().cloned())
|
||||
.take(max_results)
|
||||
.map(|item| (item, 0))
|
||||
.collect();
|
||||
}
|
||||
|
||||
let mut results: Vec<(LaunchItem, i64)> = self
|
||||
.providers
|
||||
.iter()
|
||||
.filter(|provider| filter.is_active(provider.provider_type()))
|
||||
.flat_map(|provider| {
|
||||
provider.items().iter().filter_map(|item| {
|
||||
let name_score = self.matcher.fuzzy_match(&item.name, query);
|
||||
let desc_score = item
|
||||
.description
|
||||
.as_ref()
|
||||
.and_then(|d| self.matcher.fuzzy_match(d, query));
|
||||
|
||||
let score = match (name_score, desc_score) {
|
||||
(Some(n), Some(d)) => Some(n.max(d)),
|
||||
(Some(n), None) => Some(n),
|
||||
(None, Some(d)) => Some(d / 2),
|
||||
(None, None) => None,
|
||||
};
|
||||
|
||||
score.map(|s| (item.clone(), s))
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
results.sort_by(|a, b| b.1.cmp(&a.1));
|
||||
results.truncate(max_results);
|
||||
results
|
||||
}
|
||||
|
||||
/// Get all available provider types (for UI tabs)
|
||||
pub fn available_providers(&self) -> Vec<ProviderType> {
|
||||
self.providers.iter().map(|p| p.provider_type()).collect()
|
||||
}
|
||||
}
|
||||
265
src/providers/uuctl.rs
Normal file
265
src/providers/uuctl.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
use super::{LaunchItem, Provider, ProviderType};
|
||||
use log::{debug, warn};
|
||||
use std::process::Command;
|
||||
|
||||
/// Provider for systemd user services
|
||||
/// Uses systemctl --user to list and control user-level services
|
||||
pub struct UuctlProvider {
|
||||
items: Vec<LaunchItem>,
|
||||
}
|
||||
|
||||
/// Represents the state of a systemd service
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ServiceState {
|
||||
pub unit_name: String,
|
||||
pub display_name: String,
|
||||
pub description: String,
|
||||
pub active: bool,
|
||||
pub sub_state: String,
|
||||
}
|
||||
|
||||
impl UuctlProvider {
|
||||
pub fn new() -> Self {
|
||||
Self { items: Vec::new() }
|
||||
}
|
||||
|
||||
fn systemctl_available() -> bool {
|
||||
Command::new("systemctl")
|
||||
.arg("--user")
|
||||
.arg("--version")
|
||||
.output()
|
||||
.map(|o| o.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Generate submenu actions for a given service
|
||||
pub fn actions_for_service(unit_name: &str, display_name: &str, is_active: bool) -> Vec<LaunchItem> {
|
||||
let mut actions = Vec::new();
|
||||
|
||||
if is_active {
|
||||
actions.push(LaunchItem {
|
||||
id: format!("systemd:restart:{}", unit_name),
|
||||
name: "↻ Restart".to_string(),
|
||||
description: Some(format!("Restart {}", display_name)),
|
||||
icon: Some("view-refresh".to_string()),
|
||||
provider: ProviderType::Uuctl,
|
||||
command: format!("systemctl --user restart {}", unit_name),
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
actions.push(LaunchItem {
|
||||
id: format!("systemd:stop:{}", unit_name),
|
||||
name: "■ Stop".to_string(),
|
||||
description: Some(format!("Stop {}", display_name)),
|
||||
icon: Some("process-stop".to_string()),
|
||||
provider: ProviderType::Uuctl,
|
||||
command: format!("systemctl --user stop {}", unit_name),
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
actions.push(LaunchItem {
|
||||
id: format!("systemd:reload:{}", unit_name),
|
||||
name: "⟳ Reload".to_string(),
|
||||
description: Some(format!("Reload {} configuration", display_name)),
|
||||
icon: Some("view-refresh".to_string()),
|
||||
provider: ProviderType::Uuctl,
|
||||
command: format!("systemctl --user reload {}", unit_name),
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
actions.push(LaunchItem {
|
||||
id: format!("systemd:kill:{}", unit_name),
|
||||
name: "✗ Kill".to_string(),
|
||||
description: Some(format!("Force kill {}", display_name)),
|
||||
icon: Some("edit-delete".to_string()),
|
||||
provider: ProviderType::Uuctl,
|
||||
command: format!("systemctl --user kill {}", unit_name),
|
||||
terminal: false,
|
||||
});
|
||||
} else {
|
||||
actions.push(LaunchItem {
|
||||
id: format!("systemd:start:{}", unit_name),
|
||||
name: "▶ Start".to_string(),
|
||||
description: Some(format!("Start {}", display_name)),
|
||||
icon: Some("media-playback-start".to_string()),
|
||||
provider: ProviderType::Uuctl,
|
||||
command: format!("systemctl --user start {}", unit_name),
|
||||
terminal: false,
|
||||
});
|
||||
}
|
||||
|
||||
// Always available actions
|
||||
actions.push(LaunchItem {
|
||||
id: format!("systemd:status:{}", unit_name),
|
||||
name: "ℹ Status".to_string(),
|
||||
description: Some(format!("Show {} status", display_name)),
|
||||
icon: Some("dialog-information".to_string()),
|
||||
provider: ProviderType::Uuctl,
|
||||
command: format!("systemctl --user status {}", unit_name),
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
actions.push(LaunchItem {
|
||||
id: format!("systemd:journal:{}", unit_name),
|
||||
name: "📋 Journal".to_string(),
|
||||
description: Some(format!("Show {} logs", display_name)),
|
||||
icon: Some("utilities-system-monitor".to_string()),
|
||||
provider: ProviderType::Uuctl,
|
||||
command: format!("journalctl --user -u {} -f", unit_name),
|
||||
terminal: true,
|
||||
});
|
||||
|
||||
actions.push(LaunchItem {
|
||||
id: format!("systemd:enable:{}", unit_name),
|
||||
name: "⊕ Enable".to_string(),
|
||||
description: Some(format!("Enable {} on startup", display_name)),
|
||||
icon: Some("emblem-default".to_string()),
|
||||
provider: ProviderType::Uuctl,
|
||||
command: format!("systemctl --user enable {}", unit_name),
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
actions.push(LaunchItem {
|
||||
id: format!("systemd:disable:{}", unit_name),
|
||||
name: "⊖ Disable".to_string(),
|
||||
description: Some(format!("Disable {} on startup", display_name)),
|
||||
icon: Some("emblem-unreadable".to_string()),
|
||||
provider: ProviderType::Uuctl,
|
||||
command: format!("systemctl --user disable {}", unit_name),
|
||||
terminal: false,
|
||||
});
|
||||
|
||||
actions
|
||||
}
|
||||
|
||||
fn parse_systemctl_output(output: &str) -> Vec<LaunchItem> {
|
||||
let mut items = Vec::new();
|
||||
|
||||
for line in output.lines() {
|
||||
let line = line.trim();
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Parse systemctl output - handle variable whitespace
|
||||
// Format: UNIT LOAD ACTIVE SUB DESCRIPTION...
|
||||
let mut parts = line.split_whitespace();
|
||||
|
||||
let unit_name = match parts.next() {
|
||||
Some(u) => u,
|
||||
None => continue,
|
||||
};
|
||||
|
||||
// Skip if not a proper service name
|
||||
if !unit_name.ends_with(".service") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let _load_state = parts.next().unwrap_or("");
|
||||
let active_state = parts.next().unwrap_or("");
|
||||
let sub_state = parts.next().unwrap_or("");
|
||||
let description: String = parts.collect::<Vec<_>>().join(" ");
|
||||
|
||||
// Create a clean display name
|
||||
let display_name = unit_name
|
||||
.trim_end_matches(".service")
|
||||
.replace("app-", "")
|
||||
.replace("@autostart", "")
|
||||
.replace("\\x2d", "-");
|
||||
|
||||
let is_active = active_state == "active";
|
||||
let status_icon = if is_active { "●" } else { "○" };
|
||||
|
||||
let status_desc = if description.is_empty() {
|
||||
format!("{} {} ({})", status_icon, sub_state, active_state)
|
||||
} else {
|
||||
format!("{} {} ({})", status_icon, description, sub_state)
|
||||
};
|
||||
|
||||
// Store service info in the command field as encoded data
|
||||
// Format: SUBMENU:unit_name:is_active
|
||||
let submenu_data = format!("SUBMENU:{}:{}", unit_name, is_active);
|
||||
|
||||
items.push(LaunchItem {
|
||||
id: format!("systemd:service:{}", unit_name),
|
||||
name: display_name,
|
||||
description: Some(status_desc),
|
||||
icon: Some(if is_active { "emblem-ok-symbolic" } else { "emblem-pause-symbolic" }.to_string()),
|
||||
provider: ProviderType::Uuctl,
|
||||
command: submenu_data, // Special marker for submenu
|
||||
terminal: false,
|
||||
});
|
||||
}
|
||||
|
||||
items
|
||||
}
|
||||
|
||||
/// Check if an item is a submenu trigger (service, not action)
|
||||
pub fn is_submenu_item(item: &LaunchItem) -> bool {
|
||||
item.provider == ProviderType::Uuctl && item.command.starts_with("SUBMENU:")
|
||||
}
|
||||
|
||||
/// Parse submenu data from item command
|
||||
pub fn parse_submenu_data(item: &LaunchItem) -> Option<(String, String, bool)> {
|
||||
if !Self::is_submenu_item(item) {
|
||||
return None;
|
||||
}
|
||||
|
||||
let parts: Vec<&str> = item.command.splitn(3, ':').collect();
|
||||
if parts.len() >= 3 {
|
||||
let unit_name = parts[1].to_string();
|
||||
let is_active = parts[2] == "true";
|
||||
Some((unit_name, item.name.clone(), is_active))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Provider for UuctlProvider {
|
||||
fn name(&self) -> &str {
|
||||
"systemd-user"
|
||||
}
|
||||
|
||||
fn provider_type(&self) -> ProviderType {
|
||||
ProviderType::Uuctl
|
||||
}
|
||||
|
||||
fn refresh(&mut self) {
|
||||
self.items.clear();
|
||||
|
||||
if !Self::systemctl_available() {
|
||||
debug!("systemctl --user not available, skipping");
|
||||
return;
|
||||
}
|
||||
|
||||
// List all user services (both running and available)
|
||||
let output = match Command::new("systemctl")
|
||||
.args(["--user", "list-units", "--type=service", "--all", "--no-legend", "--no-pager"])
|
||||
.output()
|
||||
{
|
||||
Ok(o) => o,
|
||||
Err(e) => {
|
||||
warn!("Failed to run systemctl --user: {}", e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if !output.status.success() {
|
||||
warn!("systemctl --user failed with status: {}", output.status);
|
||||
return;
|
||||
}
|
||||
|
||||
let stdout = String::from_utf8_lossy(&output.stdout);
|
||||
self.items = Self::parse_systemctl_output(&stdout);
|
||||
|
||||
// Sort by name
|
||||
self.items.sort_by(|a, b| a.name.cmp(&b.name));
|
||||
|
||||
debug!("Found {} systemd user services", self.items.len());
|
||||
}
|
||||
|
||||
fn items(&self) -> &[LaunchItem] {
|
||||
&self.items
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user