feat: implement OS bridge and enhance app launcher functionality

This commit is contained in:
2026-03-15 17:45:24 +01:00
parent 93736ae19d
commit 1a2de21bf6
18 changed files with 363 additions and 294 deletions

7
Cargo.lock generated
View File

@@ -1764,6 +1764,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"iced", "iced",
"k-launcher-kernel", "k-launcher-kernel",
"k-launcher-os-bridge",
"k-launcher-ui", "k-launcher-ui",
"plugin-apps", "plugin-apps",
"plugin-calc", "plugin-calc",
@@ -1785,6 +1786,10 @@ dependencies = [
[[package]] [[package]]
name = "k-launcher-os-bridge" name = "k-launcher-os-bridge"
version = "0.1.0" version = "0.1.0"
dependencies = [
"k-launcher-kernel",
"libc",
]
[[package]] [[package]]
name = "k-launcher-ui" name = "k-launcher-ui"
@@ -2738,7 +2743,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"k-launcher-kernel", "k-launcher-kernel",
"libc",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@@ -2761,7 +2765,6 @@ version = "0.1.0"
dependencies = [ dependencies = [
"async-trait", "async-trait",
"k-launcher-kernel", "k-launcher-kernel",
"libc",
"tokio", "tokio",
] ]

View File

@@ -43,6 +43,22 @@ impl Score {
} }
} }
// --- LaunchAction (port) ---
pub enum LaunchAction {
SpawnProcess(String),
SpawnInTerminal(String),
OpenPath(String),
CopyToClipboard(String),
Custom(Arc<dyn Fn() + Send + Sync>),
}
// --- AppLauncher port trait ---
pub trait AppLauncher: Send + Sync {
fn execute(&self, action: &LaunchAction);
}
// --- SearchResult --- // --- SearchResult ---
pub struct SearchResult { pub struct SearchResult {
@@ -51,7 +67,8 @@ pub struct SearchResult {
pub description: Option<String>, pub description: Option<String>,
pub icon: Option<String>, pub icon: Option<String>,
pub score: Score, pub score: Score,
pub on_execute: Arc<dyn Fn() + Send + Sync>, pub action: LaunchAction,
pub on_select: Option<Arc<dyn Fn() + Send + Sync>>,
} }
impl std::fmt::Debug for SearchResult { impl std::fmt::Debug for SearchResult {
@@ -73,6 +90,13 @@ pub trait Plugin: Send + Sync {
async fn search(&self, query: &str) -> Vec<SearchResult>; async fn search(&self, query: &str) -> Vec<SearchResult>;
} }
// --- SearchEngine port trait ---
#[async_trait]
pub trait SearchEngine: Send + Sync {
async fn search(&self, query: &str) -> Vec<SearchResult>;
}
// --- Kernel (Application use case) --- // --- Kernel (Application use case) ---
pub struct Kernel { pub struct Kernel {
@@ -94,6 +118,13 @@ impl Kernel {
} }
} }
#[async_trait]
impl SearchEngine for Kernel {
async fn search(&self, query: &str) -> Vec<SearchResult> {
self.search(query).await
}
}
// --- Tests --- // --- Tests ---
#[cfg(test)] #[cfg(test)]
@@ -126,7 +157,8 @@ mod tests {
description: None, description: None,
icon: None, icon: None,
score: Score::new(*score), score: Score::new(*score),
on_execute: Arc::new(|| {}), action: LaunchAction::Custom(Arc::new(|| {})),
on_select: None,
}) })
.collect() .collect()
} }

View File

@@ -4,3 +4,5 @@ version = "0.1.0"
edition = "2024" edition = "2024"
[dependencies] [dependencies]
k-launcher-kernel = { path = "../k-launcher-kernel" }
libc = "0.2"

View File

@@ -1,4 +1,7 @@
/// Configuration for the launcher window. mod unix_launcher;
pub use unix_launcher::UnixAppLauncher;
pub struct WindowConfig { pub struct WindowConfig {
pub width: f32, pub width: f32,
pub height: f32, pub height: f32,

View File

@@ -0,0 +1,118 @@
use std::process::{Command, Stdio};
use std::os::unix::process::CommandExt;
use k_launcher_kernel::{AppLauncher, LaunchAction};
fn parse_term_cmd(s: &str) -> (String, Vec<String>) {
let mut parts = s.split_whitespace();
let bin = parts.next().unwrap_or("").to_string();
let args = parts.map(str::to_string).collect();
(bin, args)
}
fn which(bin: &str) -> bool {
Command::new("which")
.arg(bin)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn resolve_terminal() -> Option<(String, Vec<String>)> {
if let Ok(val) = std::env::var("TERM_CMD") {
let val = val.trim().to_string();
if !val.is_empty() {
let (bin, args) = parse_term_cmd(&val);
if !bin.is_empty() {
return Some((bin, args));
}
}
}
if let Ok(val) = std::env::var("TERMINAL") {
let bin = val.trim().to_string();
if !bin.is_empty() {
return Some((bin, vec!["-e".to_string()]));
}
}
for (bin, flag) in &[
("foot", "-e"),
("kitty", "-e"),
("alacritty", "-e"),
("wezterm", "start"),
("konsole", "-e"),
("xterm", "-e"),
] {
if which(bin) {
return Some((bin.to_string(), vec![flag.to_string()]));
}
}
None
}
pub struct UnixAppLauncher;
impl UnixAppLauncher {
pub fn new() -> Self {
Self
}
}
impl Default for UnixAppLauncher {
fn default() -> Self {
Self::new()
}
}
impl AppLauncher for UnixAppLauncher {
fn execute(&self, action: &LaunchAction) {
match action {
LaunchAction::SpawnProcess(cmd) => {
let parts: Vec<&str> = cmd.split_whitespace().collect();
if let Some((bin, args)) = parts.split_first() {
let _ = unsafe {
Command::new(bin)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| { libc::setsid(); Ok(()) })
.spawn()
};
}
}
LaunchAction::SpawnInTerminal(cmd) => {
let Some((term_bin, term_args)) = resolve_terminal() else { return };
let _ = unsafe {
Command::new(&term_bin)
.args(&term_args)
.arg("sh").arg("-c").arg(cmd)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| { libc::setsid(); Ok(()) })
.spawn()
};
}
LaunchAction::OpenPath(path) => {
let _ = Command::new("xdg-open").arg(path).spawn();
}
LaunchAction::CopyToClipboard(val) => {
if Command::new("wl-copy").arg(val).spawn().is_err() {
use std::io::Write;
if let Ok(mut child) = Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(Stdio::piped())
.spawn()
{
if let Some(stdin) = child.stdin.as_mut() {
let _ = stdin.write_all(val.as_bytes());
}
}
}
}
LaunchAction::Custom(f) => f(),
}
}
}

View File

@@ -8,7 +8,7 @@ use iced::{
window, window,
}; };
use k_launcher_kernel::{Kernel, SearchResult}; use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
use k_launcher_os_bridge::WindowConfig; use k_launcher_os_bridge::WindowConfig;
use crate::theme; use crate::theme;
@@ -17,16 +17,18 @@ static INPUT_ID: std::sync::LazyLock<iced::widget::Id> =
std::sync::LazyLock::new(|| iced::widget::Id::new("search")); std::sync::LazyLock::new(|| iced::widget::Id::new("search"));
pub struct KLauncherApp { pub struct KLauncherApp {
kernel: Arc<Kernel>, engine: Arc<dyn SearchEngine>,
launcher: Arc<dyn AppLauncher>,
query: String, query: String,
results: Arc<Vec<SearchResult>>, results: Arc<Vec<SearchResult>>,
selected: usize, selected: usize,
} }
impl KLauncherApp { impl KLauncherApp {
fn new(kernel: Arc<Kernel>) -> Self { fn new(engine: Arc<dyn SearchEngine>, launcher: Arc<dyn AppLauncher>) -> Self {
Self { Self {
kernel, engine,
launcher,
query: String::new(), query: String::new(),
results: Arc::new(vec![]), results: Arc::new(vec![]),
selected: 0, selected: 0,
@@ -46,9 +48,9 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
Message::QueryChanged(q) => { Message::QueryChanged(q) => {
state.query = q.clone(); state.query = q.clone();
state.selected = 0; state.selected = 0;
let kernel = state.kernel.clone(); let engine = state.engine.clone();
Task::perform( Task::perform(
async move { kernel.search(&q).await }, async move { engine.search(&q).await },
|results| Message::ResultsReady(Arc::new(results)), |results| Message::ResultsReady(Arc::new(results)),
) )
} }
@@ -79,7 +81,10 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
} }
Named::Enter => { Named::Enter => {
if let Some(result) = state.results.get(state.selected) { if let Some(result) = state.results.get(state.selected) {
(result.on_execute)(); if let Some(on_select) = &result.on_select {
on_select();
}
state.launcher.execute(&result.action);
} }
std::process::exit(0); std::process::exit(0);
} }
@@ -192,11 +197,11 @@ fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
}) })
} }
pub fn run(kernel: Arc<Kernel>) -> iced::Result { pub fn run(engine: Arc<dyn SearchEngine>, launcher: Arc<dyn AppLauncher>) -> iced::Result {
let wc = WindowConfig::launcher(); let wc = WindowConfig::launcher();
iced::application( iced::application(
move || { move || {
let app = KLauncherApp::new(kernel.clone()); let app = KLauncherApp::new(engine.clone(), launcher.clone());
let focus = iced::widget::operation::focus(INPUT_ID.clone()); let focus = iced::widget::operation::focus(INPUT_ID.clone());
(app, focus) (app, focus)
}, },

View File

@@ -3,9 +3,8 @@ pub mod theme;
use std::sync::Arc; use std::sync::Arc;
use k_launcher_kernel::Kernel; use k_launcher_kernel::{AppLauncher, SearchEngine};
pub fn run(engine: Arc<dyn SearchEngine>, launcher: Arc<dyn AppLauncher>) -> iced::Result {
pub fn run(kernel: Arc<Kernel>) -> iced::Result { app::run(engine, launcher)
app::run(kernel)
} }

View File

@@ -11,6 +11,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
iced = { workspace = true } iced = { workspace = true }
k-launcher-kernel = { path = "../k-launcher-kernel" } k-launcher-kernel = { path = "../k-launcher-kernel" }
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
k-launcher-ui = { path = "../k-launcher-ui" } k-launcher-ui = { path = "../k-launcher-ui" }
plugin-apps = { path = "../plugins/plugin-apps" } plugin-apps = { path = "../plugins/plugin-apps" }
plugin-calc = { path = "../plugins/plugin-calc" } plugin-calc = { path = "../plugins/plugin-calc" }

View File

@@ -1,18 +1,22 @@
use std::sync::Arc; use std::sync::Arc;
use k_launcher_kernel::Kernel; use k_launcher_kernel::Kernel;
use plugin_apps::{AppsPlugin, FsDesktopEntrySource, frecency::FrecencyStore}; use k_launcher_os_bridge::UnixAppLauncher;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
#[cfg(target_os = "linux")]
use plugin_apps::linux::FsDesktopEntrySource;
use plugin_calc::CalcPlugin; use plugin_calc::CalcPlugin;
use plugin_cmd::CmdPlugin; use plugin_cmd::CmdPlugin;
use plugin_files::FilesPlugin; use plugin_files::FilesPlugin;
fn main() -> iced::Result { fn main() -> iced::Result {
let launcher = Arc::new(UnixAppLauncher::new());
let frecency = FrecencyStore::load(); let frecency = FrecencyStore::load();
let kernel = Arc::new(Kernel::new(vec![ let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(vec![
Arc::new(CmdPlugin::new()), Arc::new(CmdPlugin::new()),
Arc::new(CalcPlugin::new()), Arc::new(CalcPlugin::new()),
Arc::new(FilesPlugin::new()), Arc::new(FilesPlugin::new()),
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)), Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)),
])); ]));
k_launcher_ui::run(kernel) k_launcher_ui::run(kernel, launcher)
} }

View File

@@ -7,15 +7,12 @@ edition = "2024"
name = "plugin_apps" name = "plugin_apps"
path = "src/lib.rs" path = "src/lib.rs"
[[bin]]
name = "plugin-apps"
path = "src/main.rs"
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
k-launcher-kernel = { path = "../../k-launcher-kernel" } k-launcher-kernel = { path = "../../k-launcher-kernel" }
libc = "0.2"
serde = { workspace = true } serde = { workspace = true }
serde_json = "1.0" serde_json = "1.0"
tokio = { workspace = true } tokio = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies]
xdg = "2" xdg = "2"

View File

@@ -1,10 +1,11 @@
pub mod frecency; pub mod frecency;
#[cfg(target_os = "linux")]
pub mod linux;
use std::{collections::HashMap, path::Path, process::{Command, Stdio}, sync::Arc}; use std::{collections::HashMap, sync::Arc};
use std::os::unix::process::CommandExt;
use async_trait::async_trait; use async_trait::async_trait;
use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult}; use k_launcher_kernel::{LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
use crate::frecency::FrecencyStore; use crate::frecency::FrecencyStore;
@@ -71,7 +72,8 @@ struct CachedEntry {
keywords_lc: Vec<String>, keywords_lc: Vec<String>,
category: Option<String>, category: Option<String>,
icon: Option<String>, icon: Option<String>,
on_execute: Arc<dyn Fn() + Send + Sync>, exec: String,
on_select: Arc<dyn Fn() + Send + Sync>,
} }
// --- Plugin --- // --- Plugin ---
@@ -90,27 +92,15 @@ impl AppsPlugin {
let id = format!("app-{}", e.name.as_str()); let id = format!("app-{}", e.name.as_str());
let name_lc = e.name.as_str().to_lowercase(); let name_lc = e.name.as_str().to_lowercase();
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect(); let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
let icon = e.icon.as_ref().and_then(|p| resolve_icon_path(p.as_str())); #[cfg(target_os = "linux")]
let exec = e.exec.clone(); let icon = e.icon.as_ref().and_then(|p| linux::resolve_icon_path(p.as_str()));
#[cfg(not(target_os = "linux"))]
let icon: Option<String> = None;
let exec = e.exec.as_str().to_string();
let store = Arc::clone(&frecency); let store = Arc::clone(&frecency);
let record_id = id.clone(); let record_id = id.clone();
let on_execute: Arc<dyn Fn() + Send + Sync> = Arc::new(move || { let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
store.record(&record_id); store.record(&record_id);
let parts: Vec<&str> = exec.as_str().split_whitespace().collect();
if let Some((cmd, args)) = parts.split_first() {
let _ = unsafe {
Command::new(cmd)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn()
};
}
}); });
let cached = CachedEntry { let cached = CachedEntry {
id: id.clone(), id: id.clone(),
@@ -118,7 +108,8 @@ impl AppsPlugin {
keywords_lc, keywords_lc,
category: e.category, category: e.category,
icon, icon,
on_execute, exec,
on_select,
name: e.name, name: e.name,
}; };
(id, cached) (id, cached)
@@ -128,19 +119,6 @@ impl AppsPlugin {
} }
} }
fn resolve_icon_path(name: &str) -> Option<String> {
if name.starts_with('/') && Path::new(name).exists() {
return Some(name.to_string());
}
let candidates = [
format!("/usr/share/pixmaps/{name}.png"),
format!("/usr/share/pixmaps/{name}.svg"),
format!("/usr/share/icons/hicolor/48x48/apps/{name}.png"),
format!("/usr/share/icons/hicolor/scalable/apps/{name}.svg"),
];
candidates.into_iter().find(|p| Path::new(p).exists())
}
fn initials(name_lc: &str) -> String { fn initials(name_lc: &str) -> String {
name_lc.split_whitespace().filter_map(|w| w.chars().next()).collect() name_lc.split_whitespace().filter_map(|w| w.chars().next()).collect()
} }
@@ -153,7 +131,7 @@ fn score_match(name_lc: &str, query_lc: &str) -> Option<u32> {
None None
} }
fn humanize_category(s: &str) -> String { pub(crate) fn humanize_category(s: &str) -> String {
let mut result = String::new(); let mut result = String::new();
for ch in s.chars() { for ch in s.chars() {
if ch.is_uppercase() && !result.is_empty() { if ch.is_uppercase() && !result.is_empty() {
@@ -183,7 +161,8 @@ impl Plugin for AppsPlugin {
description: e.category.clone(), description: e.category.clone(),
icon: e.icon.clone(), icon: e.icon.clone(),
score: Score::new(score), score: Score::new(score),
on_execute: Arc::clone(&e.on_execute), action: LaunchAction::SpawnProcess(e.exec.clone()),
on_select: Some(Arc::clone(&e.on_select)),
}) })
}) })
.collect(); .collect();
@@ -202,129 +181,14 @@ impl Plugin for AppsPlugin {
description: e.category.clone(), description: e.category.clone(),
icon: e.icon.clone(), icon: e.icon.clone(),
score: Score::new(score), score: Score::new(score),
on_execute: Arc::clone(&e.on_execute), action: LaunchAction::SpawnProcess(e.exec.clone()),
on_select: Some(Arc::clone(&e.on_select)),
}) })
}) })
.collect() .collect()
} }
} }
// --- Filesystem source ---
pub struct FsDesktopEntrySource;
impl FsDesktopEntrySource {
pub fn new() -> Self {
Self
}
}
impl Default for FsDesktopEntrySource {
fn default() -> Self {
Self::new()
}
}
impl DesktopEntrySource for FsDesktopEntrySource {
fn entries(&self) -> Vec<DesktopEntry> {
let mut dirs = Vec::new();
if let Ok(xdg) = xdg::BaseDirectories::new() {
dirs.push(xdg.get_data_home().join("applications"));
for d in xdg.get_data_dirs() {
dirs.push(d.join("applications"));
}
}
let mut entries = Vec::new();
for dir in &dirs {
if let Ok(read_dir) = std::fs::read_dir(dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
if let Some(de) = parse_desktop_file(&path) {
entries.push(de);
}
}
}
}
entries
}
}
fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
let content = std::fs::read_to_string(path).ok()?;
let mut in_section = false;
let mut name: Option<String> = None;
let mut exec: Option<String> = None;
let mut icon: Option<String> = None;
let mut category: Option<String> = None;
let mut keywords: Vec<String> = Vec::new();
let mut is_application = false;
let mut no_display = false;
for line in content.lines() {
let line = line.trim();
if line == "[Desktop Entry]" {
in_section = true;
continue;
}
if line.starts_with('[') {
in_section = false;
continue;
}
if !in_section || line.starts_with('#') || line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once('=') {
match key.trim() {
"Name" if name.is_none() => name = Some(value.trim().to_string()),
"Exec" if exec.is_none() => exec = Some(value.trim().to_string()),
"Icon" if icon.is_none() => icon = Some(value.trim().to_string()),
"Type" if !is_application => is_application = value.trim() == "Application",
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
"Categories" if category.is_none() => {
category = value.trim()
.split(';')
.find(|s| !s.is_empty())
.map(|s| humanize_category(s.trim()));
}
"Keywords" if keywords.is_empty() => {
keywords = value.trim()
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.trim().to_string())
.collect();
}
_ => {}
}
}
}
if !is_application || no_display {
return None;
}
let exec_clean: String = exec?
.split_whitespace()
.filter(|s| !s.starts_with('%'))
.fold(String::new(), |mut acc, s| {
if !acc.is_empty() {
acc.push(' ');
}
acc.push_str(s);
acc
});
Some(DesktopEntry {
name: AppName::new(name?),
exec: ExecCommand::new(exec_clean),
icon: icon.map(IconPath::new),
category,
keywords,
})
}
// --- Tests --- // --- Tests ---
#[cfg(test)] #[cfg(test)]

View File

@@ -0,0 +1,133 @@
#![cfg(target_os = "linux")]
use std::path::Path;
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
use crate::humanize_category;
pub struct FsDesktopEntrySource;
impl FsDesktopEntrySource {
pub fn new() -> Self {
Self
}
}
impl Default for FsDesktopEntrySource {
fn default() -> Self {
Self::new()
}
}
impl DesktopEntrySource for FsDesktopEntrySource {
fn entries(&self) -> Vec<DesktopEntry> {
let mut dirs = Vec::new();
if let Ok(xdg) = xdg::BaseDirectories::new() {
dirs.push(xdg.get_data_home().join("applications"));
for d in xdg.get_data_dirs() {
dirs.push(d.join("applications"));
}
}
let mut entries = Vec::new();
for dir in &dirs {
if let Ok(read_dir) = std::fs::read_dir(dir) {
for entry in read_dir.flatten() {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) != Some("desktop") {
continue;
}
if let Some(de) = parse_desktop_file(&path) {
entries.push(de);
}
}
}
}
entries
}
}
pub fn resolve_icon_path(name: &str) -> Option<String> {
if name.starts_with('/') && Path::new(name).exists() {
return Some(name.to_string());
}
let candidates = [
format!("/usr/share/pixmaps/{name}.png"),
format!("/usr/share/pixmaps/{name}.svg"),
format!("/usr/share/icons/hicolor/48x48/apps/{name}.png"),
format!("/usr/share/icons/hicolor/scalable/apps/{name}.svg"),
];
candidates.into_iter().find(|p| Path::new(p).exists())
}
fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
let content = std::fs::read_to_string(path).ok()?;
let mut in_section = false;
let mut name: Option<String> = None;
let mut exec: Option<String> = None;
let mut icon: Option<String> = None;
let mut category: Option<String> = None;
let mut keywords: Vec<String> = Vec::new();
let mut is_application = false;
let mut no_display = false;
for line in content.lines() {
let line = line.trim();
if line == "[Desktop Entry]" {
in_section = true;
continue;
}
if line.starts_with('[') {
in_section = false;
continue;
}
if !in_section || line.starts_with('#') || line.is_empty() {
continue;
}
if let Some((key, value)) = line.split_once('=') {
match key.trim() {
"Name" if name.is_none() => name = Some(value.trim().to_string()),
"Exec" if exec.is_none() => exec = Some(value.trim().to_string()),
"Icon" if icon.is_none() => icon = Some(value.trim().to_string()),
"Type" if !is_application => is_application = value.trim() == "Application",
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
"Categories" if category.is_none() => {
category = value.trim()
.split(';')
.find(|s| !s.is_empty())
.map(|s| humanize_category(s.trim()));
}
"Keywords" if keywords.is_empty() => {
keywords = value.trim()
.split(';')
.filter(|s| !s.is_empty())
.map(|s| s.trim().to_string())
.collect();
}
_ => {}
}
}
}
if !is_application || no_display {
return None;
}
let exec_clean: String = exec?
.split_whitespace()
.filter(|s| !s.starts_with('%'))
.fold(String::new(), |mut acc, s| {
if !acc.is_empty() {
acc.push(' ');
}
acc.push_str(s);
acc
});
Some(DesktopEntry {
name: AppName::new(name?),
exec: ExecCommand::new(exec_clean),
icon: icon.map(IconPath::new),
category,
keywords,
})
}

View File

@@ -1 +0,0 @@
fn main() {}

View File

@@ -1,7 +1,5 @@
use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult}; use k_launcher_kernel::{LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
pub struct CalcPlugin; pub struct CalcPlugin;
@@ -46,27 +44,14 @@ impl Plugin for CalcPlugin {
}; };
let display = format!("= {value_str}"); let display = format!("= {value_str}");
let expr_owned = expr.to_string(); let expr_owned = expr.to_string();
let clipboard_val = value_str;
vec![SearchResult { vec![SearchResult {
id: ResultId::new("calc-result"), id: ResultId::new("calc-result"),
title: ResultTitle::new(display), title: ResultTitle::new(display),
description: Some(format!("{expr_owned} · Enter to copy")), description: Some(format!("{expr_owned} · Enter to copy")),
icon: None, icon: None,
score: Score::new(90), score: Score::new(90),
on_execute: Arc::new(move || { action: LaunchAction::CopyToClipboard(value_str),
if std::process::Command::new("wl-copy").arg(&clipboard_val).spawn().is_err() { on_select: None,
use std::io::Write;
if let Ok(mut child) = std::process::Command::new("xclip")
.args(["-selection", "clipboard"])
.stdin(std::process::Stdio::piped())
.spawn()
{
if let Some(stdin) = child.stdin.as_mut() {
let _ = stdin.write_all(clipboard_val.as_bytes());
}
}
}
}),
}] }]
} }
_ => vec![], _ => vec![],

View File

@@ -10,7 +10,6 @@ path = "src/lib.rs"
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
k-launcher-kernel = { path = "../../k-launcher-kernel" } k-launcher-kernel = { path = "../../k-launcher-kernel" }
libc = "0.2"
[dev-dependencies] [dev-dependencies]
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -1,56 +1,5 @@
use std::{process::{Command, Stdio}, sync::Arc};
use std::os::unix::process::CommandExt;
use async_trait::async_trait; use async_trait::async_trait;
use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult}; use k_launcher_kernel::{LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
fn parse_term_cmd(s: &str) -> (String, Vec<String>) {
let mut parts = s.split_whitespace();
let bin = parts.next().unwrap_or("").to_string();
let args = parts.map(str::to_string).collect();
(bin, args)
}
fn which(bin: &str) -> bool {
Command::new("which")
.arg(bin)
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn resolve_terminal() -> Option<(String, Vec<String>)> {
if let Ok(val) = std::env::var("TERM_CMD") {
let val = val.trim().to_string();
if !val.is_empty() {
let (bin, args) = parse_term_cmd(&val);
if !bin.is_empty() {
return Some((bin, args));
}
}
}
if let Ok(val) = std::env::var("TERMINAL") {
let bin = val.trim().to_string();
if !bin.is_empty() {
return Some((bin, vec!["-e".to_string()]));
}
}
for (bin, flag) in &[
("foot", "-e"),
("kitty", "-e"),
("alacritty", "-e"),
("wezterm", "start"),
("konsole", "-e"),
("xterm", "-e"),
] {
if which(bin) {
return Some((bin.to_string(), vec![flag.to_string()]));
}
}
None
}
pub struct CmdPlugin; pub struct CmdPlugin;
@@ -80,26 +29,14 @@ impl Plugin for CmdPlugin {
if cmd.is_empty() { if cmd.is_empty() {
return vec![]; return vec![];
} }
let cmd_owned = cmd.to_string();
vec![SearchResult { vec![SearchResult {
id: ResultId::new(format!("cmd-{cmd}")), id: ResultId::new(format!("cmd-{cmd}")),
title: ResultTitle::new(format!("Run: {cmd}")), title: ResultTitle::new(format!("Run: {cmd}")),
description: None, description: None,
icon: None, icon: None,
score: Score::new(95), score: Score::new(95),
on_execute: Arc::new(move || { action: LaunchAction::SpawnInTerminal(cmd.to_string()),
let Some((term_bin, term_args)) = resolve_terminal() else { return }; on_select: None,
let _ = unsafe {
Command::new(&term_bin)
.args(&term_args)
.arg("sh").arg("-c").arg(&cmd_owned)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| { libc::setsid(); Ok(()) })
.spawn()
};
}),
}] }]
} }
} }
@@ -130,25 +67,4 @@ mod tests {
assert!(p.search("echo hello").await.is_empty()); assert!(p.search("echo hello").await.is_empty());
assert!(p.search("firefox").await.is_empty()); assert!(p.search("firefox").await.is_empty());
} }
#[test]
fn parse_term_cmd_single_flag() {
let (bin, args) = parse_term_cmd("foot -e");
assert_eq!(bin, "foot");
assert_eq!(args, vec!["-e"]);
}
#[test]
fn parse_term_cmd_multiword() {
let (bin, args) = parse_term_cmd("wezterm start");
assert_eq!(bin, "wezterm");
assert_eq!(args, vec!["start"]);
}
#[test]
fn parse_term_cmd_no_args() {
let (bin, args) = parse_term_cmd("xterm");
assert_eq!(bin, "xterm");
assert!(args.is_empty());
}
} }

View File

@@ -1,8 +1,9 @@
mod platform;
use std::path::Path; use std::path::Path;
use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use k_launcher_kernel::{Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult}; use k_launcher_kernel::{LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
pub struct FilesPlugin; pub struct FilesPlugin;
@@ -20,7 +21,7 @@ impl Default for FilesPlugin {
fn expand_query(query: &str) -> Option<String> { fn expand_query(query: &str) -> Option<String> {
if query.starts_with("~/") { if query.starts_with("~/") {
let home = std::env::var("HOME").ok()?; let home = platform::home_dir()?;
Some(format!("{}{}", home, &query[1..])) Some(format!("{}{}", home, &query[1..]))
} else if query.starts_with('/') { } else if query.starts_with('/') {
Some(query.to_string()) Some(query.to_string())
@@ -87,9 +88,8 @@ impl Plugin for FilesPlugin {
description: Some(path_str.clone()), description: Some(path_str.clone()),
icon: None, icon: None,
score: Score::new(50), score: Score::new(50),
on_execute: Arc::new(move || { action: LaunchAction::OpenPath(path_str),
let _ = std::process::Command::new("xdg-open").arg(&path_str).spawn(); on_select: None,
}),
} }
}) })
.collect() .collect()

View File

@@ -0,0 +1,9 @@
#[cfg(unix)]
pub fn home_dir() -> Option<String> {
std::env::var("HOME").ok()
}
#[cfg(windows)]
pub fn home_dir() -> Option<String> {
std::env::var("USERPROFILE").ok()
}