feat: add support for external plugins and enhance plugin management

This commit is contained in:
2026-03-15 18:54:55 +01:00
parent b8a9a6b02f
commit d1479f41d2
15 changed files with 389 additions and 19 deletions

View File

@@ -76,6 +76,14 @@ impl Default for SearchCfg {
}
}
#[derive(Debug, Clone, Deserialize, Default)]
pub struct ExternalPluginCfg {
pub name: String,
pub path: String,
#[serde(default)]
pub args: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default)]
pub struct PluginsCfg {
@@ -83,6 +91,7 @@ pub struct PluginsCfg {
pub cmd: bool,
pub files: bool,
pub apps: bool,
pub external: Vec<ExternalPluginCfg>,
}
impl Default for PluginsCfg {
@@ -92,6 +101,7 @@ impl Default for PluginsCfg {
cmd: true,
files: true,
apps: true,
external: vec![],
}
}
}

View File

@@ -3,8 +3,6 @@ use std::sync::Arc;
use async_trait::async_trait;
use futures::future::join_all;
pub type PluginName = &'static str;
// --- Newtypes ---
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
@@ -86,7 +84,7 @@ impl std::fmt::Debug for SearchResult {
#[async_trait]
pub trait Plugin: Send + Sync {
fn name(&self) -> PluginName;
fn name(&self) -> &str;
async fn search(&self, query: &str) -> Vec<SearchResult>;
}
@@ -144,7 +142,7 @@ mod tests {
#[async_trait]
impl Plugin for MockPlugin {
fn name(&self) -> PluginName {
fn name(&self) -> &str {
"mock"
}

View File

@@ -0,0 +1,16 @@
[package]
name = "k-launcher-plugin-host"
version = "0.1.0"
edition = "2024"
[lib]
name = "k_launcher_plugin_host"
path = "src/lib.rs"
[dependencies]
async-trait = { workspace = true }
k-launcher-kernel = { path = "../k-launcher-kernel" }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["process", "io-util", "sync"] }
tracing = { workspace = true }

View File

@@ -0,0 +1,201 @@
use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader, BufWriter};
use tokio::process::{ChildStdin, ChildStdout, Command};
use tokio::sync::Mutex;
// --- Protocol types ---
#[derive(Serialize)]
struct Query {
query: String,
}
#[derive(Deserialize)]
struct ExternalResult {
id: String,
title: String,
score: u32,
#[serde(default)]
description: Option<String>,
#[serde(default)]
icon: Option<String>,
action: ExternalAction,
}
#[derive(Deserialize)]
#[serde(tag = "type")]
enum ExternalAction {
SpawnProcess { cmd: String },
CopyToClipboard { text: String },
OpenPath { path: String },
}
// --- Process I/O handle ---
struct ProcessIo {
stdin: BufWriter<ChildStdin>,
stdout: BufReader<ChildStdout>,
}
async fn do_search(
io: &mut ProcessIo,
query: &str,
) -> Result<Vec<ExternalResult>, Box<dyn std::error::Error + Send + Sync>> {
let line = serde_json::to_string(&Query { query: query.to_string() })?;
io.stdin.write_all(line.as_bytes()).await?;
io.stdin.write_all(b"\n").await?;
io.stdin.flush().await?;
let mut response = String::new();
io.stdout.read_line(&mut response).await?;
Ok(serde_json::from_str(&response)?)
}
// --- ExternalPlugin ---
pub struct ExternalPlugin {
name: String,
path: String,
args: Vec<String>,
inner: Mutex<Option<ProcessIo>>,
}
impl ExternalPlugin {
pub fn new(name: impl Into<String>, path: impl Into<String>, args: Vec<String>) -> Self {
Self {
name: name.into(),
path: path.into(),
args,
inner: Mutex::new(None),
}
}
async fn spawn(&self) -> std::io::Result<ProcessIo> {
let mut child = Command::new(&self.path)
.args(&self.args)
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::piped())
.spawn()?;
let stdin = BufWriter::new(child.stdin.take().unwrap());
let stdout = BufReader::new(child.stdout.take().unwrap());
Ok(ProcessIo { stdin, stdout })
}
}
#[async_trait]
impl Plugin for ExternalPlugin {
fn name(&self) -> &str {
&self.name
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
let mut guard = self.inner.lock().await;
if guard.is_none() {
match self.spawn().await {
Ok(io) => *guard = Some(io),
Err(e) => {
tracing::warn!("failed to spawn plugin {}: {e}", self.name);
return vec![];
}
}
}
let result = match guard.as_mut() {
Some(io) => do_search(io, query).await,
None => unreachable!(),
};
match result {
Ok(results) => results
.into_iter()
.map(|r| SearchResult {
id: ResultId::new(r.id),
title: ResultTitle::new(r.title),
description: r.description,
icon: r.icon,
score: Score::new(r.score),
action: match r.action {
ExternalAction::SpawnProcess { cmd } => LaunchAction::SpawnProcess(cmd),
ExternalAction::CopyToClipboard { text } => {
LaunchAction::CopyToClipboard(text)
}
ExternalAction::OpenPath { path } => LaunchAction::OpenPath(path),
},
on_select: None,
})
.collect(),
Err(e) => {
tracing::warn!("plugin {} error: {e}", self.name);
*guard = None;
vec![]
}
}
}
}
// --- Tests ---
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn query_serializes_correctly() {
let q = Query { query: "firefox".to_string() };
assert_eq!(serde_json::to_string(&q).unwrap(), r#"{"query":"firefox"}"#);
}
#[test]
fn result_parses_spawn_action() {
let json = r#"[{"id":"1","title":"Firefox","score":80,"action":{"type":"SpawnProcess","cmd":"firefox"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert_eq!(results.len(), 1);
assert_eq!(results[0].id, "1");
assert_eq!(results[0].title, "Firefox");
assert_eq!(results[0].score, 80);
assert!(matches!(&results[0].action, ExternalAction::SpawnProcess { cmd } if cmd == "firefox"));
}
#[test]
fn result_parses_copy_action() {
let json = r#"[{"id":"c","title":"= 4","score":90,"action":{"type":"CopyToClipboard","text":"4"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(matches!(&results[0].action, ExternalAction::CopyToClipboard { text } if text == "4"));
}
#[test]
fn result_parses_open_path_action() {
let json = r#"[{"id":"f","title":"/home/user","score":50,"action":{"type":"OpenPath","path":"/home/user"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(matches!(&results[0].action, ExternalAction::OpenPath { path } if path == "/home/user"));
}
#[test]
fn result_parses_optional_fields() {
let json = r#"[{"id":"x","title":"X","score":10,"description":"desc","icon":"/icon.png","action":{"type":"SpawnProcess","cmd":"x"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert_eq!(results[0].description.as_deref(), Some("desc"));
assert_eq!(results[0].icon.as_deref(), Some("/icon.png"));
}
#[test]
fn result_parses_missing_optional_fields() {
let json = r#"[{"id":"x","title":"X","score":10,"action":{"type":"SpawnProcess","cmd":"x"}}]"#;
let results: Vec<ExternalResult> = serde_json::from_str(json).unwrap();
assert!(results[0].description.is_none());
assert!(results[0].icon.is_none());
}
#[test]
fn invalid_json_is_err() {
assert!(serde_json::from_str::<Vec<ExternalResult>>("not json").is_err());
}
// Unused import suppression for Arc (used only in production code path)
fn _assert_send_sync() {
fn check<T: Send + Sync>() {}
check::<ExternalPlugin>();
}
}

View File

@@ -20,6 +20,7 @@ egui = ["dep:k-launcher-ui-egui"]
iced = { workspace = true }
k-launcher-config = { path = "../k-launcher-config" }
k-launcher-kernel = { path = "../k-launcher-kernel" }
k-launcher-plugin-host = { path = "../k-launcher-plugin-host" }
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
k-launcher-ui = { path = "../k-launcher-ui" }
k-launcher-ui-egui = { path = "../k-launcher-ui-egui", optional = true }

View File

@@ -2,6 +2,7 @@ use std::sync::Arc;
use k_launcher_kernel::Kernel;
use k_launcher_os_bridge::UnixAppLauncher;
use k_launcher_plugin_host::ExternalPlugin;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
#[cfg(target_os = "linux")]
use plugin_apps::linux::FsDesktopEntrySource;
@@ -21,6 +22,9 @@ fn main() -> iced::Result {
if cfg.plugins.apps {
plugins.push(Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)));
}
for ext in &cfg.plugins.external {
plugins.push(Arc::new(ExternalPlugin::new(&ext.name, &ext.path, ext.args.clone())));
}
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> =
Arc::new(Kernel::new(plugins, cfg.search.max_results));

View File

@@ -5,7 +5,7 @@ pub mod linux;
use std::{collections::HashMap, sync::Arc};
use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use crate::frecency::FrecencyStore;
@@ -144,7 +144,7 @@ pub(crate) fn humanize_category(s: &str) -> String {
#[async_trait]
impl Plugin for AppsPlugin {
fn name(&self) -> PluginName {
fn name(&self) -> &str {
"apps"
}

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
pub struct CalcPlugin;
@@ -26,7 +26,7 @@ fn should_eval(query: &str) -> bool {
#[async_trait]
impl Plugin for CalcPlugin {
fn name(&self) -> PluginName {
fn name(&self) -> &str {
"calc"
}

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
pub struct CmdPlugin;
@@ -17,7 +17,7 @@ impl Default for CmdPlugin {
#[async_trait]
impl Plugin for CmdPlugin {
fn name(&self) -> PluginName {
fn name(&self) -> &str {
"cmd"
}

View File

@@ -3,7 +3,7 @@ mod platform;
use std::path::Path;
use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult};
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
pub struct FilesPlugin;
@@ -32,7 +32,7 @@ fn expand_query(query: &str) -> Option<String> {
#[async_trait]
impl Plugin for FilesPlugin {
fn name(&self) -> PluginName {
fn name(&self) -> &str {
"files"
}