v0.2.0
Some checks failed
CI / test (push) Failing after 5m16s
CI / fmt (push) Has been cancelled
CI / clippy (push) Has been cancelled
Release / build (push) Failing after 5m31s

clean architecture refactor, performance, resilience, DX/UX

architecture:
- 13 crates with proper domain/application/infrastructure layers
- domain crate: newtypes, ports (Plugin, AppLauncher), constants
- kernel: pure orchestrator
- shared UI state machine (k-launcher-ui-core)
- merged plugin-api into domain as ports module
- granular file structure (no monolithic lib.rs)
- all tests extracted to tests/ directories

features:
- frecency boost in search results
- empty query shows top frecent apps
- append-only frecency log with configurable compaction
- config-driven styling (all colors, sizes, debounce)
- configurable terminal emulator, external plugin timeout
- log rotation with max_log_files
- loading indicator, descriptive placeholder text
- graceful shutdown via iced::exit() + Plugin::shutdown()
- --version flag, panic hook, signal handling (SIGINT/SIGTERM)
- SpawnInTerminal in external plugin protocol

performance:
- ~1500 -> ~50 heap allocs per keystroke
- reused Matcher, Pattern, char buffer across entries
- Arc<str> for shared result fields
- pre-filter before fuzzy matching
- partial sort for top frecent IDs
- cached lowercase names in entries

resilience:
- parking_lot (no mutex poisoning)
- thiserror hierarchy (PluginError, ConfigError, AppError)
- all silent error swallowing replaced with tracing::warn
- config parse errors logged

quality:
- named constants (no magic strings/numbers)
- named types (no anonymous tuples)
- Rgba newtype with validation
- domain newtype validation (debug_assert non-empty)
- man page, LICENSE (MIT), PKGBUILD, example config
- plugin development guide updated
- make check (fmt + clippy + test), make dev (RUST_LOG=debug)

style: format code for better readability in tests and function signatures

fix: update build_entries function signature to ignore frecency parameter

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)

Update iced dependency in Cargo.toml to disable default features and add additional ones

feat(app): enhance engine initialization with EngineHandle and update run function signature

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

refactor: remove client module and associated show command logic

fix(app): format code for clarity in update function

chore: update .gitignore and enhance README with compositor setup instructions

chore(docs): remove unused screenshot file

feature/prod-ready (#1)

Reviewed-on: #1

fix(calc): remove ambiguous log alias, use ln/log2/log10 explicitly

fix(calc): fix log/ln naming, cache math context, strengthen sin(pi) test

feat(calc): add math functions (sqrt, sin, cos, etc.) and pi/e constants

refactor(calc): rename preprocess, extend underscore test assertions

feat(calc): strip underscore digit separators

feat: update dependencies for improved compatibility and performance

feat: add plugin-url for URL handling and open in browser functionality

feat: add support for external plugins and enhance plugin management

feat: add Makefile for build, run, and installation commands

feat: add required features for k-launcher-egui and update dependencies

feat: update README and add documentation for installation, configuration, usage, and plugin development

feat: enhance configuration management and UI styling, remove unused theme module

feat: add k-launcher-config crate for configuration management and integrate with existing components

feat: add k-launcher-ui-egui crate for enhanced UI

- Introduced a new crate `k-launcher-ui-egui` to provide a graphical user interface using eframe and egui.
- Updated the workspace configuration in `Cargo.toml` to include the new crate.
- Implemented the main application logic in `src/app.rs`, handling search functionality and user interactions.
- Created a library entry point in `src/lib.rs` to expose the `run` function for launching the UI.
- Modified the `k-launcher` crate to include a new binary target for the egui-based launcher.
- Added a new main file `src/main_egui.rs` to initialize and run the egui UI with the existing kernel and launcher components.

feat: implement OS bridge and enhance app launcher functionality

feat: add FilesPlugin for file searching and integrate into KLauncher

feat: implement frecency tracking for app usage and enhance search functionality

feat: add CmdPlugin for executing terminal commands and update workspace configuration

refactor: update dependencies and improve keyboard event handling in KLauncherApp

refactor: simplify theme usage and enhance AppsPlugin structure

feat: restructure k-launcher workspace and add core functionality

- Updated Cargo.toml to include a new k-launcher crate and reorganized workspace members.
- Introduced a README.md file detailing the project philosophy, architecture, and technical specifications.
- Implemented a new Kernel struct in k-launcher-kernel for managing plugins and search functionality.
- Created a Plugin trait for plugins to implement, allowing for asynchronous search operations.
- Developed k-launcher-ui with an Iced-based UI for user interaction, including search input and result display.
- Added AppsPlugin and CalcPlugin to handle application launching and basic calculations, respectively.
- Established a theme module for UI styling, focusing on an Aero aesthetic.
- Removed unnecessary main.rs files from plugin crates, streamlining the project structure.

Initialize k-launcher project structure with multiple crates and basic configurations
This commit is contained in:
2026-07-24 13:42:14 +02:00
parent 2e773cdeaf
commit 051d19d878
95 changed files with 4129 additions and 2591 deletions

View File

@@ -1,8 +1,9 @@
[package]
name = "k-launcher-os-bridge"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[dependencies]
k-launcher-kernel = { path = "../k-launcher-kernel" }
k-launcher-domain = { workspace = true }
libc = "0.2"
tracing = { workspace = true }

View File

@@ -0,0 +1,50 @@
use k_launcher_domain::{AppLauncher, LaunchAction};
use crate::shell::shell_split;
use crate::spawn::{copy_to_clipboard, open_path, spawn_detached};
use crate::terminal::resolve_terminal;
pub struct UnixAppLauncher {
terminal_cmd: Option<String>,
}
impl UnixAppLauncher {
pub fn new(terminal_cmd: Option<String>) -> Self {
Self { terminal_cmd }
}
}
impl AppLauncher for UnixAppLauncher {
fn execute(&self, action: &LaunchAction) {
match action {
LaunchAction::SpawnProcess(cmd) => spawn_command(cmd),
LaunchAction::SpawnInTerminal(cmd) => {
spawn_in_terminal(cmd, self.terminal_cmd.as_deref())
}
LaunchAction::OpenPath(path) => open_path(path),
LaunchAction::CopyToClipboard(val) => copy_to_clipboard(val),
}
}
}
fn spawn_command(cmd: &str) {
let parts = shell_split(cmd);
if let Some((bin, args)) = parts.split_first() {
spawn_detached(bin, args);
}
}
fn spawn_in_terminal(cmd: &str, configured: Option<&str>) {
let Some(terminal) = resolve_terminal(configured) else {
return;
};
let mut args = terminal.exec_flag;
const SHELL: &str = "sh";
const SHELL_CMD_FLAG: &str = "-c";
args.extend([
SHELL.to_string(),
SHELL_CMD_FLAG.to_string(),
cmd.to_string(),
]);
spawn_detached(&terminal.bin, &args);
}

View File

@@ -1,2 +1,7 @@
mod unix_launcher;
pub use unix_launcher::UnixAppLauncher;
mod launcher;
mod shell;
mod spawn;
mod terminal;
pub use launcher::UnixAppLauncher;
pub use shell::shell_split;

View File

@@ -0,0 +1,22 @@
pub 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
}

View File

@@ -0,0 +1,49 @@
use std::io::Write;
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
const XDG_OPEN: &str = "xdg-open";
const WL_COPY: &str = "wl-copy";
const XCLIP: &str = "xclip";
pub(crate) fn spawn_detached(bin: &str, args: &[String]) {
// SAFETY: setsid() is async-signal-safe; called in forked child before exec
if let Err(e) = unsafe {
Command::new(bin)
.args(args)
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.pre_exec(|| {
libc::setsid();
Ok(())
})
.spawn()
} {
tracing::warn!("failed to spawn detached process '{bin}': {e}");
}
}
pub(crate) fn open_path(path: &str) {
if let Err(e) = Command::new(XDG_OPEN).arg(path).spawn() {
tracing::warn!("failed to open path '{path}': {e}");
}
}
pub(crate) fn copy_to_clipboard(val: &str) {
if Command::new(WL_COPY).arg(val).spawn().is_err() {
copy_to_clipboard_xclip(val);
}
}
fn copy_to_clipboard_xclip(val: &str) {
if let Ok(mut child) = Command::new(XCLIP)
.args(["-selection", "clipboard"])
.stdin(Stdio::piped())
.spawn()
&& let Some(stdin) = child.stdin.as_mut()
&& let Err(e) = stdin.write_all(val.as_bytes())
{
tracing::warn!("failed to write to xclip stdin: {e}");
}
}

View File

@@ -0,0 +1,86 @@
pub(crate) struct TerminalCommand {
pub bin: String,
pub exec_flag: Vec<String>,
}
struct KnownTerminal {
bin: &'static str,
exec_flag: &'static str,
}
const KNOWN_TERMINALS: &[KnownTerminal] = &[
KnownTerminal {
bin: "foot",
exec_flag: "-e",
},
KnownTerminal {
bin: "kitty",
exec_flag: "-e",
},
KnownTerminal {
bin: "alacritty",
exec_flag: "-e",
},
KnownTerminal {
bin: "wezterm",
exec_flag: "start",
},
KnownTerminal {
bin: "konsole",
exec_flag: "-e",
},
KnownTerminal {
bin: "xterm",
exec_flag: "-e",
},
];
fn find_in_path(bin: &str) -> bool {
std::env::var_os("PATH")
.iter()
.flat_map(|p| std::env::split_paths(p))
.any(|dir| dir.join(bin).is_file())
}
fn parse_term_cmd(s: &str) -> TerminalCommand {
let mut parts = s.split_whitespace();
let bin = parts.next().unwrap_or("").to_string();
let exec_flag = parts.map(str::to_string).collect();
TerminalCommand { bin, exec_flag }
}
pub(crate) fn resolve_terminal(configured: Option<&str>) -> Option<TerminalCommand> {
if let Some(cmd) = configured.filter(|s| !s.is_empty()) {
let term = parse_term_cmd(cmd);
if !term.bin.is_empty() {
return Some(term);
}
}
if let Ok(val) = std::env::var("TERM_CMD") {
let val = val.trim().to_string();
if !val.is_empty() {
let term = parse_term_cmd(&val);
if !term.bin.is_empty() {
return Some(term);
}
}
}
if let Ok(val) = std::env::var("TERMINAL") {
let bin = val.trim().to_string();
if !bin.is_empty() {
return Some(TerminalCommand {
bin,
exec_flag: vec!["-e".to_string()],
});
}
}
for terminal in KNOWN_TERMINALS {
if find_in_path(terminal.bin) {
return Some(TerminalCommand {
bin: terminal.bin.to_string(),
exec_flag: vec![terminal.exec_flag.to_string()],
});
}
}
None
}

View File

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