Compare commits

...

19 Commits

Author SHA1 Message Date
2e773cdeaf style: format code for better readability in tests and function signatures
Some checks failed
CI / test (push) Failing after 4m59s
CI / clippy (push) Failing after 4m58s
CI / fmt (push) Successful in 23s
2026-03-18 13:59:53 +01:00
3d2bd5f9fe fix: update build_entries function signature to ignore frecency parameter
Some checks failed
CI / test (push) Failing after 5m6s
CI / clippy (push) Failing after 5m3s
CI / fmt (push) Failing after 23s
2026-03-18 13:48:02 +01:00
ff9b2b5712 fix(review): bugs, arch violations, design smells
P1 bugs:
- unix_launcher: shell_split respects quoted args (was split_whitespace)
- plugin-host: 5s timeout on external plugin search
- ui: handle engine init panic, wire error state
- ui-egui: read window config instead of always using defaults
- plugin-url: use OpenPath action instead of SpawnProcess+xdg-open

Architecture:
- remove WindowConfig (mirror of WindowCfg); use WindowCfg directly
- remove on_select closure from SearchResult (domain leakage)
- remove LaunchAction::Custom; add Plugin::on_selected + SearchEngine::on_selected
- apps: record frecency via on_selected instead of embedded closure

Design smells:
- frecency: extract decay_factor helper, write outside mutex
- apps: remove cfg(test) cache_path hack; add new_for_test ctor
- apps: stable ResultId using name+exec to prevent collision
- files: stable ResultId using full path instead of index
- plugin-host: remove k-launcher-os-bridge dep (WindowConfig gone)
2026-03-18 13:45:48 +01:00
38860762c0 Update iced dependency in Cargo.toml to disable default features and add additional ones
Some checks failed
CI / test (push) Failing after 5m6s
CI / clippy (push) Failing after 5m3s
CI / fmt (push) Failing after 30s
2026-03-18 13:09:33 +01:00
248094f442 feat(app): enhance engine initialization with EngineHandle and update run function signature 2026-03-18 13:05:14 +01:00
bd356f27d1 feat: production hardening (panic isolation, file logging, apps cache)
- Kernel::search wraps each plugin in catch_unwind; panics are logged and return []
- init_logging() adds daily rolling file at ~/.local/share/k-launcher/logs/
- AppsPlugin caches entries to ~/.cache/k-launcher/apps.bin via bincode; stale-while-revalidate on subsequent launches
- 57 tests pass
2026-03-18 12:59:24 +01:00
58d0739cea refactor: remove client module and associated show command logic
Some checks failed
CI / test (push) Failing after 5m7s
CI / clippy (push) Failing after 4m59s
CI / fmt (push) Successful in 28s
2026-03-15 23:49:31 +01:00
12f1f541ae fix(app): format code for clarity in update function
Some checks failed
CI / test (push) Failing after 5m5s
CI / clippy (push) Failing after 5m3s
CI / fmt (push) Successful in 28s
2026-03-15 23:31:50 +01:00
bee429192f chore: update .gitignore and enhance README with compositor setup instructions
Some checks failed
CI / test (push) Failing after 4m58s
CI / clippy (push) Failing after 5m3s
CI / fmt (push) Successful in 19s
2026-03-15 20:09:06 +01:00
86e843f666 chore(docs): remove unused screenshot file
Some checks failed
CI / test (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
2026-03-15 20:08:29 +01:00
71b8e46ae6 feature/prod-ready (#1)
Some checks failed
CI / test (push) Has been cancelled
CI / clippy (push) Has been cancelled
CI / fmt (push) Has been cancelled
Reviewed-on: #1
2026-03-15 19:03:30 +00:00
2e2351e084 fix(calc): remove ambiguous log alias, use ln/log2/log10 explicitly 2026-03-15 19:34:54 +01:00
b567414930 fix(calc): fix log/ln naming, cache math context, strengthen sin(pi) test 2026-03-15 19:32:46 +01:00
aeea3756c1 feat(calc): add math functions (sqrt, sin, cos, etc.) and pi/e constants 2026-03-15 19:30:01 +01:00
207c20f77d refactor(calc): rename preprocess, extend underscore test assertions 2026-03-15 19:24:15 +01:00
be7c2b6b59 feat(calc): strip underscore digit separators 2026-03-15 19:21:43 +01:00
bf065ffdf0 feat: update dependencies for improved compatibility and performance 2026-03-15 19:14:50 +01:00
4283460c82 feat: add plugin-url for URL handling and open in browser functionality 2026-03-15 19:08:38 +01:00
d1479f41d2 feat: add support for external plugins and enhance plugin management 2026-03-15 18:54:55 +01:00
40 changed files with 2169 additions and 1293 deletions

39
.github/workflows/ci.yml vendored Normal file
View File

@@ -0,0 +1,39 @@
name: CI
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install system deps
run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config
- run: cargo test --workspace
clippy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: clippy
- uses: Swatinem/rust-cache@v2
- name: Install system deps
run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config
- run: cargo clippy --workspace -- -D warnings
fmt:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
with:
components: rustfmt
- run: cargo fmt --check

21
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: Release
on:
push:
tags:
- 'v*'
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: dtolnay/rust-toolchain@stable
- uses: Swatinem/rust-cache@v2
- name: Install system deps
run: sudo apt-get install -y libwayland-dev libxkbcommon-dev pkg-config
- run: cargo build --release
- uses: actions/upload-artifact@v4
with:
name: k-launcher
path: target/release/k-launcher

1
.gitignore vendored
View File

@@ -1 +1,2 @@
target/ target/
.worktrees/

110
ARCHITECTURE.md Normal file
View File

@@ -0,0 +1,110 @@
# k-launcher Architecture
## Philosophy
- **TDD:** Red-Green-Refactor is mandatory. No functional code without a failing test first.
- **Clean Architecture:** Strict layer separation — Domain, Application, Infrastructure, Main.
- **Newtype Pattern:** All domain primitives wrapped (e.g. `struct Score(f64)`).
- **Small Traits / ISP:** Many focused traits over one "God" trait.
- **No Cyclic Dependencies:** Use IoC (define traits in higher-level modules, implement in lower-level).
---
## Workspace Structure
| Crate | Layer | Responsibility |
|---|---|---|
| `k-launcher-kernel` | Domain + Application | Newtypes (`ResultId`, `ResultTitle`, `Score`), `Plugin` trait, `SearchEngine` trait, `AppLauncher` port, `Kernel` use case |
| `k-launcher-config` | Infrastructure | TOML config loading; `Config`, `WindowCfg`, `AppearanceCfg`, `PluginsCfg` structs |
| `k-launcher-os-bridge` | Infrastructure | `UnixAppLauncher` (process spawning), `WindowConfig` adapter |
| `k-launcher-plugin-host` | Infrastructure | `ExternalPlugin` — JSON-newline IPC protocol for out-of-process plugins |
| `k-launcher-ui` | Infrastructure | iced 0.14 Elm-like UI (`KLauncherApp`, debounced async search, keyboard nav) |
| `k-launcher-ui-egui` | Infrastructure | Alternative egui UI (feature-gated) |
| `plugins/plugin-apps` | Infrastructure | XDG `.desktop` parser, frecency scoring, nucleo fuzzy matching |
| `plugins/plugin-calc` | Infrastructure | `evalexpr`-based calculator |
| `plugins/plugin-cmd` | Infrastructure | Shell command runner |
| `plugins/plugin-files` | Infrastructure | File path search |
| `plugins/plugin-url` | Infrastructure | URL opener |
| `k-launcher` | Main/Entry | DI wiring, CLI arg parsing (`show` command), `run_ui()` composition root |
---
## Dependency Graph
```
k-launcher (main)
├── k-launcher-kernel (Domain/Application)
├── k-launcher-config (Infrastructure — pure data, no kernel dep)
├── k-launcher-os-bridge (Infrastructure)
├── k-launcher-plugin-host (Infrastructure)
├── k-launcher-ui (Infrastructure)
└── plugins/* (Infrastructure)
└── k-launcher-kernel
```
All arrows point inward toward the kernel. The kernel has no external dependencies.
---
## Core Abstractions (kernel)
```rust
// Plugin trait — implemented by every plugin
async fn search(&self, query: &str) -> Vec<SearchResult>;
// SearchEngine trait — implemented by Kernel
async fn search(&self, query: &str) -> Vec<SearchResult>;
// AppLauncher port — implemented by UnixAppLauncher in os-bridge
fn execute(&self, action: &LaunchAction);
// DesktopEntrySource trait (plugin-apps) — swappable .desktop file source
```
---
## Plugin System
Two kinds of plugins:
1. **In-process** — implement `Plugin` in Rust, linked at compile time.
- `plugin-calc`, `plugin-apps`, `plugin-cmd`, `plugin-files`, `plugin-url`
2. **External / out-of-process**`ExternalPlugin` in `k-launcher-plugin-host` communicates via JSON newline protocol over stdin/stdout.
- Query: `{"query": "..."}`
- Response: `[{"id": "...", "title": "...", "score": 1.0, "description": "...", "icon": "...", "action": "..."}]`
Plugins are enabled/disabled via `~/.config/k-launcher/config.toml`.
---
## Kernel (Application Use Case)
`Kernel::search` fans out to all registered plugins concurrently via `join_all`, merges results, sorts by `Score` descending, truncates to `max_results`.
---
## UI Architecture (iced 0.14 — Elm model)
- **State:** `KLauncherApp` holds engine ref, launcher ref, query string, results, selected index, appearance config.
- **Messages:** `QueryChanged`, `ResultsReady`, `KeyPressed`
- **Update:**
- `QueryChanged` → spawns debounced async task (50 ms) → `ResultsReady`
- Epoch guard prevents stale results from out-of-order responses
- **View:** search bar + scrollable result list with icon support (SVG/raster)
- **Subscription:** keyboard events — `Esc` = quit, `Enter` = launch, arrows = navigate
- **Window:** transparent, undecorated, centered (Wayland-compatible)
---
## Frecency (plugin-apps)
`FrecencyStore` records app launches by ID. On empty query, returns top-5 frecent apps instead of search results.
---
## Configuration
`~/.config/k-launcher/config.toml` — sections: `[window]`, `[appearance]`, `[search]`, `[plugins]`.
All fields have sane defaults; a missing file yields defaults without error.

58
CLAUDE.md Normal file
View File

@@ -0,0 +1,58 @@
## 1. Core Philosophy
- **Test-Driven Development (TDD):** No functional code is written without a failing test first. Red-Green-Refactor is the mandatory cycle.
- **Clean Architecture:** Maintain strict separation between Domain, Application, and Infrastructure layers.
- **Newtype Pattern:** Use the "Newtype" pattern for all domain primitives (e.g., `struct UserId(Uuid)`) to ensure type safety and prevent primitive obsession.
---
## 2. Structural Rules
### Dependency Management
- **Strict Unidirectionality:** Dependencies must only point inwards (towards the Domain).
- **No Cyclic Dependencies:** Use traits and Dependency Injection (DI) to break cycles. If two modules need each other, abstract the shared behavior into a trait or move common data to a lower-level module.
- **Feature Gating:** Organize the project into logical crates or modules that can be compiled independently.
### Traits and Decoupling
- **Swappable Infrastructure:** Define all external interactions (Database, API, File System) as traits in the Application layer.
- **Small Traits:** Adhere to the Interface Segregation Principle. Favor many specific traits over one "God" trait.
- **Mocking:** Use traits to allow easy mocking in unit tests without requiring a real database or network.
---
## 3. Rust Specifics & Clean Code
### Type Safety
- Avoid `String` or `i32` for domain concepts. Wrap them in structs.
- Use `Result` and `Option` explicitly. Minimize `unwrap()` and `expect()`—handle errors gracefully at the boundaries.
### Formatting & Style
- Follow standard `rustfmt` and `clippy` suggestions.
- Function names should be descriptive (e.g., `process_valid_order` instead of `handle_data`).
- Keep functions small (typically under 20-30 lines).
---
## 4. Layer Definitions
| Layer | Responsibility | Allowed Dependencies |
| ------------------ | --------------------------------------------- | -------------------- |
| **Domain** | Pure Business Logic, Entities, Value Objects. | None (Pure Rust) |
| **Application** | Use Cases, Orchestration, Trait definitions. | Domain |
| **Infrastructure** | Trait implementations (DB, HTTP clients). | Domain, Application |
| **Main/API** | Entry point, Wire-up/DI, Routing. | All of the above |
---
## 5. TDD Workflow Requirement
1. **Write a Test:** Create a test in `src/lib.rs` or a `tests/` directory.
2. **Define the Interface:** Use a trait or function signature to make the test compile (but fail).
3. **Minimum Implementation:** Write just enough code to pass the test.
4. **Refactor:** Clean up the logic, ensure no duplication, and check for "Newtype" opportunities.
> **Note on Cyclic Dependencies:** If an AI agent suggests a change that introduces a cycle, it must be rejected. Use **Inversion of Control** by defining a trait in the higher-level module that the lower-level module implements.

1505
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -4,21 +4,38 @@ members = [
"crates/k-launcher-config", "crates/k-launcher-config",
"crates/k-launcher-kernel", "crates/k-launcher-kernel",
"crates/k-launcher-os-bridge", "crates/k-launcher-os-bridge",
"crates/k-launcher-plugin-host",
"crates/k-launcher-ui", "crates/k-launcher-ui",
"crates/plugins/plugin-apps", "crates/plugins/plugin-apps",
"crates/plugins/plugin-calc", "crates/plugins/plugin-calc",
"crates/plugins/plugin-cmd", "crates/plugins/plugin-cmd",
"crates/plugins/plugin-files", "crates/plugins/plugin-files",
"crates/k-launcher-ui-egui", "crates/k-launcher-ui-egui",
"crates/plugins/plugin-url",
]
default-members = [
"crates/k-launcher",
"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",
"crates/plugins/plugin-cmd",
"crates/plugins/plugin-files",
"crates/plugins/plugin-url",
] ]
resolver = "2" resolver = "2"
[workspace.dependencies] [workspace.dependencies]
async-trait = "0.1" async-trait = "0.1"
dirs = "5.0" bincode = { version = "2", features = ["serde"] }
dirs = "6.0"
futures = "0.3" futures = "0.3"
iced = { version = "0.14", features = ["image", "svg", "tokio", "tiny-skia"] } iced = { version = "0.14", default-features = false, features = ["image", "svg", "tokio", "tiny-skia", "wayland", "x11", "crisp", "web-colors", "thread-pool"] }
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0", features = ["derive"] }
serde_json = "1"
tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] } tokio = { version = "1.35", features = ["rt-multi-thread", "macros"] }
toml = "0.8" toml = "1.0"
tracing = "0.1" tracing = "0.1"

View File

@@ -2,10 +2,6 @@
A lightweight, GPU-accelerated command palette for Linux (Wayland/X11). Zero Electron — every pixel rendered via WGPU. Async search that never blocks the UI. A lightweight, GPU-accelerated command palette for Linux (Wayland/X11). Zero Electron — every pixel rendered via WGPU. Async search that never blocks the UI.
```
[screenshot placeholder]
```
## Quick Start ## Quick Start
```bash ```bash
@@ -24,6 +20,25 @@ cargo build --release
| `Enter` | Launch selected | | `Enter` | Launch selected |
| `Escape` | Close | | `Escape` | Close |
## Compositor Setup
k-launcher uses a normal window; configure your compositor to float it.
**Hyprland** (`~/.config/hypr/hyprland.conf`):
```
windowrule = float, ^(k-launcher)$
windowrule = center, ^(k-launcher)$
bind = SUPER, Space, exec, k-launcher
```
**Sway** (`~/.config/sway/config`):
```
for_window [app_id="k-launcher"] floating enable, move position center
bindsym Mod4+space exec k-launcher
```
## Built-in Plugins ## Built-in Plugins
| Trigger | Plugin | Example | | Trigger | Plugin | Example |
@@ -33,6 +48,19 @@ cargo build --release
| `>` prefix | Shell | `> echo hello` | | `>` prefix | Shell | `> echo hello` |
| `/` or `~/` | Files | `~/Documents` | | `/` 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 ## Docs
- [Installation](docs/install.md) - [Installation](docs/install.md)

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)] #[derive(Debug, Clone, Deserialize)]
#[serde(default)] #[serde(default)]
pub struct PluginsCfg { pub struct PluginsCfg {
@@ -83,6 +91,7 @@ pub struct PluginsCfg {
pub cmd: bool, pub cmd: bool,
pub files: bool, pub files: bool,
pub apps: bool, pub apps: bool,
pub external: Vec<ExternalPluginCfg>,
} }
impl Default for PluginsCfg { impl Default for PluginsCfg {
@@ -92,13 +101,13 @@ impl Default for PluginsCfg {
cmd: true, cmd: true,
files: true, files: true,
apps: true, apps: true,
external: vec![],
} }
} }
} }
pub fn load() -> Config { pub fn load() -> Config {
let path = dirs::config_dir() let path = dirs::config_dir().map(|d| d.join("k-launcher").join("config.toml"));
.map(|d| d.join("k-launcher").join("config.toml"));
let Some(path) = path else { let Some(path) = path else {
return Config::default(); return Config::default();
}; };

View File

@@ -8,3 +8,4 @@ async-trait = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
serde = { workspace = true } serde = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true }

View File

@@ -3,8 +3,6 @@ use std::sync::Arc;
use async_trait::async_trait; use async_trait::async_trait;
use futures::future::join_all; use futures::future::join_all;
pub type PluginName = &'static str;
// --- Newtypes --- // --- Newtypes ---
#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
@@ -31,7 +29,9 @@ impl ResultTitle {
} }
} }
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize)] #[derive(
Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize,
)]
pub struct Score(u32); pub struct Score(u32);
impl Score { impl Score {
@@ -50,7 +50,6 @@ pub enum LaunchAction {
SpawnInTerminal(String), SpawnInTerminal(String),
OpenPath(String), OpenPath(String),
CopyToClipboard(String), CopyToClipboard(String),
Custom(Arc<dyn Fn() + Send + Sync>),
} }
// --- AppLauncher port trait --- // --- AppLauncher port trait ---
@@ -68,7 +67,6 @@ pub struct SearchResult {
pub icon: Option<String>, pub icon: Option<String>,
pub score: Score, pub score: Score,
pub action: LaunchAction, pub action: LaunchAction,
pub on_select: Option<Arc<dyn Fn() + Send + Sync>>,
} }
impl std::fmt::Debug for SearchResult { impl std::fmt::Debug for SearchResult {
@@ -86,8 +84,9 @@ impl std::fmt::Debug for SearchResult {
#[async_trait] #[async_trait]
pub trait Plugin: Send + Sync { pub trait Plugin: Send + Sync {
fn name(&self) -> PluginName; fn name(&self) -> &str;
async fn search(&self, query: &str) -> Vec<SearchResult>; async fn search(&self, query: &str) -> Vec<SearchResult>;
fn on_selected(&self, _id: &ResultId) {}
} }
// --- SearchEngine port trait --- // --- SearchEngine port trait ---
@@ -95,6 +94,19 @@ pub trait Plugin: Send + Sync {
#[async_trait] #[async_trait]
pub trait SearchEngine: Send + Sync { pub trait SearchEngine: Send + Sync {
async fn search(&self, query: &str) -> Vec<SearchResult>; async fn search(&self, query: &str) -> Vec<SearchResult>;
fn on_selected(&self, id: &ResultId);
}
// --- NullSearchEngine ---
pub struct NullSearchEngine;
#[async_trait]
impl SearchEngine for NullSearchEngine {
async fn search(&self, _query: &str) -> Vec<SearchResult> {
vec![]
}
fn on_selected(&self, _id: &ResultId) {}
} }
// --- Kernel (Application use case) --- // --- Kernel (Application use case) ---
@@ -106,13 +118,38 @@ pub struct Kernel {
impl Kernel { impl Kernel {
pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self { pub fn new(plugins: Vec<Arc<dyn Plugin>>, max_results: usize) -> Self {
Self { plugins, max_results } Self {
plugins,
max_results,
}
}
pub fn on_selected(&self, id: &ResultId) {
for plugin in &self.plugins {
plugin.on_selected(id);
}
} }
pub async fn search(&self, query: &str) -> Vec<SearchResult> { pub async fn search(&self, query: &str) -> Vec<SearchResult> {
let futures = self.plugins.iter().map(|p| p.search(query)); use futures::FutureExt;
let nested: Vec<Vec<SearchResult>> = join_all(futures).await; use std::panic::AssertUnwindSafe;
let mut flat: Vec<SearchResult> = nested.into_iter().flatten().collect();
let futures = self
.plugins
.iter()
.map(|p| AssertUnwindSafe(p.search(query)).catch_unwind());
let outcomes = join_all(futures).await;
let mut flat: Vec<SearchResult> = outcomes
.into_iter()
.zip(self.plugins.iter())
.flat_map(|(outcome, plugin)| match outcome {
Ok(results) => results,
Err(_) => {
tracing::error!(plugin = plugin.name(), "plugin panicked during search");
vec![]
}
})
.collect();
flat.sort_by(|a, b| b.score.cmp(&a.score)); flat.sort_by(|a, b| b.score.cmp(&a.score));
flat.truncate(self.max_results); flat.truncate(self.max_results);
flat flat
@@ -124,6 +161,9 @@ impl SearchEngine for Kernel {
async fn search(&self, query: &str) -> Vec<SearchResult> { async fn search(&self, query: &str) -> Vec<SearchResult> {
self.search(query).await self.search(query).await
} }
fn on_selected(&self, id: &ResultId) {
self.on_selected(id);
}
} }
// --- Tests --- // --- Tests ---
@@ -144,7 +184,7 @@ mod tests {
#[async_trait] #[async_trait]
impl Plugin for MockPlugin { impl Plugin for MockPlugin {
fn name(&self) -> PluginName { fn name(&self) -> &str {
"mock" "mock"
} }
@@ -158,8 +198,7 @@ mod tests {
description: None, description: None,
icon: None, icon: None,
score: Score::new(*score), score: Score::new(*score),
action: LaunchAction::Custom(Arc::new(|| {})), action: LaunchAction::SpawnProcess("mock".to_string()),
on_select: None,
}) })
.collect() .collect()
} }
@@ -200,6 +239,29 @@ mod tests {
assert_eq!(results[2].score.value(), 5); assert_eq!(results[2].score.value(), 5);
} }
struct PanicPlugin;
#[async_trait]
impl Plugin for PanicPlugin {
fn name(&self) -> &str {
"panic-plugin"
}
async fn search(&self, _query: &str) -> Vec<SearchResult> {
panic!("test panic");
}
}
#[tokio::test]
async fn kernel_continues_after_plugin_panic() {
let panic_plugin = Arc::new(PanicPlugin);
let normal_plugin = Arc::new(MockPlugin::returns(vec![("survivor", 5)]));
let k = Kernel::new(vec![panic_plugin, normal_plugin], 8);
let results = k.search("q").await;
assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_str(), "survivor");
}
#[tokio::test] #[tokio::test]
async fn kernel_truncates_at_max_results() { async fn kernel_truncates_at_max_results() {
let plugin = Arc::new(MockPlugin::returns(vec![ let plugin = Arc::new(MockPlugin::returns(vec![

View File

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

View File

@@ -1,23 +1,2 @@
mod unix_launcher; mod unix_launcher;
pub use unix_launcher::UnixAppLauncher; pub use unix_launcher::UnixAppLauncher;
pub struct WindowConfig {
pub width: f32,
pub height: f32,
pub decorations: bool,
pub transparent: bool,
pub resizable: bool,
}
impl WindowConfig {
pub fn from_cfg(w: &k_launcher_config::WindowCfg) -> Self {
Self {
width: w.width,
height: w.height,
decorations: w.decorations,
transparent: w.transparent,
resizable: w.resizable,
}
}
}

View File

@@ -1,8 +1,31 @@
use std::process::{Command, Stdio};
use std::os::unix::process::CommandExt; use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use k_launcher_kernel::{AppLauncher, LaunchAction}; use k_launcher_kernel::{AppLauncher, LaunchAction};
fn shell_split(cmd: &str) -> Vec<String> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut in_quotes = false;
for ch in cmd.chars() {
match ch {
'"' => in_quotes = !in_quotes,
' ' | '\t' if !in_quotes => {
if !current.is_empty() {
tokens.push(current.clone());
current.clear();
}
}
_ => current.push(ch),
}
}
if !current.is_empty() {
tokens.push(current);
}
tokens
}
fn parse_term_cmd(s: &str) -> (String, Vec<String>) { fn parse_term_cmd(s: &str) -> (String, Vec<String>) {
let mut parts = s.split_whitespace(); let mut parts = s.split_whitespace();
let bin = parts.next().unwrap_or("").to_string(); let bin = parts.next().unwrap_or("").to_string();
@@ -69,7 +92,7 @@ impl AppLauncher for UnixAppLauncher {
fn execute(&self, action: &LaunchAction) { fn execute(&self, action: &LaunchAction) {
match action { match action {
LaunchAction::SpawnProcess(cmd) => { LaunchAction::SpawnProcess(cmd) => {
let parts: Vec<&str> = cmd.split_whitespace().collect(); let parts = shell_split(cmd);
if let Some((bin, args)) = parts.split_first() { if let Some((bin, args)) = parts.split_first() {
let _ = unsafe { let _ = unsafe {
Command::new(bin) Command::new(bin)
@@ -77,21 +100,31 @@ impl AppLauncher for UnixAppLauncher {
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.pre_exec(|| { libc::setsid(); Ok(()) }) .pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn() .spawn()
}; };
} }
} }
LaunchAction::SpawnInTerminal(cmd) => { LaunchAction::SpawnInTerminal(cmd) => {
let Some((term_bin, term_args)) = resolve_terminal() else { return }; let Some((term_bin, term_args)) = resolve_terminal() else {
return;
};
let _ = unsafe { let _ = unsafe {
Command::new(&term_bin) Command::new(&term_bin)
.args(&term_args) .args(&term_args)
.arg("sh").arg("-c").arg(cmd) .arg("sh")
.arg("-c")
.arg(cmd)
.stdin(Stdio::null()) .stdin(Stdio::null())
.stdout(Stdio::null()) .stdout(Stdio::null())
.stderr(Stdio::null()) .stderr(Stdio::null())
.pre_exec(|| { libc::setsid(); Ok(()) }) .pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn() .spawn()
}; };
} }
@@ -111,7 +144,47 @@ impl AppLauncher for UnixAppLauncher {
} }
} }
} }
LaunchAction::Custom(f) => f(),
} }
} }
} }
#[cfg(test)]
mod tests {
use super::shell_split;
#[test]
fn split_simple() {
assert_eq!(shell_split("firefox"), vec!["firefox"]);
}
#[test]
fn split_with_args() {
assert_eq!(
shell_split("firefox --new-window"),
vec!["firefox", "--new-window"]
);
}
#[test]
fn split_quoted_path() {
assert_eq!(shell_split(r#""My App" --flag"#), vec!["My App", "--flag"]);
}
#[test]
fn split_quoted_with_spaces() {
assert_eq!(
shell_split(r#"env "FOO BAR" baz"#),
vec!["env", "FOO BAR", "baz"]
);
}
#[test]
fn split_empty() {
assert!(shell_split("").is_empty());
}
#[test]
fn split_extra_whitespace() {
assert_eq!(shell_split(" a b "), vec!["a", "b"]);
}
}

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", "time"] }
tracing = { workspace = true }

View File

@@ -0,0 +1,218 @@
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) => {
tokio::time::timeout(std::time::Duration::from_secs(5), do_search(io, query))
.await
.unwrap_or_else(|_| {
tracing::warn!("plugin {} search timed out", self.name);
Err("timeout".into())
})
}
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),
},
})
.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

@@ -12,5 +12,4 @@ eframe = { version = "0.31", default-features = false, features = ["default_font
egui = "0.31" egui = "0.31"
k-launcher-config = { path = "../k-launcher-config" } k-launcher-config = { path = "../k-launcher-config" }
k-launcher-kernel = { path = "../k-launcher-kernel" } k-launcher-kernel = { path = "../k-launcher-kernel" }
k-launcher-os-bridge = { path = "../k-launcher-os-bridge" }
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -2,7 +2,6 @@ use std::sync::{Arc, mpsc};
use egui::{Color32, Key, ViewportCommand}; use egui::{Color32, Key, ViewportCommand};
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult}; use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult};
use k_launcher_os_bridge::WindowConfig;
const BG: Color32 = Color32::from_rgba_premultiplied(20, 20, 30, 230); const BG: Color32 = Color32::from_rgba_premultiplied(20, 20, 30, 230);
const BORDER_COLOR: Color32 = Color32::from_rgb(229, 125, 33); const BORDER_COLOR: Color32 = Color32::from_rgb(229, 125, 33);
@@ -83,9 +82,7 @@ impl eframe::App for KLauncherApp {
if launch_selected { if launch_selected {
if let Some(result) = self.results.get(self.selected) { if let Some(result) = self.results.get(self.selected) {
if let Some(on_select) = &result.on_select { self.engine.on_selected(&result.id);
on_select();
}
self.launcher.execute(&result.action); self.launcher.execute(&result.action);
} }
ctx.send_viewport_cmd(ViewportCommand::Close); ctx.send_viewport_cmd(ViewportCommand::Close);
@@ -127,11 +124,20 @@ impl eframe::App for KLauncherApp {
ui.set_width(ui.available_width()); ui.set_width(ui.available_width());
for (i, result) in self.results.iter().enumerate() { for (i, result) in self.results.iter().enumerate() {
let is_selected = i == self.selected; let is_selected = i == self.selected;
let bg = if is_selected { SELECTED_BG } else { Color32::TRANSPARENT }; let bg = if is_selected {
SELECTED_BG
} else {
Color32::TRANSPARENT
};
let row_frame = egui::Frame::new() let row_frame = egui::Frame::new()
.fill(bg) .fill(bg)
.inner_margin(egui::Margin { left: 8, right: 8, top: 6, bottom: 6 }) .inner_margin(egui::Margin {
left: 8,
right: 8,
top: 6,
bottom: 6,
})
.corner_radius(egui::CornerRadius::same(4)); .corner_radius(egui::CornerRadius::same(4));
row_frame.show(ui, |ui| { row_frame.show(ui, |ui| {
@@ -157,17 +163,17 @@ impl eframe::App for KLauncherApp {
pub fn run( pub fn run(
engine: Arc<dyn SearchEngine>, engine: Arc<dyn SearchEngine>,
launcher: Arc<dyn AppLauncher>, launcher: Arc<dyn AppLauncher>,
window_cfg: &k_launcher_config::WindowCfg,
) -> Result<(), eframe::Error> { ) -> Result<(), eframe::Error> {
let wc = WindowConfig::from_cfg(&k_launcher_config::WindowCfg::default());
let rt = tokio::runtime::Runtime::new().expect("tokio runtime"); let rt = tokio::runtime::Runtime::new().expect("tokio runtime");
let handle = rt.handle().clone(); let handle = rt.handle().clone();
let options = eframe::NativeOptions { let options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_inner_size([wc.width, wc.height]) .with_inner_size([window_cfg.width, window_cfg.height])
.with_decorations(wc.decorations) .with_decorations(window_cfg.decorations)
.with_transparent(wc.transparent) .with_transparent(window_cfg.transparent)
.with_resizable(wc.resizable) .with_resizable(window_cfg.resizable)
.with_always_on_top(), .with_always_on_top(),
..Default::default() ..Default::default()
}; };

View File

@@ -7,6 +7,7 @@ use k_launcher_kernel::{AppLauncher, SearchEngine};
pub fn run( pub fn run(
engine: Arc<dyn SearchEngine>, engine: Arc<dyn SearchEngine>,
launcher: Arc<dyn AppLauncher>, launcher: Arc<dyn AppLauncher>,
window_cfg: &k_launcher_config::WindowCfg,
) -> Result<(), eframe::Error> { ) -> Result<(), eframe::Error> {
app::run(engine, launcher) app::run(engine, launcher, window_cfg)
} }

View File

@@ -1,20 +1,27 @@
use std::sync::Arc; use std::sync::Arc;
use iced::{ use iced::{
Border, Color, Element, Length, Size, Subscription, Task, Border, Color, Element, Length, Size, Subscription, Task, event,
event,
keyboard::{Event as KeyEvent, Key, key::Named}, keyboard::{Event as KeyEvent, Key, key::Named},
widget::{column, container, image, row, scrollable, svg, text, text_input, Space}, widget::{Space, column, container, image, row, scrollable, svg, text, text_input},
window, window,
}; };
use k_launcher_config::AppearanceCfg; use k_launcher_config::AppearanceCfg;
use k_launcher_kernel::{AppLauncher, SearchEngine, SearchResult}; use k_launcher_kernel::{AppLauncher, NullSearchEngine, SearchEngine, SearchResult};
use k_launcher_os_bridge::WindowConfig;
static INPUT_ID: std::sync::LazyLock<iced::widget::Id> = 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"));
#[derive(Clone)]
pub(crate) struct EngineHandle(Arc<dyn SearchEngine>);
impl std::fmt::Debug for EngineHandle {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("EngineHandle")
}
}
fn rgba(c: &[f32; 4]) -> Color { fn rgba(c: &[f32; 4]) -> Color {
Color::from_rgba8(c[0] as u8, c[1] as u8, c[2] as u8, c[3]) Color::from_rgba8(c[0] as u8, c[1] as u8, c[2] as u8, c[3])
} }
@@ -26,6 +33,8 @@ pub struct KLauncherApp {
results: Arc<Vec<SearchResult>>, results: Arc<Vec<SearchResult>>,
selected: usize, selected: usize,
cfg: AppearanceCfg, cfg: AppearanceCfg,
error: Option<String>,
search_epoch: u64,
} }
impl KLauncherApp { impl KLauncherApp {
@@ -41,6 +50,8 @@ impl KLauncherApp {
results: Arc::new(vec![]), results: Arc::new(vec![]),
selected: 0, selected: 0,
cfg, cfg,
error: None,
search_epoch: 0,
} }
} }
} }
@@ -48,23 +59,45 @@ impl KLauncherApp {
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub enum Message { pub enum Message {
QueryChanged(String), QueryChanged(String),
ResultsReady(Arc<Vec<SearchResult>>), ResultsReady(u64, Arc<Vec<SearchResult>>),
KeyPressed(KeyEvent), KeyPressed(KeyEvent),
EngineReady(EngineHandle),
EngineInitFailed(String),
} }
fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> { fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
match message { match message {
Message::QueryChanged(q) => { Message::QueryChanged(q) => {
state.error = None;
state.query = q.clone(); state.query = q.clone();
state.selected = 0; state.selected = 0;
state.search_epoch += 1;
let epoch = state.search_epoch;
let engine = state.engine.clone(); let engine = state.engine.clone();
Task::perform( Task::perform(
async move { engine.search(&q).await }, async move {
|results| Message::ResultsReady(Arc::new(results)), tokio::time::sleep(std::time::Duration::from_millis(50)).await;
(epoch, engine.search(&q).await)
},
|(epoch, results)| Message::ResultsReady(epoch, Arc::new(results)),
) )
} }
Message::ResultsReady(results) => { Message::ResultsReady(epoch, results) => {
state.results = results; if epoch == state.search_epoch {
state.results = results;
}
Task::none()
}
Message::EngineInitFailed(msg) => {
state.error = Some(msg);
Task::none()
}
Message::EngineReady(handle) => {
state.engine = handle.0;
if !state.query.is_empty() {
let q = state.query.clone();
return Task::done(Message::QueryChanged(q));
}
Task::none() Task::none()
} }
Message::KeyPressed(event) => { Message::KeyPressed(event) => {
@@ -77,7 +110,9 @@ fn update(state: &mut KLauncherApp, message: Message) -> Task<Message> {
}; };
let len = state.results.len(); let len = state.results.len();
match named { match named {
Named::Escape => std::process::exit(0), Named::Escape => {
std::process::exit(0);
}
Named::ArrowDown => { Named::ArrowDown => {
if len > 0 { if len > 0 {
state.selected = (state.selected + 1).min(len - 1); state.selected = (state.selected + 1).min(len - 1);
@@ -90,9 +125,7 @@ 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) {
if let Some(on_select) = &result.on_select { state.engine.on_selected(&result.id);
on_select();
}
state.launcher.execute(&result.action); state.launcher.execute(&result.action);
} }
std::process::exit(0); std::process::exit(0);
@@ -114,8 +147,13 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
.padding(12) .padding(12)
.size(cfg.search_font_size) .size(cfg.search_font_size)
.style(|theme, _status| { .style(|theme, _status| {
let mut s = iced::widget::text_input::default(theme, iced::widget::text_input::Status::Active); let mut s =
s.border = Border { color: Color::TRANSPARENT, width: 0.0, radius: 0.0.into() }; iced::widget::text_input::default(theme, iced::widget::text_input::Status::Active);
s.border = Border {
color: Color::TRANSPARENT,
width: 0.0,
radius: 0.0.into(),
};
s s
}); });
@@ -135,26 +173,27 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
Color::from_rgba8(255, 255, 255, 0.07) Color::from_rgba8(255, 255, 255, 0.07)
}; };
let icon_el: Element<'_, Message> = match &result.icon { let icon_el: Element<'_, Message> = match &result.icon {
Some(p) if p.ends_with(".svg") => Some(p) if p.ends_with(".svg") => {
svg(svg::Handle::from_path(p)).width(24).height(24).into(), svg(svg::Handle::from_path(p)).width(24).height(24).into()
Some(p) => }
image(image::Handle::from_path(p)).width(24).height(24).into(), Some(p) => image(image::Handle::from_path(p))
.width(24)
.height(24)
.into(),
None => Space::new().width(24).height(24).into(), None => Space::new().width(24).height(24).into(),
}; };
let title_col: Element<'_, Message> = if let Some(desc) = &result.description { let title_col: Element<'_, Message> = if let Some(desc) = &result.description {
column![ column![
text(result.title.as_str()).size(title_size), text(result.title.as_str()).size(title_size),
text(desc).size(desc_size).color(Color::from_rgba8(210, 215, 230, 1.0)), text(desc)
.size(desc_size)
.color(Color::from_rgba8(210, 215, 230, 1.0)),
] ]
.into() .into()
} else { } else {
text(result.title.as_str()).size(title_size).into() text(result.title.as_str()).size(title_size).into()
}; };
container( container(row![icon_el, title_col].spacing(8).align_y(iced::Center))
row![icon_el, title_col]
.spacing(8)
.align_y(iced::Center),
)
.width(Length::Fill) .width(Length::Fill)
.padding([6, 12]) .padding([6, 12])
.style(move |_theme| container::Style { .style(move |_theme| container::Style {
@@ -186,7 +225,23 @@ fn view(state: &KLauncherApp) -> Element<'_, Message> {
scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill) scrollable(column(result_rows).spacing(2).width(Length::Fill)).height(Length::Fill)
}; };
let content = column![search_bar, results_list] let maybe_error: Option<Element<'_, Message>> = state.error.as_ref().map(|msg| {
container(
text(msg.as_str())
.size(12.0)
.color(Color::from_rgba8(255, 80, 80, 1.0)),
)
.width(Length::Fill)
.padding([4, 12])
.into()
});
let mut content_children: Vec<Element<'_, Message>> =
vec![search_bar.into(), results_list.into()];
if let Some(err) = maybe_error {
content_children.push(err);
}
let content = column(content_children)
.spacing(8) .spacing(8)
.padding(12) .padding(12)
.width(Length::Fill) .width(Length::Fill)
@@ -219,30 +274,45 @@ fn subscription(_state: &KLauncherApp) -> Subscription<Message> {
} }
pub fn run( pub fn run(
engine: Arc<dyn SearchEngine>, engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
launcher: Arc<dyn AppLauncher>, launcher: Arc<dyn AppLauncher>,
window_cfg: &k_launcher_config::WindowCfg, window_cfg: &k_launcher_config::WindowCfg,
appearance_cfg: AppearanceCfg, appearance_cfg: AppearanceCfg,
) -> iced::Result { ) -> iced::Result {
let wc = WindowConfig::from_cfg(window_cfg);
iced::application( iced::application(
move || { move || {
let app = KLauncherApp::new(engine.clone(), launcher.clone(), appearance_cfg.clone()); let app = KLauncherApp::new(
Arc::new(NullSearchEngine),
launcher.clone(),
appearance_cfg.clone(),
);
let focus = iced::widget::operation::focus(INPUT_ID.clone()); let focus = iced::widget::operation::focus(INPUT_ID.clone());
(app, focus) let ef = engine_factory.clone();
let init = Task::perform(
async move {
tokio::task::spawn_blocking(move || ef())
.await
.map_err(|e| format!("Engine init failed: {e}"))
},
|result| match result {
Ok(e) => Message::EngineReady(EngineHandle(e)),
Err(msg) => Message::EngineInitFailed(msg),
},
);
(app, Task::batch([focus, init]))
}, },
update, update,
view, view,
) )
.title("K-Launcher") .title("K-Launcher")
.subscription(subscription) .subscription(subscription)
.window(window::Settings { .window(window::Settings {
size: Size::new(wc.width, wc.height), size: Size::new(window_cfg.width, window_cfg.height),
position: window::Position::Centered, position: window::Position::Centered,
decorations: wc.decorations, decorations: window_cfg.decorations,
transparent: wc.transparent, transparent: window_cfg.transparent,
resizable: wc.resizable, resizable: window_cfg.resizable,
..Default::default() ..Default::default()
}) })
.run() .run()
} }

View File

@@ -6,10 +6,10 @@ use k_launcher_config::{AppearanceCfg, WindowCfg};
use k_launcher_kernel::{AppLauncher, SearchEngine}; use k_launcher_kernel::{AppLauncher, SearchEngine};
pub fn run( pub fn run(
engine: Arc<dyn SearchEngine>, engine_factory: Arc<dyn Fn() -> Arc<dyn SearchEngine> + Send + Sync>,
launcher: Arc<dyn AppLauncher>, launcher: Arc<dyn AppLauncher>,
window_cfg: &WindowCfg, window_cfg: &WindowCfg,
appearance_cfg: AppearanceCfg, appearance_cfg: AppearanceCfg,
) -> iced::Result { ) -> iced::Result {
app::run(engine, launcher, window_cfg, appearance_cfg) app::run(engine_factory, launcher, window_cfg, appearance_cfg)
} }

View File

@@ -4,6 +4,12 @@ version = "0.1.0"
edition = "2024" edition = "2024"
default-run = "k-launcher" default-run = "k-launcher"
[profile.release]
lto = true
strip = true
codegen-units = 1
opt-level = 3
[[bin]] [[bin]]
name = "k-launcher" name = "k-launcher"
path = "src/main.rs" path = "src/main.rs"
@@ -20,6 +26,7 @@ egui = ["dep:k-launcher-ui-egui"]
iced = { workspace = true } iced = { workspace = true }
k-launcher-config = { path = "../k-launcher-config" } k-launcher-config = { path = "../k-launcher-config" }
k-launcher-kernel = { path = "../k-launcher-kernel" } 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-os-bridge = { path = "../k-launcher-os-bridge" }
k-launcher-ui = { path = "../k-launcher-ui" } k-launcher-ui = { path = "../k-launcher-ui" }
k-launcher-ui-egui = { path = "../k-launcher-ui-egui", optional = true } k-launcher-ui-egui = { path = "../k-launcher-ui-egui", optional = true }
@@ -27,4 +34,8 @@ plugin-apps = { path = "../plugins/plugin-apps" }
plugin-calc = { path = "../plugins/plugin-calc" } plugin-calc = { path = "../plugins/plugin-calc" }
plugin-cmd = { path = "../plugins/plugin-cmd" } plugin-cmd = { path = "../plugins/plugin-cmd" }
plugin-files = { path = "../plugins/plugin-files" } plugin-files = { path = "../plugins/plugin-files" }
dirs = { workspace = true }
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -2,28 +2,78 @@ use std::sync::Arc;
use k_launcher_kernel::Kernel; use k_launcher_kernel::Kernel;
use k_launcher_os_bridge::UnixAppLauncher; use k_launcher_os_bridge::UnixAppLauncher;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore}; use k_launcher_plugin_host::ExternalPlugin;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use plugin_apps::linux::FsDesktopEntrySource; use plugin_apps::linux::FsDesktopEntrySource;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
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 init_logging() -> tracing_appender::non_blocking::WorkerGuard {
let cfg = k_launcher_config::load(); use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
let launcher = Arc::new(UnixAppLauncher::new());
let frecency = FrecencyStore::load();
let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![]; let log_dir = dirs::data_local_dir()
if cfg.plugins.cmd { plugins.push(Arc::new(CmdPlugin::new())); } .map(|d| d.join("k-launcher/logs"))
if cfg.plugins.calc { plugins.push(Arc::new(CalcPlugin::new())); } .unwrap_or_else(|| std::path::PathBuf::from("/tmp/k-launcher/logs"));
if cfg.plugins.files { plugins.push(Arc::new(FilesPlugin::new())); } std::fs::create_dir_all(&log_dir).ok();
if cfg.plugins.apps {
plugins.push(Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)));
}
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = let file_appender = tracing_appender::rolling::daily(&log_dir, "k-launcher.log");
Arc::new(Kernel::new(plugins, cfg.search.max_results)); let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
k_launcher_ui::run(kernel, launcher, &cfg.window, cfg.appearance) let env_filter = EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::registry()
.with(env_filter)
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.with(tracing_subscriber::fmt::layer().with_writer(non_blocking))
.init();
guard
}
fn main() {
let _guard = init_logging();
if let Err(e) = run_ui() {
eprintln!("error: UI: {e}");
std::process::exit(1);
}
}
fn build_engine(cfg: Arc<k_launcher_config::Config>) -> Arc<dyn k_launcher_kernel::SearchEngine> {
let frecency = FrecencyStore::load();
let mut plugins: Vec<Arc<dyn k_launcher_kernel::Plugin>> = vec![];
if cfg.plugins.cmd {
plugins.push(Arc::new(CmdPlugin::new()));
}
if cfg.plugins.calc {
plugins.push(Arc::new(CalcPlugin::new()));
}
if cfg.plugins.files {
plugins.push(Arc::new(FilesPlugin::new()));
}
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(),
)));
}
Arc::new(Kernel::new(plugins, cfg.search.max_results))
}
fn run_ui() -> iced::Result {
let cfg = Arc::new(k_launcher_config::load());
let launcher = Arc::new(UnixAppLauncher::new());
let factory_cfg = cfg.clone();
let factory: Arc<dyn Fn() -> Arc<dyn k_launcher_kernel::SearchEngine> + Send + Sync> =
Arc::new(move || build_engine(factory_cfg.clone()));
k_launcher_ui::run(factory, launcher, &cfg.window, cfg.appearance.clone())
} }

View File

@@ -2,22 +2,26 @@ use std::sync::Arc;
use k_launcher_kernel::Kernel; use k_launcher_kernel::Kernel;
use k_launcher_os_bridge::UnixAppLauncher; use k_launcher_os_bridge::UnixAppLauncher;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
use plugin_apps::linux::FsDesktopEntrySource; use plugin_apps::linux::FsDesktopEntrySource;
use plugin_apps::{AppsPlugin, frecency::FrecencyStore};
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() -> Result<(), Box<dyn std::error::Error>> { fn main() -> Result<(), Box<dyn std::error::Error>> {
let cfg = k_launcher_config::load();
let launcher = Arc::new(UnixAppLauncher::new()); let launcher = Arc::new(UnixAppLauncher::new());
let frecency = FrecencyStore::load(); let frecency = FrecencyStore::load();
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(vec![ let kernel: Arc<dyn k_launcher_kernel::SearchEngine> = Arc::new(Kernel::new(
Arc::new(CmdPlugin::new()), vec![
Arc::new(CalcPlugin::new()), Arc::new(CmdPlugin::new()),
Arc::new(FilesPlugin::new()), Arc::new(CalcPlugin::new()),
Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)), Arc::new(FilesPlugin::new()),
], 8)); Arc::new(AppsPlugin::new(FsDesktopEntrySource::new(), frecency)),
k_launcher_ui_egui::run(kernel, launcher)?; ],
8,
));
k_launcher_ui_egui::run(kernel, launcher, &cfg.window)?;
Ok(()) Ok(())
} }

View File

@@ -9,10 +9,15 @@ path = "src/lib.rs"
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
bincode = { workspace = true }
dirs = { workspace = true }
k-launcher-kernel = { path = "../../k-launcher-kernel" } k-launcher-kernel = { path = "../../k-launcher-kernel" }
nucleo-matcher = "0.3"
serde = { workspace = true } serde = { workspace = true }
serde_json = "1.0" serde_json = "1.0"
tokio = { workspace = true } tokio = { workspace = true }
tracing = { workspace = true }
[target.'cfg(target_os = "linux")'.dependencies] [target.'cfg(target_os = "linux")'.dependencies]
xdg = "2" linicon = "2.3.0"
xdg = "3"

View File

@@ -24,7 +24,10 @@ impl FrecencyStore {
.ok() .ok()
.and_then(|s| serde_json::from_str(&s).ok()) .and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default(); .unwrap_or_default();
Arc::new(Self { path, data: Mutex::new(data) }) Arc::new(Self {
path,
data: Mutex::new(data),
})
} }
#[cfg(test)] #[cfg(test)]
@@ -36,11 +39,14 @@ impl FrecencyStore {
} }
pub fn load() -> Arc<Self> { pub fn load() -> Arc<Self> {
let path = xdg::BaseDirectories::new() let Some(data_home) = xdg::BaseDirectories::new().get_data_home() else {
.map(|xdg| xdg.get_data_home()) tracing::warn!("XDG_DATA_HOME unavailable; frecency disabled (in-memory only)");
.unwrap_or_else(|_| PathBuf::from(".")) return Arc::new(Self {
.join("k-launcher") path: PathBuf::from("/dev/null"),
.join("frecency.json"); data: Mutex::new(HashMap::new()),
});
};
let path = data_home.join("k-launcher").join("frecency.json");
Self::new(path) Self::new(path)
} }
@@ -49,14 +55,20 @@ impl FrecencyStore {
.duration_since(UNIX_EPOCH) .duration_since(UNIX_EPOCH)
.unwrap_or_default() .unwrap_or_default()
.as_secs(); .as_secs();
let mut data = self.data.lock().unwrap(); let json = {
let entry = data.entry(id.to_string()).or_insert(Entry { count: 0, last_used: 0 }); let mut data = self.data.lock().unwrap();
entry.count += 1; let entry = data.entry(id.to_string()).or_insert(Entry {
entry.last_used = now; count: 0,
if let Some(parent) = self.path.parent() { last_used: 0,
let _ = std::fs::create_dir_all(parent); });
} entry.count += 1;
if let Ok(json) = serde_json::to_string(&*data) { entry.last_used = now;
serde_json::to_string(&*data).ok()
}; // lock released here
if let Some(json) = json {
if let Some(parent) = self.path.parent() {
let _ = std::fs::create_dir_all(parent);
}
let _ = std::fs::write(&self.path, json); let _ = std::fs::write(&self.path, json);
} }
} }
@@ -69,8 +81,7 @@ impl FrecencyStore {
.unwrap_or_default() .unwrap_or_default()
.as_secs(); .as_secs();
let age_secs = now.saturating_sub(entry.last_used); let age_secs = now.saturating_sub(entry.last_used);
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 }; entry.count * decay_factor(age_secs)
entry.count * decay
} }
pub fn top_ids(&self, n: usize) -> Vec<String> { pub fn top_ids(&self, n: usize) -> Vec<String> {
@@ -83,8 +94,7 @@ impl FrecencyStore {
.iter() .iter()
.map(|(id, entry)| { .map(|(id, entry)| {
let age_secs = now.saturating_sub(entry.last_used); let age_secs = now.saturating_sub(entry.last_used);
let decay = if age_secs < 3600 { 4 } else if age_secs < 86400 { 2 } else { 1 }; (id.clone(), entry.count * decay_factor(age_secs))
(id.clone(), entry.count * decay)
}) })
.collect(); .collect();
scored.sort_by(|a, b| b.1.cmp(&a.1)); scored.sort_by(|a, b| b.1.cmp(&a.1));
@@ -92,6 +102,16 @@ impl FrecencyStore {
} }
} }
fn decay_factor(age_secs: u64) -> u32 {
if age_secs < 3600 {
4
} else if age_secs < 86400 {
2
} else {
1
}
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -2,10 +2,14 @@ pub mod frecency;
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
pub mod linux; pub mod linux;
use std::{collections::HashMap, sync::Arc}; use std::{
collections::HashMap,
path::{Path, PathBuf},
sync::{Arc, RwLock},
};
use async_trait::async_trait; 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; use crate::frecency::FrecencyStore;
@@ -68,67 +72,186 @@ pub trait DesktopEntrySource: Send + Sync {
struct CachedEntry { struct CachedEntry {
id: String, id: String,
name: AppName, name: AppName,
name_lc: String,
keywords_lc: Vec<String>, keywords_lc: Vec<String>,
category: Option<String>, category: Option<String>,
icon: Option<String>, icon: Option<String>,
exec: String, exec: String,
on_select: Arc<dyn Fn() + Send + Sync>, }
// --- Serializable cache data (no closures) ---
#[derive(serde::Serialize, serde::Deserialize)]
struct CachedEntryData {
id: String,
name: String,
keywords_lc: Vec<String>,
category: Option<String>,
icon: Option<String>,
exec: String,
}
fn cache_path() -> Option<PathBuf> {
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
}
fn load_from_path(path: &Path) -> Option<HashMap<String, CachedEntry>> {
let data = std::fs::read(path).ok()?;
let (entries_data, _): (Vec<CachedEntryData>, _) =
bincode::serde::decode_from_slice(&data, bincode::config::standard()).ok()?;
let map = entries_data
.into_iter()
.map(|e| {
let cached = CachedEntry {
id: e.id.clone(),
name: AppName::new(e.name),
keywords_lc: e.keywords_lc,
category: e.category,
icon: e.icon,
exec: e.exec,
};
(e.id, cached)
})
.collect();
Some(map)
}
fn save_to_path(path: &Path, entries: &HashMap<String, CachedEntry>) {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir).ok();
}
let data: Vec<CachedEntryData> = entries
.values()
.map(|e| CachedEntryData {
id: e.id.clone(),
name: e.name.as_str().to_string(),
keywords_lc: e.keywords_lc.clone(),
category: e.category.clone(),
icon: e.icon.clone(),
exec: e.exec.clone(),
})
.collect();
if let Ok(encoded) = bincode::serde::encode_to_vec(&data, bincode::config::standard()) {
std::fs::write(path, encoded).ok();
}
}
fn build_entries(
source: &impl DesktopEntrySource,
_frecency: &Arc<FrecencyStore>,
) -> HashMap<String, CachedEntry> {
source
.entries()
.into_iter()
.map(|e| {
let id = format!("app-{}:{}", e.name.as_str(), e.exec.as_str());
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect();
#[cfg(target_os = "linux")]
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 cached = CachedEntry {
id: id.clone(),
keywords_lc,
category: e.category,
icon,
exec,
name: e.name,
};
(id, cached)
})
.collect()
} }
// --- Plugin --- // --- Plugin ---
pub struct AppsPlugin { pub struct AppsPlugin {
entries: HashMap<String, CachedEntry>, entries: Arc<RwLock<HashMap<String, CachedEntry>>>,
frecency: Arc<FrecencyStore>, frecency: Arc<FrecencyStore>,
} }
impl AppsPlugin { impl AppsPlugin {
pub fn new(source: impl DesktopEntrySource, frecency: Arc<FrecencyStore>) -> Self { pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self {
let entries = source Self::new_impl(source, frecency, cache_path())
.entries() }
.into_iter()
.map(|e| { fn new_impl(
let id = format!("app-{}", e.name.as_str()); source: impl DesktopEntrySource + 'static,
let name_lc = e.name.as_str().to_lowercase(); frecency: Arc<FrecencyStore>,
let keywords_lc = e.keywords.iter().map(|k| k.to_lowercase()).collect(); cp: Option<PathBuf>,
#[cfg(target_os = "linux")] ) -> Self {
let icon = e.icon.as_ref().and_then(|p| linux::resolve_icon_path(p.as_str())); let cached = cp.as_deref().and_then(load_from_path);
#[cfg(not(target_os = "linux"))]
let icon: Option<String> = None; let entries = if let Some(from_cache) = cached {
let exec = e.exec.as_str().to_string(); // Serve cache immediately; refresh in background.
let store = Arc::clone(&frecency); let map = Arc::new(RwLock::new(from_cache));
let record_id = id.clone(); let entries_bg = Arc::clone(&map);
let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || { let frecency_bg = Arc::clone(&frecency);
store.record(&record_id); let cp_bg = cp.clone();
}); std::thread::spawn(move || {
let cached = CachedEntry { let fresh = build_entries(&source, &frecency_bg);
id: id.clone(), if let Some(path) = cp_bg {
name_lc, save_to_path(&path, &fresh);
keywords_lc, }
category: e.category, *entries_bg.write().unwrap() = fresh;
icon, });
exec, map
on_select, } else {
name: e.name, // No cache: build synchronously, then persist.
}; let initial = build_entries(&source, &frecency);
(id, cached) if let Some(path) = &cp {
}) save_to_path(path, &initial);
.collect(); }
Arc::new(RwLock::new(initial))
};
Self { entries, frecency } Self { entries, frecency }
} }
#[cfg(test)]
fn new_for_test(
source: impl DesktopEntrySource + 'static,
frecency: Arc<FrecencyStore>,
) -> Self {
Self::new_impl(source, frecency, None)
}
} }
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()
} }
fn score_match(name_lc: &str, query_lc: &str) -> Option<u32> { fn score_match(name: &str, query: &str) -> Option<u32> {
if name_lc == query_lc { return Some(100); } use nucleo_matcher::{
if name_lc.starts_with(query_lc) { return Some(80); } Config, Matcher, Utf32Str,
if name_lc.contains(query_lc) { return Some(60); } pattern::{CaseMatching, Normalization, Pattern},
if initials(name_lc).starts_with(query_lc) { return Some(70); } };
None
let mut matcher = Matcher::new(Config::DEFAULT);
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut name_chars: Vec<char> = name.chars().collect();
let haystack = Utf32Str::new(name, &mut name_chars);
let score = pattern.score(haystack, &mut matcher);
if let Some(s) = score {
let name_lc = name.to_lowercase();
let query_lc = query.to_lowercase();
let bonus: u32 = if initials(&name_lc).starts_with(&query_lc) {
20
} else {
0
};
Some(s.saturating_add(bonus))
} else {
None
}
} }
pub(crate) fn humanize_category(s: &str) -> String { pub(crate) fn humanize_category(s: &str) -> String {
@@ -144,16 +267,23 @@ pub(crate) fn humanize_category(s: &str) -> String {
#[async_trait] #[async_trait]
impl Plugin for AppsPlugin { impl Plugin for AppsPlugin {
fn name(&self) -> PluginName { fn name(&self) -> &str {
"apps" "apps"
} }
fn on_selected(&self, id: &ResultId) {
self.frecency.record(id.as_str());
}
async fn search(&self, query: &str) -> Vec<SearchResult> { async fn search(&self, query: &str) -> Vec<SearchResult> {
let entries = self.entries.read().unwrap();
if query.is_empty() { if query.is_empty() {
return self.frecency.top_ids(5) return self
.frecency
.top_ids(5)
.iter() .iter()
.filter_map(|id| { .filter_map(|id| {
let e = self.entries.get(id)?; let e = entries.get(id)?;
let score = self.frecency.frecency_score(id).max(1); let score = self.frecency.frecency_score(id).max(1);
Some(SearchResult { Some(SearchResult {
id: ResultId::new(id), id: ResultId::new(id),
@@ -162,18 +292,20 @@ impl Plugin for AppsPlugin {
icon: e.icon.clone(), icon: e.icon.clone(),
score: Score::new(score), score: Score::new(score),
action: LaunchAction::SpawnProcess(e.exec.clone()), action: LaunchAction::SpawnProcess(e.exec.clone()),
on_select: Some(Arc::clone(&e.on_select)),
}) })
}) })
.collect(); .collect();
} }
let query_lc = query.to_lowercase(); let query_lc = query.to_lowercase();
self.entries entries
.values() .values()
.filter_map(|e| { .filter_map(|e| {
let score = score_match(&e.name_lc, &query_lc).or_else(|| { let score = score_match(e.name.as_str(), query).or_else(|| {
e.keywords_lc.iter().any(|k| k.contains(&query_lc)).then_some(50) e.keywords_lc
.iter()
.any(|k| k.contains(&query_lc))
.then_some(50)
})?; })?;
Some(SearchResult { Some(SearchResult {
id: ResultId::new(&e.id), id: ResultId::new(&e.id),
@@ -182,7 +314,6 @@ impl Plugin for AppsPlugin {
icon: e.icon.clone(), icon: e.icon.clone(),
score: Score::new(score), score: Score::new(score),
action: LaunchAction::SpawnProcess(e.exec.clone()), action: LaunchAction::SpawnProcess(e.exec.clone()),
on_select: Some(Arc::clone(&e.on_select)),
}) })
}) })
.collect() .collect()
@@ -226,7 +357,14 @@ mod tests {
Self { Self {
entries: entries entries: entries
.into_iter() .into_iter()
.map(|(n, e, kw)| (n.to_string(), e.to_string(), None, kw.into_iter().map(|s| s.to_string()).collect())) .map(|(n, e, kw)| {
(
n.to_string(),
e.to_string(),
None,
kw.into_iter().map(|s| s.to_string()).collect(),
)
})
.collect(), .collect(),
} }
} }
@@ -249,52 +387,66 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn apps_prefix_match() { async fn apps_prefix_match() {
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency()); let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
let results = p.search("fire").await; let results = p.search("fire").await;
assert_eq!(results[0].title.as_str(), "Firefox"); assert_eq!(results[0].title.as_str(), "Firefox");
} }
#[tokio::test] #[tokio::test]
async fn apps_no_match_returns_empty() { async fn apps_no_match_returns_empty() {
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency()); let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
assert!(p.search("zz").await.is_empty()); assert!(p.search("zz").await.is_empty());
} }
#[tokio::test] #[tokio::test]
async fn apps_empty_query_no_frecency_returns_empty() { async fn apps_empty_query_no_frecency_returns_empty() {
let p = AppsPlugin::new(MockSource::with(vec![("Firefox", "firefox")]), ephemeral_frecency()); let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
assert!(p.search("").await.is_empty()); assert!(p.search("").await.is_empty());
} }
#[test] #[test]
fn score_match_abbreviation() { fn score_match_abbreviation() {
assert_eq!(initials("visual studio code"), "vsc"); assert_eq!(initials("visual studio code"), "vsc");
assert_eq!(score_match("visual studio code", "vsc"), Some(70)); assert!(score_match("visual studio code", "vsc").is_some());
} }
#[test] #[test]
fn score_match_exact_beats_prefix_beats_abbrev_beats_substr() { fn score_match_exact_beats_prefix() {
assert_eq!(score_match("firefox", "firefox"), Some(100)); let exact = score_match("firefox", "firefox");
assert_eq!(score_match("firefox", "fire"), Some(80)); let prefix = score_match("firefox", "fire");
assert_eq!(score_match("gnu firefox", "gf"), Some(70)); let abbrev = score_match("gnu firefox", "gf");
assert_eq!(score_match("ice firefox", "fire"), Some(60)); let substr = score_match("ice firefox", "fire");
assert!(exact.is_some());
assert!(prefix.is_some());
assert!(abbrev.is_some());
assert!(substr.is_some());
assert!(exact.unwrap() > prefix.unwrap());
} }
#[tokio::test] #[tokio::test]
async fn apps_abbreviation_match() { async fn apps_abbreviation_match() {
let p = AppsPlugin::new( let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Visual Studio Code", "code")]), MockSource::with(vec![("Visual Studio Code", "code")]),
ephemeral_frecency(), ephemeral_frecency(),
); );
let results = p.search("vsc").await; let results = p.search("vsc").await;
assert_eq!(results.len(), 1); assert_eq!(results.len(), 1);
assert_eq!(results[0].title.as_str(), "Visual Studio Code"); assert_eq!(results[0].title.as_str(), "Visual Studio Code");
assert_eq!(results[0].score.value(), 70); assert!(results[0].score.value() > 0);
} }
#[tokio::test] #[tokio::test]
async fn apps_keyword_match() { async fn apps_keyword_match() {
let p = AppsPlugin::new( let p = AppsPlugin::new_for_test(
MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]), MockSource::with_keywords(vec![("Code", "code", vec!["editor", "ide"])]),
ephemeral_frecency(), ephemeral_frecency(),
); );
@@ -303,6 +455,20 @@ mod tests {
assert_eq!(results[0].score.value(), 50); assert_eq!(results[0].score.value(), 50);
} }
#[tokio::test]
async fn apps_fuzzy_typo_match() {
let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox")]),
ephemeral_frecency(),
);
let results = p.search("frefox").await;
assert!(
!results.is_empty(),
"nucleo should fuzzy-match 'frefox' to 'Firefox'"
);
assert!(results[0].score.value() > 0);
}
#[test] #[test]
fn humanize_category_splits_camel_case() { fn humanize_category_splits_camel_case() {
assert_eq!(humanize_category("TextEditor"), "Text Editor"); assert_eq!(humanize_category("TextEditor"), "Text Editor");
@@ -312,7 +478,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn apps_category_appears_in_description() { async fn apps_category_appears_in_description() {
let p = AppsPlugin::new( let p = AppsPlugin::new_for_test(
MockSource::with_categories(vec![("Code", "code", "Text Editor")]), MockSource::with_categories(vec![("Code", "code", "Text Editor")]),
ephemeral_frecency(), ephemeral_frecency(),
); );
@@ -323,10 +489,10 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn apps_empty_query_returns_top_frecent() { async fn apps_empty_query_returns_top_frecent() {
let frecency = ephemeral_frecency(); let frecency = ephemeral_frecency();
frecency.record("app-Code"); frecency.record("app-Code:code");
frecency.record("app-Code"); frecency.record("app-Code:code");
frecency.record("app-Firefox"); frecency.record("app-Firefox:firefox");
let p = AppsPlugin::new( let p = AppsPlugin::new_for_test(
MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]), MockSource::with(vec![("Firefox", "firefox"), ("Code", "code")]),
frecency, frecency,
); );
@@ -334,4 +500,22 @@ mod tests {
assert_eq!(results.len(), 2); assert_eq!(results.len(), 2);
assert_eq!(results[0].title.as_str(), "Code"); assert_eq!(results[0].title.as_str(), "Code");
} }
#[test]
fn apps_loads_from_cache_when_source_is_empty() {
let frecency = ephemeral_frecency();
let cache_file =
std::env::temp_dir().join(format!("k-launcher-test-{}.bin", std::process::id()));
// Build entries from a real source and save to temp path
let source = MockSource::with(vec![("Firefox", "firefox")]);
let entries = build_entries(&source, &frecency);
save_to_path(&cache_file, &entries);
// Load from temp path — should contain Firefox
let loaded = load_from_path(&cache_file).unwrap();
assert!(loaded.contains_key("app-Firefox:firefox"));
std::fs::remove_file(&cache_file).ok();
}
} }

View File

@@ -1,7 +1,7 @@
use std::path::Path; use std::path::Path;
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
use crate::humanize_category; use crate::humanize_category;
use crate::{AppName, DesktopEntry, DesktopEntrySource, ExecCommand, IconPath};
pub struct FsDesktopEntrySource; pub struct FsDesktopEntrySource;
@@ -20,11 +20,12 @@ impl Default for FsDesktopEntrySource {
impl DesktopEntrySource for FsDesktopEntrySource { impl DesktopEntrySource for FsDesktopEntrySource {
fn entries(&self) -> Vec<DesktopEntry> { fn entries(&self) -> Vec<DesktopEntry> {
let mut dirs = Vec::new(); let mut dirs = Vec::new();
if let Ok(xdg) = xdg::BaseDirectories::new() { let xdg = xdg::BaseDirectories::new();
dirs.push(xdg.get_data_home().join("applications")); if let Some(data_home) = xdg.get_data_home() {
for d in xdg.get_data_dirs() { dirs.push(data_home.join("applications"));
dirs.push(d.join("applications")); }
} for d in xdg.get_data_dirs() {
dirs.push(d.join("applications"));
} }
let mut entries = Vec::new(); let mut entries = Vec::new();
for dir in &dirs { for dir in &dirs {
@@ -44,15 +45,79 @@ impl DesktopEntrySource for FsDesktopEntrySource {
} }
} }
pub(crate) fn clean_exec(exec: &str) -> String {
// Tokenize respecting double-quoted strings, then filter field codes.
let mut tokens: Vec<String> = Vec::new();
let mut chars = exec.chars().peekable();
while let Some(&ch) = chars.peek() {
if ch.is_whitespace() {
chars.next();
continue;
}
if ch == '"' {
// Consume opening quote
chars.next();
let mut token = String::from('"');
while let Some(&c) = chars.peek() {
chars.next();
if c == '"' {
token.push('"');
break;
}
token.push(c);
}
// Strip embedded field codes like %f inside the quoted string
// (between the quotes, before re-assembling)
let inner = &token[1..token.len().saturating_sub(1)];
let cleaned_inner: String = inner
.split_whitespace()
.filter(|s| !is_field_code(s))
.collect::<Vec<_>>()
.join(" ");
tokens.push(format!("\"{cleaned_inner}\""));
} else {
let mut token = String::new();
while let Some(&c) = chars.peek() {
if c.is_whitespace() {
break;
}
chars.next();
token.push(c);
}
if !is_field_code(&token) {
tokens.push(token);
}
}
}
tokens.join(" ")
}
fn is_field_code(s: &str) -> bool {
let b = s.as_bytes();
b.len() == 2 && b[0] == b'%' && b[1].is_ascii_alphabetic()
}
pub fn resolve_icon_path(name: &str) -> Option<String> { pub fn resolve_icon_path(name: &str) -> Option<String> {
if name.starts_with('/') && Path::new(name).exists() { if name.starts_with('/') && Path::new(name).exists() {
return Some(name.to_string()); return Some(name.to_string());
} }
// Try linicon freedesktop theme traversal
let themes = ["hicolor", "Adwaita", "breeze", "Papirus"];
for theme in &themes {
if let Some(icon_path) = linicon::lookup_icon(name)
.from_theme(theme)
.with_size(48)
.find_map(|r| r.ok())
{
return Some(icon_path.path.to_string_lossy().into_owned());
}
}
// Fallback to pixmaps
let candidates = [ let candidates = [
format!("/usr/share/pixmaps/{name}.png"), format!("/usr/share/pixmaps/{name}.png"),
format!("/usr/share/pixmaps/{name}.svg"), 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()) candidates.into_iter().find(|p| Path::new(p).exists())
} }
@@ -89,13 +154,15 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
"Type" if !is_application => is_application = value.trim() == "Application", "Type" if !is_application => is_application = value.trim() == "Application",
"NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"), "NoDisplay" => no_display = value.trim().eq_ignore_ascii_case("true"),
"Categories" if category.is_none() => { "Categories" if category.is_none() => {
category = value.trim() category = value
.trim()
.split(';') .split(';')
.find(|s| !s.is_empty()) .find(|s| !s.is_empty())
.map(|s| humanize_category(s.trim())); .map(|s| humanize_category(s.trim()));
} }
"Keywords" if keywords.is_empty() => { "Keywords" if keywords.is_empty() => {
keywords = value.trim() keywords = value
.trim()
.split(';') .split(';')
.filter(|s| !s.is_empty()) .filter(|s| !s.is_empty())
.map(|s| s.trim().to_string()) .map(|s| s.trim().to_string())
@@ -110,16 +177,7 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
return None; return None;
} }
let exec_clean: String = exec? let exec_clean: String = clean_exec(&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 { Some(DesktopEntry {
name: AppName::new(name?), name: AppName::new(name?),
@@ -129,3 +187,31 @@ fn parse_desktop_file(path: &Path) -> Option<DesktopEntry> {
keywords, keywords,
}) })
} }
#[cfg(test)]
mod exec_tests {
use super::clean_exec;
#[test]
fn strips_bare_field_code() {
assert_eq!(clean_exec("app --file %f"), "app --file");
}
#[test]
fn strips_multiple_field_codes() {
assert_eq!(clean_exec("app %U --flag"), "app --flag");
}
#[test]
fn preserves_quoted_value() {
assert_eq!(
clean_exec(r#"app --arg="value" %U"#),
r#"app --arg="value""#
);
}
#[test]
fn handles_plain_exec() {
assert_eq!(clean_exec("firefox"), "firefox");
}
}

View File

@@ -9,6 +9,6 @@ path = "src/lib.rs"
[dependencies] [dependencies]
async-trait = { workspace = true } async-trait = { workspace = true }
evalexpr = "11" evalexpr = "13"
k-launcher-kernel = { path = "../../k-launcher-kernel" } k-launcher-kernel = { path = "../../k-launcher-kernel" }
tokio = { workspace = true } tokio = { workspace = true }

View File

@@ -1,5 +1,7 @@
use async_trait::async_trait; use async_trait::async_trait;
use k_launcher_kernel::{LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult}; use evalexpr::eval_number_with_context;
use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
use std::sync::LazyLock;
pub struct CalcPlugin; pub struct CalcPlugin;
@@ -15,18 +17,54 @@ impl Default for CalcPlugin {
} }
} }
fn strip_numeric_separators(expr: &str) -> String {
expr.replace('_', "")
}
const MATH_FNS: &[&str] = &[
"sqrt", "sin", "cos", "tan", "asin", "acos", "atan", "ln", "log2", "log10", "exp", "abs",
"ceil", "floor", "round",
];
fn should_eval(query: &str) -> bool { fn should_eval(query: &str) -> bool {
query let q = query.strip_prefix('=').unwrap_or(query);
.chars() q.chars()
.next() .next()
.map(|c| c.is_ascii_digit() || c == '(' || c == '-') .map(|c| c.is_ascii_digit() || c == '(' || c == '-')
.unwrap_or(false) .unwrap_or(false)
|| query.starts_with('=') || query.starts_with('=')
|| MATH_FNS.iter().any(|f| q.starts_with(f))
} }
static MATH_CTX: LazyLock<evalexpr::HashMapContext<evalexpr::DefaultNumericTypes>> = LazyLock::new(
|| {
use evalexpr::*;
context_map! {
"pi" => float std::f64::consts::PI,
"e" => float std::f64::consts::E,
"sqrt" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.sqrt()))),
"sin" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.sin()))),
"cos" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.cos()))),
"tan" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.tan()))),
"asin" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.asin()))),
"acos" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.acos()))),
"atan" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.atan()))),
"ln" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.ln()))),
"log2" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.log2()))),
"log10" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.log10()))),
"exp" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.exp()))),
"abs" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.abs()))),
"ceil" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.ceil()))),
"floor" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.floor()))),
"round" => Function::new(|a: &Value<DefaultNumericTypes>| Ok(Value::from_float(a.as_number()?.round())))
}
.expect("static math context must be valid")
},
);
#[async_trait] #[async_trait]
impl Plugin for CalcPlugin { impl Plugin for CalcPlugin {
fn name(&self) -> PluginName { fn name(&self) -> &str {
"calc" "calc"
} }
@@ -34,8 +72,10 @@ impl Plugin for CalcPlugin {
if !should_eval(query) { if !should_eval(query) {
return vec![]; return vec![];
} }
let expr = query.strip_prefix('=').unwrap_or(query); let raw = query.strip_prefix('=').unwrap_or(query);
match evalexpr::eval_number(expr) { let expr_owned = strip_numeric_separators(raw);
let expr = expr_owned.as_str();
match eval_number_with_context(expr, &*MATH_CTX) {
Ok(n) if n.is_finite() => { Ok(n) if n.is_finite() => {
let value_str = if n.fract() == 0.0 { let value_str = if n.fract() == 0.0 {
format!("{}", n as i64) format!("{}", n as i64)
@@ -43,7 +83,6 @@ impl Plugin for CalcPlugin {
format!("{n}") format!("{n}")
}; };
let display = format!("= {value_str}"); let display = format!("= {value_str}");
let expr_owned = expr.to_string();
vec![SearchResult { vec![SearchResult {
id: ResultId::new("calc-result"), id: ResultId::new("calc-result"),
title: ResultTitle::new(display), title: ResultTitle::new(display),
@@ -51,7 +90,6 @@ impl Plugin for CalcPlugin {
icon: None, icon: None,
score: Score::new(90), score: Score::new(90),
action: LaunchAction::CopyToClipboard(value_str), action: LaunchAction::CopyToClipboard(value_str),
on_select: None,
}] }]
} }
_ => vec![], _ => vec![],
@@ -81,4 +119,36 @@ mod tests {
let p = CalcPlugin::new(); let p = CalcPlugin::new();
assert!(p.search("1/0").await.is_empty()); assert!(p.search("1/0").await.is_empty());
} }
#[tokio::test]
async fn calc_sqrt() {
let p = CalcPlugin::new();
let results = p.search("sqrt(9)").await;
assert_eq!(results[0].title.as_str(), "= 3");
}
#[tokio::test]
async fn calc_sin_pi() {
let p = CalcPlugin::new();
let results = p.search("sin(pi)").await;
assert!(!results.is_empty());
let title = results[0].title.as_str();
let val: f64 = title.trim_start_matches("= ").parse().unwrap();
assert!(val.abs() < 1e-10, "sin(pi) should be near zero, got {val}");
}
#[tokio::test]
async fn calc_underscore_separator() {
let p = CalcPlugin::new();
let results = p.search("1_000 * 2").await;
assert_eq!(results[0].title.as_str(), "= 2000");
assert_eq!(
results[0].description.as_deref(),
Some("1000 * 2 · Enter to copy")
);
assert!(matches!(
&results[0].action,
LaunchAction::CopyToClipboard(v) if v == "2000"
));
}
} }

View File

@@ -1,5 +1,5 @@
use async_trait::async_trait; 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; pub struct CmdPlugin;
@@ -17,7 +17,7 @@ impl Default for CmdPlugin {
#[async_trait] #[async_trait]
impl Plugin for CmdPlugin { impl Plugin for CmdPlugin {
fn name(&self) -> PluginName { fn name(&self) -> &str {
"cmd" "cmd"
} }
@@ -36,7 +36,6 @@ impl Plugin for CmdPlugin {
icon: None, icon: None,
score: Score::new(95), score: Score::new(95),
action: LaunchAction::SpawnInTerminal(cmd.to_string()), action: LaunchAction::SpawnInTerminal(cmd.to_string()),
on_select: None,
}] }]
} }
} }

View File

@@ -3,7 +3,7 @@ mod platform;
use std::path::Path; use std::path::Path;
use async_trait::async_trait; 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; pub struct FilesPlugin;
@@ -32,7 +32,7 @@ fn expand_query(query: &str) -> Option<String> {
#[async_trait] #[async_trait]
impl Plugin for FilesPlugin { impl Plugin for FilesPlugin {
fn name(&self) -> PluginName { fn name(&self) -> &str {
"files" "files"
} }
@@ -71,25 +71,19 @@ impl Plugin for FilesPlugin {
.unwrap_or(false) .unwrap_or(false)
}) })
.take(20) .take(20)
.enumerate() .map(|entry| {
.map(|(i, entry)| {
let full_path = entry.path(); let full_path = entry.path();
let name = entry.file_name().to_string_lossy().to_string(); let name = entry.file_name().to_string_lossy().to_string();
let is_dir = full_path.is_dir(); let is_dir = full_path.is_dir();
let title = if is_dir { let title = if is_dir { format!("{name}/") } else { name };
format!("{name}/")
} else {
name
};
let path_str = full_path.to_string_lossy().to_string(); let path_str = full_path.to_string_lossy().to_string();
SearchResult { SearchResult {
id: ResultId::new(format!("file-{i}")), id: ResultId::new(&path_str),
title: ResultTitle::new(title), title: ResultTitle::new(title),
description: Some(path_str.clone()), description: Some(path_str.clone()),
icon: None, icon: None,
score: Score::new(50), score: Score::new(50),
action: LaunchAction::OpenPath(path_str), action: LaunchAction::OpenPath(path_str),
on_select: None,
} }
}) })
.collect() .collect()

View File

@@ -0,0 +1,12 @@
[package]
name = "plugin-url"
version = "0.1.0"
edition = "2024"
[[bin]]
name = "k-launcher-plugin-url"
path = "src/main.rs"
[dependencies]
serde = { workspace = true }
serde_json = { workspace = true }

View File

@@ -0,0 +1,130 @@
use std::io::{self, BufRead, Write};
use serde::{Deserialize, Serialize};
#[derive(Deserialize)]
struct Query {
query: String,
}
#[derive(Serialize)]
struct Action {
r#type: &'static str,
path: String,
}
#[derive(Serialize)]
struct Result {
id: &'static str,
title: &'static str,
description: String,
score: u32,
action: Action,
}
fn is_url(query: &str) -> bool {
query.starts_with("http://") || query.starts_with("https://") || query.starts_with("www.")
}
fn normalize(query: &str) -> String {
if query.starts_with("www.") {
format!("https://{query}")
} else {
query.to_string()
}
}
fn search(query: &str) -> Vec<Result> {
if !is_url(query) {
return vec![];
}
let url = normalize(query);
vec![Result {
id: "url-open",
title: "Open in Browser",
description: url.clone(),
score: 95,
action: Action {
r#type: "OpenPath",
path: url.clone(),
},
}]
}
fn main() -> io::Result<()> {
let stdin = io::stdin();
let stdout = io::stdout();
let mut out = stdout.lock();
for line in stdin.lock().lines() {
let line = line?;
let q: Query = match serde_json::from_str(&line) {
Ok(q) => q,
Err(_) => continue,
};
let results = search(&q.query);
writeln!(out, "{}", serde_json::to_string(&results).unwrap())?;
out.flush()?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_url_https() {
assert!(is_url("https://example.com"));
}
#[test]
fn is_url_http() {
assert!(is_url("http://example.com"));
}
#[test]
fn is_url_www() {
assert!(is_url("www.foo.com"));
}
#[test]
fn is_url_plain() {
assert!(!is_url("firefox"));
}
#[test]
fn is_url_empty() {
assert!(!is_url(""));
}
#[test]
fn normalize_www() {
assert_eq!(normalize("www.foo.com"), "https://www.foo.com");
}
#[test]
fn normalize_https() {
assert_eq!(normalize("https://example.com"), "https://example.com");
}
#[test]
fn search_returns_result() {
let results = search("https://example.com");
assert_eq!(results.len(), 1);
assert_eq!(results[0].action.path, "https://example.com");
}
#[test]
fn search_returns_empty() {
assert!(search("firefox").is_empty());
}
#[test]
fn result_serializes() {
let results = search("https://example.com");
let json = serde_json::to_string(&results).unwrap();
assert!(json.contains("OpenPath"));
assert!(json.contains("https://example.com"));
}
}

View File

@@ -34,6 +34,12 @@ calc = true # math expression evaluator
cmd = true # shell command runner (> prefix) cmd = true # shell command runner (> prefix)
files = true # filesystem browser (/ or ~/ prefix) files = true # filesystem browser (/ or ~/ prefix)
apps = true # XDG application launcher 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 ## RGBA Format

View File

@@ -1,10 +1,102 @@
# Plugin Development # 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 ### 1. Create a new crate in the workspace
@@ -38,9 +130,7 @@ async-trait = "0.1"
```rust ```rust
use async_trait::async_trait; use async_trait::async_trait;
use k_launcher_kernel::{ use k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
LaunchAction, Plugin, PluginName, ResultId, ResultTitle, Score, SearchResult,
};
pub struct HelloPlugin; pub struct HelloPlugin;
@@ -52,7 +142,7 @@ impl HelloPlugin {
#[async_trait] #[async_trait]
impl Plugin for HelloPlugin { impl Plugin for HelloPlugin {
fn name(&self) -> PluginName { fn name(&self) -> &str {
"hello" "hello"
} }

15
packaging/aur/.SRCINFO Normal file
View File

@@ -0,0 +1,15 @@
pkgbase = k-launcher-bin
pkgdesc = GPU-accelerated command palette launcher for Linux (Wayland/X11)
pkgver = 0.1.0
pkgrel = 1
url = https://github.com/GKaszewski/k-launcher
arch = x86_64
license = MIT
depends = wayland
depends = libxkbcommon
provides = k-launcher
conflicts = k-launcher
source = k-launcher-0.1.0::https://github.com/GKaszewski/k-launcher/releases/download/v0.1.0/k-launcher
sha256sums = SKIP
pkgname = k-launcher-bin

17
packaging/aur/PKGBUILD Normal file
View File

@@ -0,0 +1,17 @@
# Maintainer: k-launcher contributors
pkgname=k-launcher-bin
pkgver=0.1.0
pkgrel=1
pkgdesc="GPU-accelerated command palette launcher for Linux (Wayland/X11)"
arch=('x86_64')
url="https://github.com/GKaszewski/k-launcher"
license=('MIT')
depends=('wayland' 'libxkbcommon')
provides=('k-launcher')
conflicts=('k-launcher')
source=("k-launcher-${pkgver}::https://github.com/GKaszewski/k-launcher/releases/download/v${pkgver}/k-launcher")
sha256sums=('SKIP')
package() {
install -Dm755 "k-launcher-${pkgver}" "${pkgdir}/usr/bin/k-launcher"
}

View File

@@ -0,0 +1,11 @@
[Unit]
Description=k-launcher command palette daemon
After=graphical-session.target
[Service]
Type=simple
ExecStart=/usr/bin/k-launcher
Restart=on-failure
[Install]
WantedBy=graphical-session.target

View File

@@ -0,0 +1,8 @@
[Unit]
Description=k-launcher IPC socket
[Socket]
ListenStream=%t/k-launcher.sock
[Install]
WantedBy=sockets.target