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
This commit is contained in:
2026-03-18 12:59:24 +01:00
parent 58d0739cea
commit bd356f27d1
8 changed files with 357 additions and 49 deletions

111
Cargo.lock generated
View File

@@ -386,6 +386,26 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "bincode"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36eaf5d7b090263e8150820482d5d93cd964a81e4019913c972f4edcc6edb740"
dependencies = [
"bincode_derive",
"serde",
"unty",
]
[[package]]
name = "bincode_derive"
version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bf95709a440f45e986983918d0e8a1f30a9b1df04918fc828670606804ac3c09"
dependencies = [
"virtue",
]
[[package]]
name = "bit-set"
version = "0.8.0"
@@ -799,6 +819,15 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "crossbeam-channel"
version = "0.5.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2"
dependencies = [
"crossbeam-utils",
]
[[package]]
name = "crossbeam-deque"
version = "0.8.6"
@@ -861,6 +890,15 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5c297a1c74b71ae29df00c3e22dd9534821d60eb9af5a0192823fa2acea70c2a"
[[package]]
name = "deranged"
version = "0.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
dependencies = [
"powerfmt",
]
[[package]]
name = "dirs"
version = "6.0.0"
@@ -2267,6 +2305,7 @@ dependencies = [
name = "k-launcher"
version = "0.1.0"
dependencies = [
"dirs",
"iced",
"k-launcher-config",
"k-launcher-kernel",
@@ -2279,6 +2318,8 @@ dependencies = [
"plugin-cmd",
"plugin-files",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
]
@@ -2299,6 +2340,7 @@ dependencies = [
"futures",
"serde",
"tokio",
"tracing",
]
[[package]]
@@ -2870,6 +2912,12 @@ dependencies = [
"num-traits",
]
[[package]]
name = "num-conv"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cf97ec579c3c42f953ef76dbf8d55ac91fb219dde70e49aa4a6b7d74e9919050"
[[package]]
name = "num-derive"
version = "0.4.2"
@@ -3474,6 +3522,8 @@ name = "plugin-apps"
version = "0.1.0"
dependencies = [
"async-trait",
"bincode",
"dirs",
"k-launcher-kernel",
"linicon",
"nucleo-matcher",
@@ -3584,6 +3634,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "powerfmt"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "ppv-lite86"
version = "0.2.21"
@@ -4541,6 +4597,37 @@ dependencies = [
"zune-jpeg 0.4.21",
]
[[package]]
name = "time"
version = "0.3.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c"
dependencies = [
"deranged",
"itoa",
"num-conv",
"powerfmt",
"serde_core",
"time-core",
"time-macros",
]
[[package]]
name = "time-core"
version = "0.1.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca"
[[package]]
name = "time-macros"
version = "0.2.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215"
dependencies = [
"num-conv",
"time-core",
]
[[package]]
name = "tiny-skia"
version = "0.11.4"
@@ -4694,6 +4781,18 @@ dependencies = [
"tracing-core",
]
[[package]]
name = "tracing-appender"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "786d480bce6247ab75f005b14ae1624ad978d3029d9113f0a22fa1ac773faeaf"
dependencies = [
"crossbeam-channel",
"thiserror 2.0.18",
"time",
"tracing-subscriber",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
@@ -4845,6 +4944,12 @@ version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "unty"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
[[package]]
name = "url"
version = "2.5.8"
@@ -4924,6 +5029,12 @@ version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "virtue"
version = "0.0.18"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "051eb1abcf10076295e815102942cc58f9d5e3b4560e46e53c21e8ff6f3af7b1"
[[package]]
name = "walkdir"
version = "2.5.0"

View File

@@ -17,6 +17,7 @@ resolver = "2"
[workspace.dependencies]
async-trait = "0.1"
bincode = { version = "2", features = ["serde"] }
dirs = "6.0"
futures = "0.3"
iced = { version = "0.14", features = ["image", "svg", "tokio", "tiny-skia"] }

View File

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

View File

@@ -97,6 +97,17 @@ pub trait SearchEngine: Send + Sync {
async fn search(&self, query: &str) -> Vec<SearchResult>;
}
// --- NullSearchEngine ---
pub struct NullSearchEngine;
#[async_trait]
impl SearchEngine for NullSearchEngine {
async fn search(&self, _query: &str) -> Vec<SearchResult> {
vec![]
}
}
// --- Kernel (Application use case) ---
pub struct Kernel {
@@ -113,9 +124,25 @@ impl Kernel {
}
pub async fn search(&self, query: &str) -> Vec<SearchResult> {
let futures = self.plugins.iter().map(|p| p.search(query));
let nested: Vec<Vec<SearchResult>> = join_all(futures).await;
let mut flat: Vec<SearchResult> = nested.into_iter().flatten().collect();
use futures::FutureExt;
use std::panic::AssertUnwindSafe;
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.truncate(self.max_results);
flat
@@ -203,6 +230,29 @@ mod tests {
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]
async fn kernel_truncates_at_max_results() {
let plugin = Arc::new(MockPlugin::returns(vec![

View File

@@ -34,5 +34,8 @@ plugin-apps = { path = "../plugins/plugin-apps" }
plugin-calc = { path = "../plugins/plugin-calc" }
plugin-cmd = { path = "../plugins/plugin-cmd" }
plugin-files = { path = "../plugins/plugin-files" }
dirs = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
tracing-appender = "0.2"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }

View File

@@ -10,8 +10,30 @@ use plugin_calc::CalcPlugin;
use plugin_cmd::CmdPlugin;
use plugin_files::FilesPlugin;
fn init_logging() -> tracing_appender::non_blocking::WorkerGuard {
use tracing_subscriber::{EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
let log_dir = dirs::data_local_dir()
.map(|d| d.join("k-launcher/logs"))
.unwrap_or_else(|| std::path::PathBuf::from("/tmp/k-launcher/logs"));
std::fs::create_dir_all(&log_dir).ok();
let file_appender = tracing_appender::rolling::daily(&log_dir, "k-launcher.log");
let (non_blocking, guard) = tracing_appender::non_blocking(file_appender);
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() {
tracing_subscriber::fmt::init();
let _guard = init_logging();
if let Err(e) = run_ui() {
eprintln!("error: UI: {e}");
@@ -19,11 +41,8 @@ fn main() {
}
}
fn run_ui() -> iced::Result {
let cfg = k_launcher_config::load();
let launcher = Arc::new(UnixAppLauncher::new());
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()));
@@ -47,9 +66,14 @@ fn run_ui() -> iced::Result {
ext.args.clone(),
)));
}
let kernel: Arc<dyn k_launcher_kernel::SearchEngine> =
Arc::new(Kernel::new(plugins, cfg.search.max_results));
k_launcher_ui::run(kernel, launcher, &cfg.window, cfg.appearance)
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

@@ -9,6 +9,8 @@ path = "src/lib.rs"
[dependencies]
async-trait = { workspace = true }
bincode = { workspace = true }
dirs = { workspace = true }
k-launcher-kernel = { path = "../../k-launcher-kernel" }
nucleo-matcher = "0.3"
serde = { workspace = true }

View File

@@ -2,7 +2,11 @@ pub mod frecency;
#[cfg(target_os = "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 k_launcher_kernel::{LaunchAction, Plugin, ResultId, ResultTitle, Score, SearchResult};
@@ -75,46 +79,139 @@ struct CachedEntry {
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> {
#[cfg(test)]
return None;
#[cfg(not(test))]
dirs::cache_dir().map(|d| d.join("k-launcher/apps.bin"))
}
fn load_from_path(path: &Path, frecency: &Arc<FrecencyStore>) -> 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 store = Arc::clone(frecency);
let record_id = e.id.clone();
let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
store.record(&record_id);
});
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,
on_select,
};
(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());
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 store = Arc::clone(frecency);
let record_id = id.clone();
let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
store.record(&record_id);
});
let cached = CachedEntry {
id: id.clone(),
keywords_lc,
category: e.category,
icon,
exec,
on_select,
name: e.name,
};
(id, cached)
})
.collect()
}
// --- Plugin ---
pub struct AppsPlugin {
entries: HashMap<String, CachedEntry>,
entries: Arc<RwLock<HashMap<String, CachedEntry>>>,
frecency: Arc<FrecencyStore>,
}
impl AppsPlugin {
pub fn new(source: impl DesktopEntrySource, frecency: Arc<FrecencyStore>) -> Self {
let entries = source
.entries()
.into_iter()
.map(|e| {
let id = format!("app-{}", e.name.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 store = Arc::clone(&frecency);
let record_id = id.clone();
let on_select: Arc<dyn Fn() + Send + Sync> = Arc::new(move || {
store.record(&record_id);
});
let cached = CachedEntry {
id: id.clone(),
keywords_lc,
category: e.category,
icon,
exec,
on_select,
name: e.name,
};
(id, cached)
})
.collect();
pub fn new(source: impl DesktopEntrySource + 'static, frecency: Arc<FrecencyStore>) -> Self {
let cached = cache_path().and_then(|p| load_from_path(&p, &frecency));
let entries = if let Some(from_cache) = cached {
// Serve cache immediately; refresh in background.
let map = Arc::new(RwLock::new(from_cache));
let entries_bg = Arc::clone(&map);
let frecency_bg = Arc::clone(&frecency);
std::thread::spawn(move || {
let fresh = build_entries(&source, &frecency_bg);
if let Some(path) = cache_path() {
save_to_path(&path, &fresh);
}
*entries_bg.write().unwrap() = fresh;
});
map
} else {
// No cache: build synchronously, then persist.
let initial = build_entries(&source, &frecency);
if let Some(path) = cache_path() {
save_to_path(&path, &initial);
}
Arc::new(RwLock::new(initial))
};
Self { entries, frecency }
}
}
@@ -171,13 +268,14 @@ impl Plugin for AppsPlugin {
}
async fn search(&self, query: &str) -> Vec<SearchResult> {
let entries = self.entries.read().unwrap();
if query.is_empty() {
return self
.frecency
.top_ids(5)
.iter()
.filter_map(|id| {
let e = self.entries.get(id)?;
let e = entries.get(id)?;
let score = self.frecency.frecency_score(id).max(1);
Some(SearchResult {
id: ResultId::new(id),
@@ -193,7 +291,7 @@ impl Plugin for AppsPlugin {
}
let query_lc = query.to_lowercase();
self.entries
entries
.values()
.filter_map(|e| {
let score = score_match(e.name.as_str(), query).or_else(|| {
@@ -396,4 +494,22 @@ mod tests {
assert_eq!(results.len(), 2);
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, &frecency).unwrap();
assert!(loaded.contains_key("app-Firefox"));
std::fs::remove_file(&cache_file).ok();
}
}