diff --git a/Cargo.lock b/Cargo.lock index 4885237..978572e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2219,6 +2219,7 @@ dependencies = [ "k-launcher-config", "k-launcher-kernel", "k-launcher-os-bridge", + "k-launcher-plugin-host", "k-launcher-ui", "k-launcher-ui-egui", "plugin-apps", @@ -2256,6 +2257,18 @@ dependencies = [ "libc", ] +[[package]] +name = "k-launcher-plugin-host" +version = "0.1.0" +dependencies = [ + "async-trait", + "k-launcher-kernel", + "serde", + "serde_json", + "tokio", + "tracing", +] + [[package]] name = "k-launcher-ui" version = "0.1.0" @@ -2534,6 +2547,17 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "mio" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + [[package]] name = "moxcms" version = "0.7.11" @@ -4344,8 +4368,13 @@ version = "1.50.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "27ad5e34374e03cfffefc301becb44e9dc3c17584f414349ebe29ed26661822d" dependencies = [ + "bytes", + "libc", + "mio", "pin-project-lite", + "signal-hook-registry", "tokio-macros", + "windows-sys 0.61.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index bbf1455..76ae899 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = [ "crates/k-launcher-config", "crates/k-launcher-kernel", "crates/k-launcher-os-bridge", + "crates/k-launcher-plugin-host", "crates/k-launcher-ui", "crates/plugins/plugin-apps", "crates/plugins/plugin-calc", @@ -19,6 +20,7 @@ dirs = "5.0" futures = "0.3" iced = { version = "0.14", features = ["image", "svg", "tokio", "tiny-skia"] } serde = { version = "1.0", features = ["derive"] } +serde_json = "1" tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] } toml = "0.8" tracing = "0.1" diff --git a/README.md b/README.md index b659502..3fe2144 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,19 @@ cargo build --release | `>` prefix | Shell | `> echo hello` | | `/` or `~/` | Files | `~/Documents` | +## External Plugins + +Drop in community plugins — any language, no recompilation. Plugins are executables that communicate over stdin/stdout JSON: + +```toml +# ~/.config/k-launcher/config.toml +[[plugins.external]] +name = "my-plugin" +path = "/usr/lib/k-launcher/plugins/my-plugin" +``` + +See [Plugin Development](docs/plugin-development.md) for the full protocol. + ## Docs - [Installation](docs/install.md) diff --git a/crates/k-launcher-config/src/lib.rs b/crates/k-launcher-config/src/lib.rs index 1225dae..7432300 100644 --- a/crates/k-launcher-config/src/lib.rs +++ b/crates/k-launcher-config/src/lib.rs @@ -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, +} + #[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, } impl Default for PluginsCfg { @@ -92,6 +101,7 @@ impl Default for PluginsCfg { cmd: true, files: true, apps: true, + external: vec![], } } } diff --git a/crates/k-launcher-kernel/src/lib.rs b/crates/k-launcher-kernel/src/lib.rs index 0d48a24..76b6cde 100644 --- a/crates/k-launcher-kernel/src/lib.rs +++ b/crates/k-launcher-kernel/src/lib.rs @@ -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; } @@ -144,7 +142,7 @@ mod tests { #[async_trait] impl Plugin for MockPlugin { - fn name(&self) -> PluginName { + fn name(&self) -> &str { "mock" } diff --git a/crates/k-launcher-plugin-host/Cargo.toml b/crates/k-launcher-plugin-host/Cargo.toml new file mode 100644 index 0000000..d4cab18 --- /dev/null +++ b/crates/k-launcher-plugin-host/Cargo.toml @@ -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 } diff --git a/crates/k-launcher-plugin-host/src/lib.rs b/crates/k-launcher-plugin-host/src/lib.rs new file mode 100644 index 0000000..7849326 --- /dev/null +++ b/crates/k-launcher-plugin-host/src/lib.rs @@ -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, + #[serde(default)] + icon: Option, + 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, + stdout: BufReader, +} + +async fn do_search( + io: &mut ProcessIo, + query: &str, +) -> Result, Box> { + 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, + inner: Mutex>, +} + +impl ExternalPlugin { + pub fn new(name: impl Into, path: impl Into, args: Vec) -> Self { + Self { + name: name.into(), + path: path.into(), + args, + inner: Mutex::new(None), + } + } + + async fn spawn(&self) -> std::io::Result { + 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 { + 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 = 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 = 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 = 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 = 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 = 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::>("not json").is_err()); + } + + // Unused import suppression for Arc (used only in production code path) + fn _assert_send_sync() { + fn check() {} + check::(); + } +} diff --git a/crates/k-launcher/Cargo.toml b/crates/k-launcher/Cargo.toml index fa362f2..f963498 100644 --- a/crates/k-launcher/Cargo.toml +++ b/crates/k-launcher/Cargo.toml @@ -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 } diff --git a/crates/k-launcher/src/main.rs b/crates/k-launcher/src/main.rs index abad775..ccd38ad 100644 --- a/crates/k-launcher/src/main.rs +++ b/crates/k-launcher/src/main.rs @@ -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 = Arc::new(Kernel::new(plugins, cfg.search.max_results)); diff --git a/crates/plugins/plugin-apps/src/lib.rs b/crates/plugins/plugin-apps/src/lib.rs index 294236e..90e6984 100644 --- a/crates/plugins/plugin-apps/src/lib.rs +++ b/crates/plugins/plugin-apps/src/lib.rs @@ -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" } diff --git a/crates/plugins/plugin-calc/src/lib.rs b/crates/plugins/plugin-calc/src/lib.rs index 8430107..e5ab747 100644 --- a/crates/plugins/plugin-calc/src/lib.rs +++ b/crates/plugins/plugin-calc/src/lib.rs @@ -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" } diff --git a/crates/plugins/plugin-cmd/src/lib.rs b/crates/plugins/plugin-cmd/src/lib.rs index 8e2d286..7ba56c1 100644 --- a/crates/plugins/plugin-cmd/src/lib.rs +++ b/crates/plugins/plugin-cmd/src/lib.rs @@ -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" } diff --git a/crates/plugins/plugin-files/src/lib.rs b/crates/plugins/plugin-files/src/lib.rs index bbf93e8..447d73b 100644 --- a/crates/plugins/plugin-files/src/lib.rs +++ b/crates/plugins/plugin-files/src/lib.rs @@ -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 { #[async_trait] impl Plugin for FilesPlugin { - fn name(&self) -> PluginName { + fn name(&self) -> &str { "files" } diff --git a/docs/configuration.md b/docs/configuration.md index 0309167..fd0e488 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -34,6 +34,12 @@ calc = true # math expression evaluator cmd = true # shell command runner (> prefix) files = true # filesystem browser (/ or ~/ prefix) apps = true # XDG application launcher + +# External (dynamic) plugins — repeat block for each plugin +[[plugins.external]] +name = "my-plugin" # display name / identifier +path = "/path/to/my-plugin" # path to executable +args = [] # optional extra arguments ``` ## RGBA Format diff --git a/docs/plugin-development.md b/docs/plugin-development.md index dd2ef6d..0d6842f 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -1,10 +1,102 @@ # Plugin Development -Plugins are Rust crates that implement the `Plugin` trait from `k-launcher-kernel`. They run concurrently — the kernel fans out every query to all enabled plugins and merges results by score. +Plugins are queried concurrently — the kernel fans out every search to all enabled plugins and merges results by score. -> Note: plugins are compiled into the binary at build time. There is no dynamic loading support yet. +There are two kinds of plugins: -## Step-by-Step +- **External plugins** — executables that speak a JSON protocol over stdin/stdout. Any language, no compilation required. Recommended for community plugins. +- **Built-in plugins** — Rust crates compiled into the binary. For performance-critical or tightly integrated plugins. + +--- + +## External Plugins + +An external plugin is any executable that: + +1. Reads a JSON object from stdin (one line per query) +2. Writes a JSON array of results to stdout (one line per response) + +### Protocol + +**Input** (one line, newline-terminated): +```json +{"query": "firefox"} +``` + +**Output** (one line, newline-terminated): +```json +[{"id":"app-firefox","title":"Firefox","score":80,"description":"Web Browser","action":{"type":"SpawnProcess","cmd":"firefox"}}] +``` + +The process is kept alive between queries — do **not** exit after each response. + +### Action types + +| `"type"` | Extra fields | Behavior | +|----------|-------------|---------| +| `SpawnProcess` | `"cmd"` | Launch process directly | +| `CopyToClipboard` | `"text"` | Copy text to clipboard | +| `OpenPath` | `"path"` | Open file/dir with xdg-open | + +### Optional result fields + +| Field | Type | Description | +|-------|------|-------------| +| `description` | `string` | Secondary line shown below title | +| `icon` | `string` | Icon path (future use) | + +### Enabling an external plugin + +In `~/.config/k-launcher/config.toml`: + +```toml +[[plugins.external]] +name = "my-plugin" +path = "/usr/lib/k-launcher/plugins/my-plugin" +args = [] # optional +``` + +Multiple `[[plugins.external]]` blocks are supported. + +### Example: shell plugin + +```bash +#!/usr/bin/env bash +# A plugin that greets the user. +while IFS= read -r line; do + query=$(echo "$line" | python3 -c "import sys,json; print(json.load(sys.stdin)['query'])") + if [[ "$query" == hello* ]]; then + echo '[{"id":"greet","title":"Hello, World!","score":80,"action":{"type":"CopyToClipboard","text":"Hello, World!"}}]' + else + echo '[]' + fi +done +``` + +### Example: Python plugin + +```python +#!/usr/bin/env python3 +import sys, json + +for line in sys.stdin: + query = json.loads(line)["query"] + results = [] + if query.startswith("hello"): + results.append({ + "id": "greet", + "title": "Hello, World!", + "score": 80, + "action": {"type": "CopyToClipboard", "text": "Hello, World!"}, + }) + print(json.dumps(results), flush=True) +``` + +--- + +## Built-in Plugins (compiled-in) + +Built-in plugins implement the `Plugin` trait from `k-launcher-kernel` as Rust crates compiled into the binary. ### 1. Create a new crate in the workspace @@ -38,9 +130,7 @@ async-trait = "0.1" ```rust 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 HelloPlugin; @@ -52,7 +142,7 @@ impl HelloPlugin { #[async_trait] impl Plugin for HelloPlugin { - fn name(&self) -> PluginName { + fn name(&self) -> &str { "hello" }